From 8858f804cca31d9e1c6071ea3df919b7dffd6947 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 3 Oct 2025 16:07:15 -0700 Subject: [PATCH 1/7] Proof of concept --- .../labkey/api/data/DataRegionSelection.java | 1094 +- api/webapp/clientapi/dom/DataRegion.js | 9986 +++++++++-------- 2 files changed, 5551 insertions(+), 5529 deletions(-) diff --git a/api/src/org/labkey/api/data/DataRegionSelection.java b/api/src/org/labkey/api/data/DataRegionSelection.java index ec2a3c5e1ca..987341e3b1c 100644 --- a/api/src/org/labkey/api/data/DataRegionSelection.java +++ b/api/src/org/labkey/api/data/DataRegionSelection.java @@ -1,541 +1,553 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed 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. - */ - -package org.labkey.api.data; - -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.collections.LongArrayList; -import org.labkey.api.collections.ResultSetRowMapFactory; -import org.labkey.api.miniprofiler.MiniProfiler; -import org.labkey.api.miniprofiler.Timing; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QueryView; -import org.labkey.api.util.Pair; -import org.labkey.api.util.SessionHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.BadRequestException; -import org.labkey.api.view.DataView; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.ViewContext; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; -import java.io.IOException; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Set; - -/** - * Manages row selection states, scoped to schema/query and possibly a separate selection key. - * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers - * should do their own synchronization on the set itself if they are operating on it one element at a time - * and want to have a consistent view. This allows for the backing set to be a {@link LinkedHashSet}. - * User: kevink - * Date: Jan 3, 2008 - */ -public class DataRegionSelection -{ - public static final String SELECTED_VALUES = ".selectValues"; - public static final String SEPARATOR = "$"; - public static final String DATA_REGION_SELECTION_KEY = "dataRegionSelectionKey"; - - // set/updated using query-setSnapshotSelection - // can be used to hold an arbitrary set of selections in session - // example usage: set a filtered set of selected values in session - public static final String SNAPSHOT_SELECTED_VALUES = ".snapshotSelectValues"; - - private static @NotNull String getSessionAttributeKey(@NotNull String path, @NotNull String key, boolean useSnapshot) - { - return path + key + (useSnapshot ? SNAPSHOT_SELECTED_VALUES : SELECTED_VALUES); - } - - private static @NotNull Set getSet(ViewContext context, @Nullable String key, boolean create) - { - return getSet(context, key, create, false); - } - - /** - * * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers - * * should do their own synchronization on the set itself if they are operating on it one element at a time - * * and want to have a consistent view - */ - private static @NotNull Set getSet(ViewContext context, @Nullable String key, boolean create, boolean useSnapshot) - { - if (key == null) - key = getSelectionKeyFromRequest(context); - - if (key != null) - { - key = getSessionAttributeKey(context.getContainer().getPath(), key, useSnapshot); - var request = context.getRequest(); - HttpSession session = request != null ? context.getRequest().getSession(false) : null; - if (session != null) - { - // Ensure that two different requests don't end up creating two different selection sets - // in the same session - synchronized (SessionHelper.getSessionLock(session)) - { - @SuppressWarnings("unchecked") Set result = (Set) session.getAttribute(key); - if (result == null) - { - result = Collections.synchronizedSet(new LinkedHashSet<>()); - - if (create) - session.setAttribute(key, result); - } - return result; - } - } - } - - return Collections.synchronizedSet(new LinkedHashSet<>()); - } - - /** - * Composes a selection key string used to uniquely identify the selected items - * of a given dataregion. Nulls are allowed. - */ - public static String getSelectionKey(String schemaName, String queryName, String viewName, String dataRegionName) - { - StringBuilder buf = new StringBuilder(); - - for (String s : new String[]{schemaName, queryName, viewName, dataRegionName}) - { - buf.append(SEPARATOR); - if (s != null) - buf.append(s); - } - - return buf.toString(); - } - - /** - * Get selected items from the request parameters including both current page's selection and session state - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelected(ViewContext context) - { - return getSelected(context, null, true); - } - - /** - * Get selected items from the request parameters including both current page's selection and session state - * @param context Used to get the selection key - * @param clearSelection Remove the request parameter selected items from session selection state - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelected(ViewContext context, boolean clearSelection) - { - return getSelected(context, null, clearSelection); - } - - /** - * Tests if selected items are in the request parameters or session state - * @param context Used to get the selection key - * @return true if there are selected item ids, false if not - */ - public static boolean hasSelected(ViewContext context) - { - return !getSelected(context, null, false).isEmpty(); - } - - /** - * Get selected items from the request parameters as integers including both current page's selection and session - * state and clears the state - * @param context Used to get the selection key - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelectedIntegers(ViewContext context) - { - return asLongs(getSelected(context, true)); - } - - /** - * Get selected items from the request parameters as integers including both current page's selection and session state - * @param context Used to get the selection key - * @param clearSelection Remove the request parameter selected items from session selection state - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelectedIntegers(ViewContext context, boolean clearSelection) - { - return asLongs(getSelected(context, null, clearSelection)); - } - - @Nullable - public static String getSelectionKeyFromRequest(ViewContext context) - { - HttpServletRequest request = context.getRequest(); - return request == null ? null : request.getParameter(DATA_REGION_SELECTION_KEY); - } - - /** - * Get the selected items from the request parameters (the current page of a data region) and session state. - * @param context Contains the session - * @param key The data region selection key; if null the DATA_REGION_SELECTION_KEY request parameter will be used - * @param clearSession Remove the request parameter selected items from session selection state - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelected(ViewContext context, @Nullable String key, boolean clearSession) - { - String[] values = null; - var request = context.getRequest(); - if (request != null) - values = context.getRequest().getParameterValues(DataRegion.SELECT_CHECKBOX_NAME); - if (null != values && values.length == 1 && values[0].contains("\t")) - values = StringUtils.split(values[0],'\t'); - List parameterSelected = values == null ? new ArrayList<>() : Arrays.asList(values); - Set result = new LinkedHashSet<>(parameterSelected); - - Set sessionSelected = getSet(context, key, false); - //noinspection SynchronizationOnLocalVariableOrMethodParameter - synchronized (sessionSelected) - { - result.addAll(sessionSelected); - if (clearSession) - sessionSelected.removeAll(result); - } - - return Collections.unmodifiableSet(result); - } - - /** - * Get the selected items from the request parameters (the current page of a data region) and session state as integers. - */ - public static @NotNull Set getSelectedIntegers(ViewContext context, @Nullable String key, boolean clearSession) - { - return asLongs(getSelected(context, key, clearSession)); - } - - public static @NotNull ArrayList getSnapshotSelected(ViewContext context, @Nullable String key) - { - return new ArrayList<>(getSet(context, key, false, true)); - } - - public static @NotNull ArrayList getSnapshotSelectedIntegers(ViewContext context, @Nullable String key) - { - return new LongArrayList(asLongs(getSnapshotSelected(context, key))); - } - - private static @NotNull Set asLongs(Collection ids) - { - Set result = new LinkedHashSet<>(); - for (String s : ids) - { - try - { - result.add(Long.parseLong(s)); - } - catch (NumberFormatException nfe) - { - throw new BadRequestException("Unable to convert " + s + " to an int", nfe); - } - } - - return result; - } - - public static int setSelected(ViewContext context, String key, Collection selection, boolean checked) - { - return setSelected(context, key, selection, checked, false); - } - - /** - * Sets the checked state for the given ids in the session state. - */ - public static int setSelected(ViewContext context, String key, Collection selection, boolean checked, boolean useSnapshot) - { - Set selectedValues = getSet(context, key, true, useSnapshot); - if (checked) - selectedValues.addAll(selection); - else - selectedValues.removeAll(selection); - return selectedValues.size(); - } - - /** - * Clear any session attributes that match the given container path, as the prefix, and the selection key, as the suffix - */ - public static void clearRelatedByContainerPath(ViewContext context, String key) - { - if (key == null || context.getRequest() == null) - return; - - HttpSession session = context.getRequest().getSession(false); - String containerPath = context.getContainer().getPath(); - Collections.list(session.getAttributeNames()).stream() - .filter(name -> name.startsWith(containerPath) && (name.endsWith(key + SNAPSHOT_SELECTED_VALUES) || name.endsWith(key + SELECTED_VALUES))) - .forEach(session::removeAttribute); - } - - private static void clearAll(HttpSession session, String path, String key, boolean isSnapshot) - { - assert path != null : "DataRegion container path required"; - assert key != null : "DataRegion selection key required"; - if (session == null) - return; - session.removeAttribute(getSessionAttributeKey(path, key, isSnapshot)); - } - - /** - * Removes all selection state from the session for RenderContext.getSelectionKey(). - */ - public static void clearAll(RenderContext ctx) - { - clearAll(ctx.getRequest().getSession(false), - ctx.getContainer().getPath(), ctx.getCurrentRegion().getSelectionKey(), false); - } - - /** - * Removes all selection state from the session for the given key. If key is null, the request parameter DATA_REGION_SELECTION_KEY is used. - */ - public static void clearAll(ViewContext context, @Nullable String key) - { - clearAll(context, key, false); - } - - public static void clearAll(ViewContext context, @Nullable String key, boolean isSnapshot) - { - HttpServletRequest request = context.getRequest(); - if (key == null) - key = getSelectionKeyFromRequest(context); - if (key != null && request != null) - clearAll(request.getSession(false), - context.getContainer().getPath(), key, isSnapshot); - } - - /** - * Removes all selection state from the session for the key given by request parameter DATA_REGION_SELECTION_KEY. - */ - public static void clearAll(ViewContext context) - { - clearAll(context, null); - } - - /** - * Gets the ids of the selected items for all items in the given query form's view. That is, - * not just the items on the current page, but all selected items corresponding to the view's filters. - */ - public static List getSelected(QueryForm form, boolean clearSelected) throws IOException - { - List items; - var view = getQueryView(form); - - var selection = getSet(view.getViewContext(), form.getQuerySettings().getSelectionKey(), true); - items = getSelectedItems(view, selection); - - if (clearSelected && !selection.isEmpty()) - { - //noinspection SynchronizationOnLocalVariableOrMethodParameter - synchronized (selection) - { - items.forEach(selection::remove); - } - } - return Collections.unmodifiableList(items); - } - - private static Pair getDataRegionContext(QueryView view) - { - // Turn off features of QueryView - view.setPrintView(true); - view.setShowConfiguredButtons(false); - view.setShowPagination(false); - view.setShowPaginationCount(false); - view.setShowDetailsColumn(false); - view.setShowUpdateColumn(false); - - TableInfo table = view.getTable(); - if (table == null) - { - throw new NotFoundException("Could not find table"); - } - - DataView v = view.createDataView(); - DataRegion rgn = v.getDataRegion(); - - // Include all rows. If only selected rows are included, it does not - // respect filters. - view.getSettings().setShowRows(ShowRows.ALL); - view.getSettings().setOffset(Table.NO_OFFSET); - - RenderContext rc = v.getRenderContext(); - rc.setViewContext(view.getViewContext()); - rc.setCache(false); - - setDataRegionColumnsForSelection(rgn, rc, view, table); - - return Pair.of(rgn, rc); - } - - private static @NotNull QueryView getQueryView(QueryForm form) throws NotFoundException - { - var schema = form.getSchema(); - if (schema == null) - throw new NotFoundException(); - return schema.createView(form, null); - } - - public static List getValidatedIds(@NotNull List selection, QueryForm form) throws IOException - { - return getSelectedItems(getQueryView(form), selection); - } - - /** - * Sets the selection for all items in the given query form's view - */ - public static int setSelectionForAll(QueryForm form, boolean checked) throws IOException - { - return setSelectionForAll(getQueryView(form), form.getQuerySettings().getSelectionKey(), checked); - } - - private static void setDataRegionColumnsForSelection(DataRegion rgn, RenderContext rc, QueryView view, TableInfo table) - { - // force the pk column(s) into the default list of columns - List selectorColNames = rgn.getRecordSelectorValueColumns(); - if (selectorColNames == null) - selectorColNames = table.getPkColumnNames(); - List selectorColumns = new ArrayList<>(); - for (String colName : selectorColNames) - { - if (null == rgn.getDisplayColumn(colName)) { - selectorColumns.add(table.getColumn(colName)); - } - } - ActionURL url = view.getSettings().getSortFilterURL(); - - Sort sort = rc.buildSort(table, url, rgn.getName()); - SimpleFilter filter = rc.buildFilter(table, rc.getColumnInfos(rgn.getDisplayColumns()), url, rgn.getName(), Table.ALL_ROWS, 0, sort); - - // Issue 36600: remove unnecessary columns for performance purposes - rgn.clearColumns(); - // Issue 39011: then add back the columns needed by the filters, if any - Collection filterColumns = QueryService.get().ensureRequiredColumns(table, selectorColumns, filter, sort, null); - rgn.addColumns(selectorColumns); - rgn.addColumns(filterColumns); - } - - public static int setSelectionForAll(QueryView view, String key, boolean checked) throws IOException - { - var regionCtx = getDataRegionContext(view); - var rgn = regionCtx.first; - var rc = regionCtx.second; - - try (Timing ignored = MiniProfiler.step("selectAll"); ResultSet rs = rgn.getResults(rc)) - { - var selection = createSelectionList(rc, rgn, rs, null); - return setSelected(view.getViewContext(), key, selection, checked); - } - catch (SQLException e) - { - throw new RuntimeSQLException(e); - } - } - - /** - * Returns all items in the given result set that are selected and selectable - * @param view the view from which to retrieve the data region context and session variable - * @param selectedValues optionally (nullable) specify a collection of selected values that will be matched - * against when selecting items. If null, then all items will be returned. - * @return list of items from the result set that are in the selected session, or an empty list if none. - */ - private static List getSelectedItems(QueryView view, @NotNull Collection selectedValues) throws IOException - { - // Issue 48657: no need to query the region result set if we have no selectedValues - if (selectedValues.isEmpty()) - return new LinkedList<>(); - - var dataRegionContext = getDataRegionContext(view); - var rgn = dataRegionContext.first; - var ctx = dataRegionContext.second; - - // Issue 48657: no need to query for all region results if we are only interested in a subset, filter for just those we want to verify - // Note: this only currently applies for tables with a single PK col. Consider altering this for multi-pk tables. - List pkCols = rgn.getTable().getPkColumns(); - if (pkCols.size() == 1) - { - ColumnInfo pkCol = pkCols.get(0); - ctx.setBaseFilter(new SimpleFilter(pkCol.getFieldKey(), pkCol.isNumericType() ? selectedValues.stream().map(Integer::parseInt).toList() : selectedValues, CompareType.IN)); - } - - try (Timing ignored = MiniProfiler.step("getSelected"); Results rs = rgn.getResults(ctx)) - { - //noinspection SynchronizationOnLocalVariableOrMethodParameter - synchronized (selectedValues) - { - return createSelectionList(ctx, rgn, rs, selectedValues); - } - } - catch (SQLException e) - { - throw new RuntimeSQLException(e); - } - } - - private static List createSelectionList( - RenderContext ctx, - DataRegion rgn, - ResultSet rs, - @Nullable Collection selectedValues - ) throws SQLException - { - List selected = new LinkedList<>(); - - if (rs != null) - { - ResultSetRowMapFactory factory = ResultSetRowMapFactory.create(rs); - while (rs.next()) - { - ctx.setRow(factory.getRowMap(rs)); - - // Issue 35513: Don't select un-selectables - if (rgn.isRecordSelectorEnabled(ctx)) - { - var value = rgn.getRecordSelectorValue(ctx); - if (selectedValues == null || selectedValues.contains(value)) - selected.add(value); - } - } - } - - return selected; - } - - /** Response used from SelectAll, ClearAll, and similar APIs for bulk selecting/unselecting data rows */ - public static class SelectionResponse extends ApiSimpleResponse - { - public SelectionResponse(int count) - { - super("count", count); - } - } - - public interface DataSelectionKeyForm - { - String getDataRegionSelectionKey(); - void setDataRegionSelectionKey(String key); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed 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. + */ + +package org.labkey.api.data; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.collections.LongArrayList; +import org.labkey.api.collections.ResultSetRowMapFactory; +import org.labkey.api.miniprofiler.MiniProfiler; +import org.labkey.api.miniprofiler.Timing; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QueryView; +import org.labkey.api.util.Formats; +import org.labkey.api.util.Pair; +import org.labkey.api.util.SessionHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.BadRequestException; +import org.labkey.api.view.DataView; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.ViewContext; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.io.IOException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +/** + * Manages row selection states, scoped to schema/query and possibly a separate selection key. + * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers + * should do their own synchronization on the set itself if they are operating on it one element at a time + * and want to have a consistent view. This allows for the backing set to be a {@link LinkedHashSet}. + */ +public class DataRegionSelection +{ + public static final String SELECTED_VALUES = ".selectValues"; + public static final String SEPARATOR = "$"; + public static final String DATA_REGION_SELECTION_KEY = "dataRegionSelectionKey"; + public static final int MAX_SELECTION_SIZE = 1_000; + + // set/updated using query-setSnapshotSelection + // can be used to hold an arbitrary set of selections in session + // example usage: set a filtered set of selected values in session + public static final String SNAPSHOT_SELECTED_VALUES = ".snapshotSelectValues"; + + private static @NotNull String getSessionAttributeKey(@NotNull String path, @NotNull String key, boolean useSnapshot) + { + return path + key + (useSnapshot ? SNAPSHOT_SELECTED_VALUES : SELECTED_VALUES); + } + + private static @NotNull Set getSet(ViewContext context, @Nullable String key, boolean create) + { + return getSet(context, key, create, false); + } + + /** + * * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers + * * should do their own synchronization on the set itself if they are operating on it one element at a time + * * and want to have a consistent view + */ + private static @NotNull Set getSet(ViewContext context, @Nullable String key, boolean create, boolean useSnapshot) + { + if (key == null) + key = getSelectionKeyFromRequest(context); + + if (key != null) + { + key = getSessionAttributeKey(context.getContainer().getPath(), key, useSnapshot); + var request = context.getRequest(); + HttpSession session = request != null ? context.getRequest().getSession(false) : null; + if (session != null) + { + // Ensure that two different requests don't end up creating two different selection sets + // in the same session + synchronized (SessionHelper.getSessionLock(session)) + { + @SuppressWarnings("unchecked") Set result = (Set) session.getAttribute(key); + if (result == null) + { + result = Collections.synchronizedSet(new LinkedHashSet<>()); + + if (create) + session.setAttribute(key, result); + } + return result; + } + } + } + + return Collections.synchronizedSet(new LinkedHashSet<>()); + } + + /** + * Composes a selection key string used to uniquely identify the selected items + * of a given dataregion. Nulls are allowed. + */ + public static String getSelectionKey(String schemaName, String queryName, String viewName, String dataRegionName) + { + StringBuilder buf = new StringBuilder(); + + for (String s : new String[]{schemaName, queryName, viewName, dataRegionName}) + { + buf.append(SEPARATOR); + if (s != null) + buf.append(s); + } + + return buf.toString(); + } + + /** + * Get selected items from the request parameters including both current page's selection and session state + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelected(ViewContext context) + { + return getSelected(context, null, true); + } + + /** + * Get selected items from the request parameters including both current page's selection and session state + * @param context Used to get the selection key + * @param clearSelection Remove the request parameter selected items from session selection state + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelected(ViewContext context, boolean clearSelection) + { + return getSelected(context, null, clearSelection); + } + + /** + * Tests if selected items are in the request parameters or session state + * @param context Used to get the selection key + * @return true if there are selected item ids, false if not + */ + public static boolean hasSelected(ViewContext context) + { + return !getSelected(context, null, false).isEmpty(); + } + + /** + * Get selected items from the request parameters as integers including both current page's selection and session + * state and clears the state + * @param context Used to get the selection key + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelectedIntegers(ViewContext context) + { + return asLongs(getSelected(context, true)); + } + + /** + * Get selected items from the request parameters as integers including both current page's selection and session state + * @param context Used to get the selection key + * @param clearSelection Remove the request parameter selected items from session selection state + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelectedIntegers(ViewContext context, boolean clearSelection) + { + return asLongs(getSelected(context, null, clearSelection)); + } + + @Nullable + public static String getSelectionKeyFromRequest(ViewContext context) + { + HttpServletRequest request = context.getRequest(); + return request == null ? null : request.getParameter(DATA_REGION_SELECTION_KEY); + } + + /** + * Get the selected items from the request parameters (the current page of a data region) and session state. + * @param context Contains the session + * @param key The data region selection key; if null the DATA_REGION_SELECTION_KEY request parameter will be used + * @param clearSession Remove the request parameter selected items from session selection state + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelected(ViewContext context, @Nullable String key, boolean clearSession) + { + String[] values = null; + var request = context.getRequest(); + if (request != null) + values = context.getRequest().getParameterValues(DataRegion.SELECT_CHECKBOX_NAME); + if (null != values && values.length == 1 && values[0].contains("\t")) + values = StringUtils.split(values[0],'\t'); + List parameterSelected = values == null ? new ArrayList<>() : Arrays.asList(values); + Set result = new LinkedHashSet<>(parameterSelected); + + Set sessionSelected = getSet(context, key, false); + //noinspection SynchronizationOnLocalVariableOrMethodParameter + synchronized (sessionSelected) + { + result.addAll(sessionSelected); + if (clearSession) + sessionSelected.removeAll(result); + } + + return Collections.unmodifiableSet(result); + } + + /** + * Get the selected items from the request parameters (the current page of a data region) and session state as integers. + */ + public static @NotNull Set getSelectedIntegers(ViewContext context, @Nullable String key, boolean clearSession) + { + return asLongs(getSelected(context, key, clearSession)); + } + + public static @NotNull ArrayList getSnapshotSelected(ViewContext context, @Nullable String key) + { + return new ArrayList<>(getSet(context, key, false, true)); + } + + public static @NotNull ArrayList getSnapshotSelectedIntegers(ViewContext context, @Nullable String key) + { + return new LongArrayList(asLongs(getSnapshotSelected(context, key))); + } + + private static @NotNull Set asLongs(Collection ids) + { + Set result = new LinkedHashSet<>(); + for (String s : ids) + { + try + { + result.add(Long.parseLong(s)); + } + catch (NumberFormatException nfe) + { + throw new BadRequestException("Unable to convert " + s + " to an int", nfe); + } + } + + return result; + } + + public static int setSelected(ViewContext context, String key, Collection selection, boolean checked) + { + return setSelected(context, key, selection, checked, false); + } + + /** + * Sets the checked state for the given ids in the session state. + */ + public static int setSelected(ViewContext context, String key, Collection selection, boolean checked, boolean useSnapshot) + { + if (checked && selection.size() > MAX_SELECTION_SIZE) + throw new BadRequestException(String.format("Too many selected items: %s. Maximum number of selected items allowed is %s.", Formats.commaf0.format(selection.size()), Formats.commaf0.format(MAX_SELECTION_SIZE))); + + Set selectedValues = getSet(context, key, true, useSnapshot); + if (checked) + { + // TODO: Need to synchronize on this change and not make it if it is too many + selectedValues.addAll(selection); + if (selectedValues.size() > MAX_SELECTION_SIZE) + throw new BadRequestException(String.format("Too many selected items: %s. Maximum number of selected items allowed is %s.", Formats.commaf0.format(selectedValues.size()), Formats.commaf0.format(MAX_SELECTION_SIZE))); + } + else + selectedValues.removeAll(selection); + return selectedValues.size(); + } + + /** + * Clear any session attributes that match the given container path, as the prefix, and the selection key, as the suffix + */ + public static void clearRelatedByContainerPath(ViewContext context, String key) + { + if (key == null || context.getRequest() == null) + return; + + HttpSession session = context.getRequest().getSession(false); + String containerPath = context.getContainer().getPath(); + Collections.list(session.getAttributeNames()).stream() + .filter(name -> name.startsWith(containerPath) && (name.endsWith(key + SNAPSHOT_SELECTED_VALUES) || name.endsWith(key + SELECTED_VALUES))) + .forEach(session::removeAttribute); + } + + private static void clearAll(HttpSession session, String path, String key, boolean isSnapshot) + { + assert path != null : "DataRegion container path required"; + assert key != null : "DataRegion selection key required"; + if (session == null) + return; + session.removeAttribute(getSessionAttributeKey(path, key, isSnapshot)); + } + + /** + * Removes all selection state from the session for RenderContext.getSelectionKey(). + */ + public static void clearAll(RenderContext ctx) + { + clearAll(ctx.getRequest().getSession(false), + ctx.getContainer().getPath(), ctx.getCurrentRegion().getSelectionKey(), false); + } + + /** + * Removes all selection state from the session for the given key. If key is null, the request parameter DATA_REGION_SELECTION_KEY is used. + */ + public static void clearAll(ViewContext context, @Nullable String key) + { + clearAll(context, key, false); + } + + public static void clearAll(ViewContext context, @Nullable String key, boolean isSnapshot) + { + HttpServletRequest request = context.getRequest(); + if (key == null) + key = getSelectionKeyFromRequest(context); + if (key != null && request != null) + clearAll(request.getSession(false), + context.getContainer().getPath(), key, isSnapshot); + } + + /** + * Removes all selection state from the session for the key given by request parameter DATA_REGION_SELECTION_KEY. + */ + public static void clearAll(ViewContext context) + { + clearAll(context, null); + } + + /** + * Gets the ids of the selected items for all items in the given query form's view. That is, + * not just the items on the current page, but all selected items corresponding to the view's filters. + */ + public static List getSelected(QueryForm form, boolean clearSelected) throws IOException + { + List items; + var view = getQueryView(form); + + var selection = getSet(view.getViewContext(), form.getQuerySettings().getSelectionKey(), true); + items = getSelectedItems(view, selection); + + if (clearSelected && !selection.isEmpty()) + { + //noinspection SynchronizationOnLocalVariableOrMethodParameter + synchronized (selection) + { + items.forEach(selection::remove); + } + } + return Collections.unmodifiableList(items); + } + + private static Pair getDataRegionContext(QueryView view) + { + // Turn off features of QueryView + view.setPrintView(true); + view.setShowConfiguredButtons(false); + view.setShowPagination(false); + view.setShowPaginationCount(false); + view.setShowDetailsColumn(false); + view.setShowUpdateColumn(false); + + TableInfo table = view.getTable(); + if (table == null) + { + throw new NotFoundException("Could not find table"); + } + + DataView v = view.createDataView(); + DataRegion rgn = v.getDataRegion(); + + // Include all rows. If only selected rows are included, it does not + // respect filters. + view.getSettings().setShowRows(ShowRows.ALL); + view.getSettings().setOffset(Table.NO_OFFSET); + + RenderContext rc = v.getRenderContext(); + rc.setViewContext(view.getViewContext()); + rc.setCache(false); + + setDataRegionColumnsForSelection(rgn, rc, view, table); + + return Pair.of(rgn, rc); + } + + private static @NotNull QueryView getQueryView(QueryForm form) throws NotFoundException + { + var schema = form.getSchema(); + if (schema == null) + throw new NotFoundException(); + return schema.createView(form, null); + } + + public static List getValidatedIds(@NotNull List selection, QueryForm form) throws IOException + { + return getSelectedItems(getQueryView(form), selection); + } + + /** + * Sets the selection for all items in the given query form's view + */ + public static int setSelectionForAll(QueryForm form, boolean checked) throws IOException + { + return setSelectionForAll(getQueryView(form), form.getQuerySettings().getSelectionKey(), checked); + } + + private static void setDataRegionColumnsForSelection(DataRegion rgn, RenderContext rc, QueryView view, TableInfo table) + { + // force the pk column(s) into the default list of columns + List selectorColNames = rgn.getRecordSelectorValueColumns(); + if (selectorColNames == null) + selectorColNames = table.getPkColumnNames(); + List selectorColumns = new ArrayList<>(); + for (String colName : selectorColNames) + { + if (null == rgn.getDisplayColumn(colName)) { + selectorColumns.add(table.getColumn(colName)); + } + } + ActionURL url = view.getSettings().getSortFilterURL(); + + Sort sort = rc.buildSort(table, url, rgn.getName()); + SimpleFilter filter = rc.buildFilter(table, rc.getColumnInfos(rgn.getDisplayColumns()), url, rgn.getName(), Table.ALL_ROWS, 0, sort); + + // Issue 36600: remove unnecessary columns for performance purposes + rgn.clearColumns(); + // Issue 39011: then add back the columns needed by the filters, if any + Collection filterColumns = QueryService.get().ensureRequiredColumns(table, selectorColumns, filter, sort, null); + rgn.addColumns(selectorColumns); + rgn.addColumns(filterColumns); + } + + public static int setSelectionForAll(QueryView view, String key, boolean checked) throws IOException + { + var regionCtx = getDataRegionContext(view); + var rgn = regionCtx.first; + var rc = regionCtx.second; + + try (Timing ignored = MiniProfiler.step("selectAll"); ResultSet rs = rgn.getResults(rc)) + { + var selection = createSelectionList(rc, rgn, rs, null); + return setSelected(view.getViewContext(), key, selection, checked); + } + catch (SQLException e) + { + throw new RuntimeSQLException(e); + } + } + + /** + * Returns all items in the given result set that are selected and selectable + * @param view the view from which to retrieve the data region context and session variable + * @param selectedValues optionally (nullable) specify a collection of selected values that will be matched + * against when selecting items. If null, then all items will be returned. + * @return list of items from the result set that are in the selected session, or an empty list if none. + */ + private static List getSelectedItems(QueryView view, @NotNull Collection selectedValues) throws IOException + { + // Issue 48657: no need to query the region result set if we have no selectedValues + if (selectedValues.isEmpty()) + return new LinkedList<>(); + + var dataRegionContext = getDataRegionContext(view); + var rgn = dataRegionContext.first; + var ctx = dataRegionContext.second; + + // Issue 48657: no need to query for all region results if we are only interested in a subset, filter for just those we want to verify + // Note: this only currently applies for tables with a single PK col. Consider altering this for multi-pk tables. + List pkCols = rgn.getTable().getPkColumns(); + if (pkCols.size() == 1) + { + ColumnInfo pkCol = pkCols.get(0); + ctx.setBaseFilter(new SimpleFilter(pkCol.getFieldKey(), pkCol.isNumericType() ? selectedValues.stream().map(Integer::parseInt).toList() : selectedValues, CompareType.IN)); + } + + try (Timing ignored = MiniProfiler.step("getSelected"); Results rs = rgn.getResults(ctx)) + { + //noinspection SynchronizationOnLocalVariableOrMethodParameter + synchronized (selectedValues) + { + return createSelectionList(ctx, rgn, rs, selectedValues); + } + } + catch (SQLException e) + { + throw new RuntimeSQLException(e); + } + } + + private static List createSelectionList( + RenderContext ctx, + DataRegion rgn, + ResultSet rs, + @Nullable Collection selectedValues + ) throws SQLException + { + List selected = new ArrayList<>(); + + if (rs != null) + { + ResultSetRowMapFactory factory = ResultSetRowMapFactory.create(rs); + while (rs.next()) + { + ctx.setRow(factory.getRowMap(rs)); + + // Issue 35513: Don't select un-selectables + if (rgn.isRecordSelectorEnabled(ctx)) + { + var value = rgn.getRecordSelectorValue(ctx); + if (selectedValues == null || selectedValues.contains(value)) + { + selected.add(value); + if (selected.size() == MAX_SELECTION_SIZE) + break; + } + } + } + } + + return selected; + } + + /** Response used from SelectAll, ClearAll, and similar APIs for bulk selecting/unselecting data rows */ + public static class SelectionResponse extends ApiSimpleResponse + { + public SelectionResponse(int count) + { + super("count", count); + } + } + + public interface DataSelectionKeyForm + { + String getDataRegionSelectionKey(); + void setDataRegionSelectionKey(String key); + } +} diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index 28899dd1ac4..76f7145dacf 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -1,4988 +1,4998 @@ -/* - * Copyright (c) 2015-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -if (!LABKEY.DataRegions) { - LABKEY.DataRegions = {}; -} - -(function($) { - - // - // CONSTANTS - // - // Issue 48715: Limit the number of rows that can be displayed in a data region - var ALL_ROWS_MAX = 5_000; - var CUSTOM_VIEW_PANELID = '~~customizeView~~'; - var DEFAULT_TIMEOUT = 30_000; - var PARAM_PREFIX = '.param.'; - var SORT_ASC = '+'; - var SORT_DESC = '-'; - - // - // URL PREFIXES - // - var ALL_FILTERS_SKIP_PREFIX = '.~'; - var COLUMNS_PREFIX = '.columns'; - var CONTAINER_FILTER_NAME = '.containerFilterName'; - var MAX_ROWS_PREFIX = '.maxRows'; - var OFFSET_PREFIX = '.offset'; - var REPORTID_PREFIX = '.reportId'; - var SORT_PREFIX = '.sort'; - var SHOW_ROWS_PREFIX = '.showRows'; - var VIEWNAME_PREFIX = '.viewName'; - - // Issue 33536: These prefixes should match the URL parameter key exactly - var EXACT_MATCH_PREFIXES = [ - COLUMNS_PREFIX, - CONTAINER_FILTER_NAME, - MAX_ROWS_PREFIX, - OFFSET_PREFIX, - REPORTID_PREFIX, - SORT_PREFIX, - SHOW_ROWS_PREFIX, - VIEWNAME_PREFIX - ]; - - var VALID_LISTENERS = [ - /** - * @memberOf LABKEY.DataRegion.prototype - * @name afterpanelhide - * @event LABKEY.DataRegion.prototype#hidePanel - * @description Fires after hiding a visible 'Customize Grid' panel. - */ - 'afterpanelhide', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name afterpanelshow - * @event LABKEY.DataRegion.prototype.showPanel - * @description Fires after showing 'Customize Grid' panel. - */ - 'afterpanelshow', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforechangeview - * @event - * @description Fires before changing grid/view/report. - * @see LABKEY.DataRegion#changeView - */ - 'beforechangeview', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforeclearsort - * @event - * @description Fires before clearing sort applied to grid. - * @see LABKEY.DataRegion#clearSort - */ - 'beforeclearsort', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforemaxrowschange - * @event - * @description Fires before change page size. - * @see LABKEY.DataRegion#setMaxRows - */ - 'beforemaxrowschange', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforeoffsetchange - * @event - * @description Fires before change page number. - * @see LABKEY.DataRegion#setPageOffset - */ - 'beforeoffsetchange', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforerefresh - * @event - * @description Fires before refresh grid. - * @see LABKEY.DataRegion#refresh - */ - 'beforerefresh', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforesetparameters - * @event - * @description Fires before setting the parameterized query values for this query. - * @see LABKEY.DataRegion#setParameters - */ - 'beforesetparameters', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforesortchange - * @event - * @description Fires before change sorting on the grid. - * @see LABKEY.DataRegion#changeSort - */ - 'beforesortchange', - /** - * @memberOf LABKEY.DataRegion.prototype - * @member - * @name render - * @event - * @description Fires when data region renders. - */ - 'render', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name selectchange - * @event - * @description Fires when data region selection changes. - */ - 'selectchange', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name success - * @event - * @description Fires when data region loads successfully. - */ - 'success']; - - // TODO: Update constants to not include '.' so mapping can be used easier - var REQUIRE_NAME_PREFIX = { - '~': true, - 'columns': true, - 'param': true, - 'reportId': true, - 'sort': true, - 'offset': true, - 'maxRows': true, - 'showRows': true, - 'containerFilterName': true, - 'viewName': true, - 'disableAnalytics': true - }; - - // - // PRIVATE VARIABLES - // - var _paneCache = {}; - - /** - * The DataRegion constructor is private - to get a LABKEY.DataRegion object, use LABKEY.DataRegions['dataregionname']. - * @class LABKEY.DataRegion - * The DataRegion class allows you to interact with LabKey grids, including querying and modifying selection state, filters, and more. - * @constructor - */ - LABKEY.DataRegion = function(config) { - _init.call(this, config, true); - }; - - LABKEY.DataRegion.prototype.toJSON = function() { - return { - name: this.name, - schemaName: this.schemaName, - queryName: this.queryName, - viewName: this.viewName, - offset: this.offset, - maxRows: this.maxRows, - messages: this.msgbox.toJSON() // hmm, unsure exactly how this works - }; - }; - - /** - * - * @param {Object} config - * @param {Boolean} [applyDefaults=false] - * @private - */ - var _init = function(config, applyDefaults) { - - // ensure name - if (!config.dataRegionName) { - if (!config.name) { - this.name = LABKEY.Utils.id('aqwp'); - } - else { - this.name = config.name; - } - } - else if (!config.name) { - this.name = config.dataRegionName; - } - else { - this.name = config.name; - } - - if (!this.name) { - throw '"name" is required to initialize a LABKEY.DataRegion'; - } - - // _useQWPDefaults is only used on initial construction - var isQWP = config._useQWPDefaults === true; - delete config._useQWPDefaults; - - if (config.buttonBar && config.buttonBar.items && LABKEY.Utils.isArray(config.buttonBar.items)) { - // Be tolerant of the caller passing in undefined items, as pageSize has been removed as an option. Strip - // them out so they don't cause problems downstream. See Issue 34562. - config.buttonBar.items = config.buttonBar.items.filter(function (value, index, arr) { - return value; - }); - } - - var settings; - - if (applyDefaults) { - - // defensively remove, not allowed to be set - delete config._userSort; - - /** - * Config Options - */ - var defaults = { - - _allowHeaderLock: isQWP, - - _failure: isQWP ? LABKEY.Utils.getOnFailure(config) : undefined, - - _success: isQWP ? LABKEY.Utils.getOnSuccess(config) : undefined, - - aggregates: undefined, - - allowChooseQuery: undefined, - - allowChooseView: undefined, - - async: isQWP, - - bodyClass: undefined, - - buttonBar: undefined, - - buttonBarPosition: undefined, - - chartWizardURL: undefined, - - /** - * All rows visible on the current page. - */ - complete: false, - - /** - * The currently applied container filter. Note, this is only if it is set on the URL, otherwise - * the containerFilter could come from the view configuration. Use getContainerFilter() - * on this object to get the right value. - */ - containerFilter: undefined, - - containerPath: undefined, - - /** - * @deprecated use region.name instead - */ - dataRegionName: this.name, - - detailsURL: undefined, - - domId: undefined, - - /** - * The faceted filter pane as been loaded - * @private - */ - facetLoaded: false, - - filters: undefined, - - frame: isQWP ? undefined : 'none', - - errorType: 'html', - - /** - * Id of the DataRegion. Same as name property. - */ - id: this.name, - - deleteURL: undefined, - - importURL: undefined, - - insertURL: undefined, - - linkTarget: undefined, - - /** - * Maximum number of rows to be displayed. 0 if the count is not limited. Read-only. - */ - maxRows: 0, - - metadata: undefined, - - /** - * Name of the DataRegion. Should be unique within a given page. Read-only. This will also be used as the id. - */ - name: this.name, - - /** - * The index of the first row to return from the server (defaults to 0). Use this along with the maxRows config property to request pages of data. - */ - offset: 0, - - parameters: undefined, - - /** - * Name of the query to which this DataRegion is bound. Read-only. - */ - queryName: '', - - disableAnalytics: false, - - removeableContainerFilter: undefined, - - removeableFilters: undefined, - - removeableSort: undefined, - - renderTo: undefined, - - reportId: undefined, - - requestURL: isQWP ? window.location.href : (document.location.search.substring(1) /* strip the ? */ || ''), - - returnUrl: isQWP ? window.location.href : undefined, - - /** - * Schema name of the query to which this DataRegion is bound. Read-only. - */ - schemaName: '', - - /** - * An object to use as the callback function's scope. Defaults to this. - */ - scope: this, - - /** - * URL to use when selecting all rows in the grid. May be null. Read-only. - */ - selectAllURL: undefined, - - selectedCount: 0, - - shadeAlternatingRows: undefined, - - showBorders: undefined, - - showDeleteButton: undefined, - - showDetailsColumn: undefined, - - showExportButtons: undefined, - - showRStudioButton: undefined, - - showImportDataButton: undefined, - - showInsertNewButton: undefined, - - showPagination: undefined, - - showPaginationCount: undefined, - - showPaginationCountAsync: false, - - showRecordSelectors: false, - - showFilterDescription: true, - - showReports: undefined, - - /** - * An enum declaring which set of rows to show. all | selected | unselected | paginated - */ - showRows: 'paginated', - - showSurroundingBorder: undefined, - - showUpdateColumn: undefined, - - /** - * Open the customize view panel after rendering. The value of this option can be "true" or one of "ColumnsTab", "FilterTab", or "SortTab". - */ - showViewPanel: undefined, - - sort: undefined, - - sql: undefined, - - /** - * If true, no alert will appear if there is a problem rendering the QueryWebpart. This is most often encountered if page configuration changes between the time when a request was made and the content loads. Defaults to false. - */ - suppressRenderErrors: false, - - /** - * A timeout for the AJAX call, in milliseconds. - */ - timeout: undefined, - - title: undefined, - - titleHref: undefined, - - totalRows: undefined, // totalRows isn't available when showing all rows. - - updateURL: undefined, - - userContainerFilter: undefined, // TODO: Incorporate this with the standard containerFilter - - userFilters: {}, - - /** - * Name of the custom view to which this DataRegion is bound, may be blank. Read-only. - */ - viewName: null - }; - - settings = $.extend({}, defaults, config); - } - else { - settings = $.extend({}, config); - } - - // if showPaginationCountAsync is set to true, make sure that showPaginationCount is false - if (settings.showPaginationCountAsync && settings.showPaginationCount) { - settings.showPaginationCount = false; - } - - // if 'filters' is not specified and 'filterArray' is, use 'filterArray' - if (!LABKEY.Utils.isArray(settings.filters) && LABKEY.Utils.isArray(config.filterArray)) { - settings.filters = config.filterArray; - } - - // Any 'key' of this object will not be copied from settings to the region instance - var blackList = { - failure: true, - success: true - }; - - for (var s in settings) { - if (settings.hasOwnProperty(s) && !blackList[s]) { - this[s] = settings[s]; - } - } - - if (config.renderTo) { - _convertRenderTo(this, config.renderTo); - } - - if (LABKEY.Utils.isArray(this.removeableFilters)) { - LABKEY.Filter.appendFilterParams(this.userFilters, this.removeableFilters, this.name); - delete this.removeableFilters; // they've been applied - } - - // initialize sorting - if (this._userSort === undefined) { - this._userSort = _getUserSort(this, true /* asString */); - } - - if (LABKEY.Utils.isString(this.removeableSort)) { - this._userSort = this.removeableSort + (this._userSort ? this._userSort : ''); - delete this.removeableSort; - } - - this._allowHeaderLock = this.allowHeaderLock === true; - - if (!config.messages) { - this.messages = {}; - } - - /** - * @ignore - * Non-configurable Options - */ - this.selectionModified = false; - - if (this.panelConfigurations === undefined) { - this.panelConfigurations = {}; - } - - if (isQWP && this.renderTo) { - _load(this); - } - else if (!isQWP) { - _initContexts.call(this); - _initMessaging.call(this); - _initSelection.call(this); - _initPaging.call(this); - _initHeaderLocking.call(this); - _initCustomViews.call(this); - _initPanes.call(this); - _initReport.call(this); - } - // else the user needs to call render - - // bind supported listeners - if (isQWP) { - var me = this; - if (config.listeners) { - var scope = config.listeners.scope || me; - $.each(config.listeners, function(event, handler) { - if ($.inArray(event, VALID_LISTENERS) > -1) { - - // support either "event: function" or "event: { fn: function }" - var callback; - if ($.isFunction(handler)) { - callback = handler; - } - else if ($.isFunction(handler.fn)) { - callback = handler.fn; - } - else { - throw 'Unsupported listener configuration: ' + event; - } - - $(me).bind(event, function() { - callback.apply(scope, $(arguments).slice(1)); - }); - } - else if (event != 'scope') { - throw 'Unsupported listener: ' + event; - } - }); - } - } - }; - - LABKEY.DataRegion.prototype.destroy = function() { - // clean-up panel configurations because we preserve this in init - this.panelConfigurations = {}; - - // currently a no-op, but should be used to clean-up after ourselves - this.disableHeaderLock(); - }; - - /** - * Refreshes the grid, via AJAX region is in async mode (loaded through a QueryWebPart), - * and via a page reload otherwise. Can be prevented with a listener - * on the 'beforerefresh' - * event. - */ - LABKEY.DataRegion.prototype.refresh = function() { - $(this).trigger('beforerefresh', this); - - if (this.async) { - _load(this); - } - else { - window.location.reload(); - } - }; - - // - // Filtering - // - - /** - * Add a filter to this Data Region. - * @param {LABKEY.Filter} filter - * @see LABKEY.DataRegion.addFilter static method. - */ - LABKEY.DataRegion.prototype.addFilter = function(filter) { - this.clearSelected({quiet: true}); - _updateFilter(this, filter); - }; - - /** - * Removes all filters from the DataRegion - */ - LABKEY.DataRegion.prototype.clearAllFilters = function() { - this.clearSelected({quiet: true}); - if (this.async) { - this.offset = 0; - this.userFilters = {}; - } - - _removeParameters(this, [ALL_FILTERS_SKIP_PREFIX, OFFSET_PREFIX]); - }; - - /** - * Removes all the filters for a particular field - * @param {string|FieldKey} fieldKey the name of the field from which all filters should be removed - */ - LABKEY.DataRegion.prototype.clearFilter = function(fieldKey) { - this.clearSelected({quiet: true}); - var fk = _resolveFieldKey(this, fieldKey); - - if (fk) { - var columnPrefix = '.' + fk.toString() + '~'; - - if (this.async) { - this.offset = 0; - - if (this.userFilters) { - var namePrefix = this.name + columnPrefix, - me = this; - - $.each(this.userFilters, function(name, v) { - if (name.indexOf(namePrefix) >= 0) { - delete me.userFilters[name]; - } - }); - } - } - - _removeParameters(this, [columnPrefix, OFFSET_PREFIX]); - } - }; - - /** - * Returns an Array of LABKEY.Filter instances applied when creating this DataRegion. These cannot be removed through the UI. - * @returns {Array} Array of {@link LABKEY.Filter} objects that represent currently applied base filters. - */ - LABKEY.DataRegion.prototype.getBaseFilters = function() { - if (this.filters) { - return this.filters.slice(); - } - - return []; - }; - - /** - * Returns the {@link LABKEY.Query.containerFilter} currently applied to the DataRegion. Defaults to LABKEY.Query.containerFilter.current. - * @returns {String} The container filter currently applied to this DataRegion. Defaults to 'undefined' if a container filter is not specified by the configuration. - * @see LABKEY.DataRegion#getUserContainerFilter to get the containerFilter value from the URL. - */ - LABKEY.DataRegion.prototype.getContainerFilter = function() { - var cf; - - if (LABKEY.Utils.isString(this.containerFilter) && this.containerFilter.length > 0) { - cf = this.containerFilter; - } - else if (LABKEY.Utils.isObject(this.view) && LABKEY.Utils.isString(this.view.containerFilter) && this.view.containerFilter.length > 0) { - cf = this.view.containerFilter; - } - - return cf; - }; - - LABKEY.DataRegion.prototype.getDataRegion = function() { - return this; - }; - - /** - * Returns the user {@link LABKEY.Query.containerFilter} parameter from the URL. - * @returns {LABKEY.Query.containerFilter} The user container filter. - */ - LABKEY.DataRegion.prototype.getUserContainerFilter = function() { - return this.getParameter(this.name + CONTAINER_FILTER_NAME); - }; - - /** - * Returns the user filter from the URL. The filter is represented as an Array of objects of the form: - *
    - *
  • fieldKey: {String} The field key of the filter. - *
  • op: {String} The filter operator (eg. "eq" or "in") - *
  • value: {String} Optional value to filter by. - *
- * @returns {Object} Object representing the user filter. - * @deprecated 12.2 Use getUserFilterArray instead - */ - LABKEY.DataRegion.prototype.getUserFilter = function() { - - if (LABKEY.devMode) { - console.warn([ - 'LABKEY.DataRegion.getUserFilter() is deprecated since release 12.2.', - 'Consider using getUserFilterArray() instead.' - ].join(' ')); - } - - return this.getUserFilterArray().map(function(filter) { - return { - fieldKey: filter.getColumnName(), - op: filter.getFilterType().getURLSuffix(), - value: filter.getValue() - }; - }); - }; - - /** - * Returns an Array of LABKEY.Filter instances constructed from the URL. - * @returns {Array} Array of {@link LABKEY.Filter} objects that represent currently applied filters. - */ - LABKEY.DataRegion.prototype.getUserFilterArray = function() { - var userFilter = [], me = this; - - _getParameters(this).forEach(function(pair) { - if (pair[0].indexOf(me.name + '.') == 0 && pair[0].indexOf('~') > -1) { - var tilde = pair[0].indexOf('~'); - var fieldKey = pair[0].substring(me.name.length + 1, tilde); - var op = pair[0].substring(tilde + 1); - userFilter.push(LABKEY.Filter.create(fieldKey, pair[1], LABKEY.Filter.getFilterTypeForURLSuffix(op))); - } - }); - - return userFilter; - }; - - /** - * Remove a filter on this DataRegion. - * @param {LABKEY.Filter} filter - */ - LABKEY.DataRegion.prototype.removeFilter = function(filter) { - this.clearSelected({quiet: true}); - if (LABKEY.Utils.isObject(filter) && LABKEY.Utils.isFunction(filter.getColumnName)) { - _updateFilter(this, null, [this.name + '.' + filter.getColumnName() + '~']); - } - }; - - /** - * Replace a filter on this Data Region. Optionally, supply another filter to replace for cases when the filter - * columns don't match exactly. - * @param {LABKEY.Filter} filter - * @param {LABKEY.Filter} [filterToReplace] - */ - LABKEY.DataRegion.prototype.replaceFilter = function(filter, filterToReplace) { - this.clearSelected({quiet: true}); - var target = filterToReplace ? filterToReplace : filter; - _updateFilter(this, filter, [this.name + '.' + target.getColumnName() + '~']); - }; - - /** - * @ignore - * @param filters - * @param columnNames - */ - LABKEY.DataRegion.prototype.replaceFilters = function(filters, columnNames) { - this.clearSelected({quiet: true}); - var filterPrefixes = [], - filterParams = [], - me = this; - - if (LABKEY.Utils.isArray(filters)) { - filters.forEach(function(filter) { - filterPrefixes.push(me.name + '.' + filter.getColumnName() + '~'); - filterParams.push([filter.getURLParameterName(me.name), filter.getURLParameterValue()]); - }); - } - - var fieldKeys = []; - - if (LABKEY.Utils.isArray(columnNames)) { - fieldKeys = fieldKeys.concat(columnNames); - } - else if ($.isPlainObject(columnNames) && columnNames.fieldKey) { - fieldKeys.push(columnNames.fieldKey.toString()); - } - - // support fieldKeys (e.g. ["ColumnA", "ColumnA/Sub1"]) - // A special case of fieldKey is "SUBJECT_PREFIX/", used by participant group facet - if (fieldKeys.length > 0) { - _getParameters(this).forEach(function(param) { - var p = param[0]; - if (p.indexOf(me.name + '.') === 0 && p.indexOf('~') > -1) { - $.each(fieldKeys, function(j, name) { - var postfix = name && name.length && name[name.length - 1] == '/' ? '' : '~'; - if (p.indexOf(me.name + '.' + name + postfix) > -1) { - filterPrefixes.push(p); - } - }); - } - }); - } - - _setParameters(this, filterParams, [OFFSET_PREFIX].concat($.unique(filterPrefixes))); - }; - - /** - * @private - * @param filter - * @param filterMatch - */ - LABKEY.DataRegion.prototype.replaceFilterMatch = function(filter, filterMatch) { - this.clearSelected({quiet: true}); - var skips = [], me = this; - - _getParameters(this).forEach(function(param) { - if (param[0].indexOf(me.name + '.') === 0 && param[0].indexOf(filterMatch) > -1) { - skips.push(param[0]); - } - }); - - _updateFilter(this, filter, skips); - }; - - // - // Selection - // - - /** - * @private - */ - var _initSelection = function() { - - var me = this, - form = _getFormSelector(this); - - if (form && form.length) { - // backwards compatibility -- some references use this directly - // if you're looking to use this internally to the region use _getFormSelector() instead - this.form = form[0]; - } - - if (form && this.showRecordSelectors) { - _onSelectionChange(this); - } - - // Bind Events - _getAllRowSelectors(this).on('click', function(evt) { - evt.stopPropagation(); - me.selectPage.call(me, this.checked); - }); - _getRowSelectors(this).on('click', function() { me.selectRow.call(me, this); }); - - // click row highlight - var rows = form.find('.labkey-data-region > tbody > tr'); - rows.on('click', function(e) { - if (e.target && e.target.tagName.toLowerCase() === 'td') { - $(this).siblings('tr').removeClass('lk-row-hl'); - $(this).addClass('lk-row-hl'); - _selClickLock = me; - } - }); - rows.on('mouseenter', function() { - $(this).siblings('tr').removeClass('lk-row-over'); - $(this).addClass('lk-row-over'); - }); - rows.on('mouseleave', function() { - $(this).removeClass('lk-row-over'); - }); - - if (!_selDocClick) { - _selDocClick = $(document).on('click', _onDocumentClick); - } - }; - - var _selClickLock; // lock to prevent removing a row highlight that was just applied - var _selDocClick; // global (shared across all Data Region instances) click event handler instance - - // Issue 32898: Clear row highlights on document click - var _onDocumentClick = function() { - if (_selClickLock) { - var form = _getFormSelector(_selClickLock); - _selClickLock = undefined; - - $('.lk-row-hl').each(function() { - if (!form.has($(this)).length) { - $(this).removeClass('lk-row-hl'); - } - }); - } - else { - $('.lk-row-hl').removeClass('lk-row-hl'); - } - }; - - /** - * Clear all selected items for the current DataRegion. - * - * @param config A configuration object with the following properties: - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'count' of 0 to indicate an empty selection. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion#selectPage - * @see LABKEY.DataRegion.clearSelected static method. - */ - LABKEY.DataRegion.prototype.clearSelected = function(config) { - config = config || {}; - config.selectionKey = this.selectionKey; - config.scope = config.scope || this; - - this.selectedCount = 0; - if (!config.quiet) - { - _onSelectionChange(this); - } - - if (config.selectionKey) { - LABKEY.DataRegion.clearSelected(config); - } - - if (this.showRows == 'selected') { - _removeParameters(this, [SHOW_ROWS_PREFIX]); - } - else if (this.showRows == 'unselected') { - // keep "SHOW_ROWS_PREFIX=unselected" parameter - window.location.reload(true); - } - else { - _toggleAllRows(this, false); - this.removeMessage('selection'); - } - }; - - /** - * Get selected items on the current page of the DataRegion, based on the current state of the checkboxes in the - * browser's DOM. Note, if the region is paginated, selected items may exist on other pages which will not be - * included in the results of this function. - * @see LABKEY.DataRegion#getSelected - */ - LABKEY.DataRegion.prototype.getChecked = function() { - var values = []; - _getRowSelectors(this).each(function() { - if (this.checked) { - values.push(this.value); - } - }); - return values; - }; - - /** - * Get all selected items for this DataRegion, as maintained in server-state. This will include rows on any - * pages of a paginated grid, and may not correspond directly with the state of the checkboxes in the current - * browser window's DOM if the server-side state has been modified. - * - * @param config A configuration object with the following properties: - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'selected' that is an array of the primary keys for the selected rows. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion.getSelected static method. - */ - LABKEY.DataRegion.prototype.getSelected = function(config) { - if (!this.selectionKey) - return; - - config = config || {}; - config.selectionKey = this.selectionKey; - LABKEY.DataRegion.getSelected(config); - }; - - /** - * Returns the number of selected rows on the current page of the DataRegion. Selected items may exist on other pages. - * @returns {Integer} the number of selected rows on the current page of the DataRegion. - * @see LABKEY.DataRegion#getSelected to get all selected rows. - */ - LABKEY.DataRegion.prototype.getSelectionCount = function() { - if (!$('#' + this.domId)) { - return 0; - } - - var count = 0; - _getRowSelectors(this).each(function() { - if (this.checked === true) { - count++; - } - }); - - return count; - }; - - /** - * Returns true if any row is checked on the current page of the DataRegion. Selected items may exist on other pages. - * @returns {Boolean} true if any row is checked on the current page of the DataRegion. - * @see LABKEY.DataRegion#getSelected to get all selected rows. - */ - LABKEY.DataRegion.prototype.hasSelected = function() { - return this.getSelectionCount() > 0; - }; - - /** - * Returns true if all rows are checked on the current page of the DataRegion and at least one row is present. - * @returns {Boolean} true if all rows are checked on the current page of the DataRegion and at least one row is present. - * @see LABKEY.DataRegion#getSelected to get all selected rows. - */ - LABKEY.DataRegion.prototype.isPageSelected = function() { - var checkboxes = _getRowSelectors(this); - var i=0; - - for (; i < checkboxes.length; i++) { - if (!checkboxes[i].checked) { - return false; - } - } - return i > 0; - }; - - LABKEY.DataRegion.prototype.selectAll = function(config) { - if (this.selectionKey) { - config = config || {}; - config.scope = config.scope || this; - - // Either use the selectAllURL provided or create a query config - // object that can be used with the generic query/selectAll.api action. - if (this.selectAllURL) { - config.url = this.selectAllURL; - } - else { - config = LABKEY.Utils.apply(config, this.getQueryConfig()); - } - - config = _chainSelectionCountCallback(this, config); - - LABKEY.DataRegion.selectAll(config); - - if (this.showRows === "selected") { - // keep "SHOW_ROWS_PREFIX=selected" parameter - window.location.reload(true); - } - else if (this.showRows === "unselected") { - _removeParameters(this, [SHOW_ROWS_PREFIX]); - } - else { - _toggleAllRows(this, true); - } - } - }; - - /** - * @deprecated use clearSelected instead - * @function - * @see LABKEY.DataRegion#clearSelected - */ - LABKEY.DataRegion.prototype.selectNone = LABKEY.DataRegion.prototype.clearSelected; - - /** - * Set the selection state for all checkboxes on the current page of the DataRegion. - * @param checked whether all of the rows on the current page should be selected or unselected - * @returns {Array} Array of ids that were selected or unselected. - * - * @see LABKEY.DataRegion#setSelected to set selected items on the current page of the DataRegion. - * @see LABKEY.DataRegion#clearSelected to clear all selected. - */ - LABKEY.DataRegion.prototype.selectPage = function(checked) { - var _check = (checked === true); - var ids = _toggleAllRows(this, _check); - var me = this; - - if (ids.length > 0) { - _getAllRowSelectors(this).each(function() { this.checked = _check}); - this.setSelected({ - ids: ids, - checked: _check, - success: function(data) { - if (data && data.count > 0 && !this.complete) { - var count = data.count; - var msg; - if (me.totalRows) { - if (count == me.totalRows) { - msg = 'All ' + this.totalRows + ' rows selected.'; - } - else { - msg = 'Selected ' + count + ' of ' + this.totalRows + ' rows.'; - } - } - else { - // totalRows isn't available when showing all rows. - msg = 'Selected ' + count + ' rows.'; - } - _showSelectMessage(me, msg); - } - else { - this.removeMessage('selection'); - } - } - }); - } - - return ids; - }; - - /** - * @ignore - * @param el - */ - LABKEY.DataRegion.prototype.selectRow = function(el) { - this.setSelected({ - ids: [el.value], - checked: el.checked - }); - - if (!el.checked) { - this.removeMessage('selection'); - } - }; - - /** - * Add or remove items from the selection associated with the this DataRegion. - * - * @param config A configuration object with the following properties: - * @param {Array} config.ids Array of primary key ids for each row to select/unselect. - * @param {Boolean} config.checked If true, the ids will be selected, otherwise unselected. - * @param {Function} [config.success] The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'count' to indicate the updated selection count. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion#getSelected to get the selected items for this DataRegion. - * @see LABKEY.DataRegion#clearSelected to clear all selected items for this DataRegion. - */ - LABKEY.DataRegion.prototype.setSelected = function(config) { - if (!config || !LABKEY.Utils.isArray(config.ids) || config.ids.length === 0) { - return; - } - - var me = this; - config = config || {}; - config.selectionKey = this.selectionKey; - config.scope = config.scope || me; - - config = _chainSelectionCountCallback(this, config); - - var failure = LABKEY.Utils.getOnFailure(config); - if ($.isFunction(failure)) { - config.failure = failure; - } - else { - config.failure = function() { me.addMessage('Error sending selection.'); }; - } - - if (config.selectionKey) { - LABKEY.DataRegion.setSelected(config); - } - else if ($.isFunction(config.success)) { - // Don't send the selection change to the server if there is no selectionKey. - // Call the success callback directly. - config.success.call(config.scope, {count: this.getSelectionCount()}); - } - }; - - // - // Parameters - // - - /** - * Removes all parameters from the DataRegion - */ - LABKEY.DataRegion.prototype.clearAllParameters = function() { - if (this.async) { - this.offset = 0; - this.parameters = undefined; - } - - _removeParameters(this, [PARAM_PREFIX, OFFSET_PREFIX]); - }; - - /** - * Returns the specified parameter from the URL. Note, this is not related specifically - * to parameterized query values (e.g. setParameters()/getParameters()) - * @param {String} paramName - * @returns {*} - */ - LABKEY.DataRegion.prototype.getParameter = function(paramName) { - var param = null; - - $.each(_getParameters(this), function(i, pair) { - if (pair.length > 0 && pair[0] === paramName) { - param = pair.length > 1 ? pair[1] : ''; - return false; - } - }); - - return param; - }; - - /** - * Get the parameterized query values for this query. These parameters - * are named by the query itself. - * @param {boolean} toLowercase If true, all parameter names will be converted to lowercase - * returns params An Object of key/val pairs. - */ - LABKEY.DataRegion.prototype.getParameters = function(toLowercase) { - - var params = this.parameters ? this.parameters : {}, - re = new RegExp('^' + LABKEY.Utils.escapeRe(this.name) + LABKEY.Utils.escapeRe(PARAM_PREFIX), 'i'), - name; - - _getParameters(this).forEach(function(pair) { - if (pair.length > 0 && pair[0].match(re)) { - name = pair[0].replace(re, ''); - if (toLowercase === true) { - name = name.toLowerCase(); - } - - // URL parameters will override this.parameters values - params[name] = pair[1]; - } - }); - - return params; - }; - - /** - * Set the parameterized query values for this query. These parameters - * are named by the query itself. - * @param {Mixed} params An Object or Array of Array key/val pairs. - */ - LABKEY.DataRegion.prototype.setParameters = function(params) { - var event = $.Event('beforesetparameters'); - - $(this).trigger(event); - - if (event.isDefaultPrevented()) { - return; - } - - var paramPrefix = this.name + PARAM_PREFIX, _params = []; - var newParameters = this.parameters ? this.parameters : {}; - - function applyParameters(pKey, pValue) { - var key = pKey; - if (pKey.indexOf(paramPrefix) !== 0) { - key = paramPrefix + pKey; - } - newParameters[key.replace(paramPrefix, '')] = pValue; - _params.push([key, pValue]); - } - - // convert Object into Array of Array pairs and prefix the parameter name if necessary. - if (LABKEY.Utils.isObject(params)) { - $.each(params, applyParameters); - } - else if (LABKEY.Utils.isArray(params)) { - params.forEach(function(pair) { - if (LABKEY.Utils.isArray(pair) && pair.length > 1) { - applyParameters(pair[0], pair[1]); - } - }); - } - else { - return; // invalid argument shape - } - - this.parameters = newParameters; - - _setParameters(this, _params, [PARAM_PREFIX, OFFSET_PREFIX]); - }; - - /** - * @ignore - * @Deprecated - */ - LABKEY.DataRegion.prototype.setSearchString = function(regionName, search) { - this.savedSearchString = search || ""; - // If the search string doesn't change and there is a hash on the url, the page won't reload. - // Remove the hash by setting the full path plus search string. - window.location.assign(window.location.pathname + (this.savedSearchString.length > 0 ? "?" + this.savedSearchString : "")); - }; - - // - // Messaging - // - - /** - * @private - */ - var _initMessaging = function() { - if (!this.msgbox) { - this.msgbox = new MessageArea(this); - this.msgbox.on('rendermsg', function(evt, msgArea, parts) { _onRenderMessageArea(this, parts); }, this); - } - else { - this.msgbox.bindRegion(this); - } - - if (this.messages) { - this.msgbox.setMessages(this.messages); - this.msgbox.render(); - } - }; - - /** - * Show a message in the header of this DataRegion. - * @param {String / Object} config the HTML source of the message to be shown or a config object with the following properties: - *
    - *
  • html: {String} the HTML source of the message to be shown.
  • - *
  • part: {String} The part of the message area to render the message to.
  • - *
  • duration: {Integer} The amount of time (in milliseconds) the message will stay visible.
  • - *
  • hideButtonPanel: {Boolean} If true the button panel (customize view, export, etc.) will be hidden if visible.
  • - *
  • append: {Boolean} If true the msg is appended to any existing content for the given part.
  • - *
- * @param part The part of the message area to render the message to. Used to scope messages so they can be added - * and removed without clearing other messages. - */ - LABKEY.DataRegion.prototype.addMessage = function(config, part) { - this.hidePanel(); - - if (LABKEY.Utils.isString(config)) { - this.msgbox.addMessage(config, part); - } - else if (LABKEY.Utils.isObject(config)) { - this.msgbox.addMessage(config.html, config.part || part, config.append); - - if (config.hideButtonPanel) { - this.hideButtonPanel(); - } - - if (config.duration) { - var dr = this; - setTimeout(function() { - dr.removeMessage(config.part || part); - _getHeaderSelector(dr).trigger('resize'); - }, config.duration); - } - } - }; - - /** - * Clear the message box contents. - */ - LABKEY.DataRegion.prototype.clearMessage = function() { - if (this.msgbox) this.msgbox.removeAll(); - }; - - /** - * @param part The part of the message area to render the message to. Used to scope messages so they can be added - * and removed without clearing other messages. - * @return {String} The message for 'part'. Could be undefined. - */ - LABKEY.DataRegion.prototype.getMessage = function(part) { - if (this.msgbox) { return this.msgbox.getMessage(part); } // else undefined - }; - - /** - * @param part The part of the message area to render the message to. Used to scope messages so they can be added - * and removed without clearing other messages. - * @return {Boolean} true iff there is a message area for this region and it has the message keyed by 'part'. - */ - LABKEY.DataRegion.prototype.hasMessage = function(part) { - return this.msgbox && this.msgbox.hasMessage(part); - }; - - LABKEY.DataRegion.prototype.hideContext = function() { - _getContextBarSelector(this).hide(); - _getViewBarSelector(this).hide(); - }; - - /** - * If a message is currently showing, hide it and clear out its contents - * @param keepContent If true don't remove the message area content - */ - LABKEY.DataRegion.prototype.hideMessage = function(keepContent) { - if (this.msgbox) { - this.msgbox.hide(); - - if (!keepContent) - this.removeAllMessages(); - } - }; - - /** - * Returns true if a message is currently being shown for this DataRegion. Messages are shown as a header. - * @return {Boolean} true if a message is showing. - */ - LABKEY.DataRegion.prototype.isMessageShowing = function() { - return this.msgbox && this.msgbox.isVisible(); - }; - - /** - * Removes all messages from this Data Region. - */ - LABKEY.DataRegion.prototype.removeAllMessages = function() { - if (this.msgbox) { this.msgbox.removeAll(); } - }; - - /** - * If a message is currently showing, remove the specified part - */ - LABKEY.DataRegion.prototype.removeMessage = function(part) { - if (this.msgbox) { this.msgbox.removeMessage(part); } - }; - - /** - * Show a message in the header of this DataRegion with a loading indicator. - * @param html the HTML source of the message to be shown - */ - LABKEY.DataRegion.prototype.showLoadingMessage = function(html) { - html = html || "Loading..."; - this.addMessage('
 ' + html + '
', 'drloading'); - }; - - LABKEY.DataRegion.prototype.hideLoadingMessage = function() { - this.removeMessage('drloading'); - }; - - /** - * Show a success message in the header of this DataRegion. - * @param html the HTML source of the message to be shown - */ - LABKEY.DataRegion.prototype.showSuccessMessage = function(html) { - html = html || "Completed successfully."; - this.addMessage('
' + html + '
'); - }; - - /** - * Show an error message in the header of this DataRegion. - * @param html the HTML source of the message to be shown - */ - LABKEY.DataRegion.prototype.showErrorMessage = function(html) { - html = html || "An error occurred."; - this.addMessage('
' + html + '
'); - }; - - LABKEY.DataRegion.prototype.showContext = function() { - _initContexts(); - - var contexts = [ - _getContextBarSelector(this), - _getViewBarSelector(this) - ]; - - for (var i = 0; i < contexts.length; i++) { - var ctx = contexts[i]; - var html = ctx.html(); - - if (html && html.trim() !== '') { - ctx.show(); - } - } - }; - - /** - * Show a message in the header of this DataRegion. - * @param msg the HTML source of the message to be shown - * @deprecated use addMessage(msg, part) instead. - */ - LABKEY.DataRegion.prototype.showMessage = function(msg) { - if (this.msgbox) { - this.msgbox.addMessage(msg); - } - }; - - LABKEY.DataRegion.prototype.showMessageArea = function() { - if (this.msgbox && this.msgbox.hasContent()) { - this.msgbox.show(); - } - }; - - // - // Sections - // - - LABKEY.DataRegion.prototype.displaySection = function(options) { - var dir = options && options.dir ? options.dir : 'n'; - - var sec = _getSectionSelector(this, dir); - if (options && options.html) { - options.append === true ? sec.append(options.html) : sec.html(options.html); - } - sec.show(); - }; - - LABKEY.DataRegion.prototype.hideSection = function(options) { - var dir = options && options.dir ? options.dir : 'n'; - var sec = _getSectionSelector(this, dir); - - sec.hide(); - - if (options && options.clear === true) { - sec.html(''); - } - }; - - LABKEY.DataRegion.prototype.writeSection = function(content, options) { - var append = options && options.append === true; - var dir = options && options.dir ? options.dir : 'n'; - - var sec = _getSectionSelector(this, dir); - append ? sec.append(content) : sec.html(content); - }; - - // - // Sorting - // - - /** - * Replaces the sort on the given column, if present, or sets a brand new sort - * @param {string or LABKEY.FieldKey} fieldKey name of the column to be sorted - * @param {string} [sortDir=+] Set to '+' for ascending or '-' for descending - */ - LABKEY.DataRegion.prototype.changeSort = function(fieldKey, sortDir) { - if (!fieldKey) - return; - - fieldKey = _resolveFieldKey(this, fieldKey); - - var columnName = fieldKey.toString(); - - var event = $.Event("beforesortchange"); - - $(this).trigger(event, [this, columnName, sortDir]); - - if (event.isDefaultPrevented()) { - return; - } - - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - this._userSort = _alterSortString(this, this._userSort, fieldKey, sortDir); - _setParameter(this, SORT_PREFIX, this._userSort, [SORT_PREFIX, OFFSET_PREFIX]); - }; - - /** - * Removes the sort on a specified column - * @param {string or LABKEY.FieldKey} fieldKey name of the column - */ - LABKEY.DataRegion.prototype.clearSort = function(fieldKey) { - if (!fieldKey) - return; - - fieldKey = _resolveFieldKey(this, fieldKey); - - var columnName = fieldKey.toString(); - - var event = $.Event("beforeclearsort"); - - $(this).trigger(event, [this, columnName]); - - if (event.isDefaultPrevented()) { - return; - } - - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - this._userSort = _alterSortString(this, this._userSort, fieldKey); - if (this._userSort.length > 0) { - _setParameter(this, SORT_PREFIX, this._userSort, [SORT_PREFIX, OFFSET_PREFIX]); - } - else { - _removeParameters(this, [SORT_PREFIX, OFFSET_PREFIX]); - } - }; - - /** - * Returns the user sort from the URL. The sort is represented as an Array of objects of the form: - *
    - *
  • fieldKey: {String} The field key of the sort. - *
  • dir: {String} The sort direction, either "+" or "-". - *
- * @returns {Object} Object representing the user sort. - */ - LABKEY.DataRegion.prototype.getUserSort = function() { - return _getUserSort(this); - }; - - // - // Paging - // - - var _initPaging = function() { - if (this.showPagination) { - // Issue 51036: load totalRows count async for DataRegions - if (!this.complete && this.showPaginationCountAsync && !this.skipTotalRowCount && this.loadingTotalRows === undefined) { - var params = _getAsyncParams(this, _getParameters(this), false); - var jsonData = _getAsyncBody(this, params); - _loadAsyncTotalRowCount(this, params, jsonData); - } - - var ct = _getBarSelector(this).find('.labkey-pagination'); - - if (ct && ct.length) { - var hasOffset = $.isNumeric(this.offset); - var hasTotal = $.isNumeric(this.totalRows); - - // display the counts - if (hasOffset) { - - // small result set - if (hasTotal && this.totalRows < 5) { - return; - } - - var low = this.offset + 1; - var high = this.offset + this.rowCount; - - // user has opted to show all rows - if (hasTotal && (this.rowCount === null || this.rowCount < 1)) { - high = this.totalRows; - } - - var showFirst = _showFirstEnabled(this); - var showLast = _showLastEnabled(this); - var showAll = _showAllEnabled(this); - this.showFirstID = LABKEY.Utils.id(); - this.showLastID = LABKEY.Utils.id(); - this.showAllID = LABKEY.Utils.id(); - - // If modifying this ensure it is consistent with DOM generated by PopupMenu.java - var elems = [ - ''); - ct.append(elems.join('')); - - //bind functions to menu items - _getShowFirstSelector(this).click(_firstPage.bind(this)); - _getShowLastSelector(this).click(_lastPage.bind(this)); - _getShowAllSelector(this).click(this.showAllRows.bind(this)); - - if (_isMaxRowsAllRows(this) && this.totalRows > this.maxRows) { - this.addMessage('Show all: Displaying the first ' + ALL_ROWS_MAX.toLocaleString() + ' rows. Use paging to see more results.'); - } - - for (var key in offsetIds) { - if (offsetIds.hasOwnProperty(key)) { - $('#' + key).click(_setMaxRows.bind(this, offsetIds[key])); - } - } - - // only display buttons if all the results are not shown - if (low === 1 && high === this.totalRows) { - _getBarSelector(this).find('.paging-widget').css("top", "4px"); - return; - } - - var canNext = this.maxRows > 0 && high !== this.totalRows, - canPrev = this.maxRows > 0 && low > 1, - prevId = LABKEY.Utils.id(), - nextId = LABKEY.Utils.id(); - - ct.append([ - '
', - '', - '', - '
' - ].join('')); - - var prev = $('#' + prevId); - prev.click(_page.bind(this, this.offset - this.maxRows, canPrev)); - if (!canPrev) { - prev.addClass('disabled'); - } - - var next = $('#' + nextId); - next.click(_page.bind(this, this.offset + this.maxRows, canNext)); - if (!canNext) { - next.addClass('disabled'); - } - } - } - } - else { - _getHeaderSelector(this).find('div.labkey-pagination').css('visibility', 'visible'); - } - }; - - var _showFirstEnabled = function(region) { - return region.offset && region.offset > 0; - }; - - var _showLastEnabled = function(region) { - var low = region.offset + 1; - var high = region.offset + region.rowCount; - return !(low === 1 && high === region.totalRows) && (region.offset + region.maxRows <= region.totalRows); - }; - - var _showAllEnabled = function(region) { - return (_showFirstEnabled(region) || _showLastEnabled(region)) && !_isMaxRowsAllRows(region); - }; - - var _getPaginationText = function(region) { - var hasTotal = $.isNumeric(region.totalRows); - var low = region.offset + 1; - var high = region.offset + region.rowCount; - - var paginationText = low.toLocaleString() + ' - ' + high.toLocaleString(); - if (region.showPaginationCount || region.showPaginationCountAsync) { - if (hasTotal) { - paginationText += ' of ' + region.totalRows.toLocaleString(); - } else if (region.loadingTotalRows) { - paginationText += ' of '; - } - } - - return paginationText; - }; - - var _page = function(offset, enabled) { - if (enabled) { - this.setPageOffset(offset); - } - return false; - }; - - var _firstPage = function() { - if (_showFirstEnabled(this)) { - this.setPageOffset(0); - } - return false; - }; - - var _lastPage = function() { - if (_showLastEnabled(this)) { - var lastPageSize = this.totalRows % this.maxRows === 0 ? this.maxRows : this.totalRows % this.maxRows; - this.setPageOffset(this.totalRows - lastPageSize); - } - return false; - }; - - var _setMaxRows = function(rows) { - if (this.maxRows !== rows) { - this.setMaxRows(rows); - } - return false; - }; - - var _isMaxRowsAllRows = function(region) { - return region.maxRows === ALL_ROWS_MAX; - }; - - /** - * Forces the grid to show all rows, up to ALL_ROWS_MAX, without any paging - */ - LABKEY.DataRegion.prototype.showAllRows = function() { - _setMaxRows.bind(this, ALL_ROWS_MAX)(); - }; - - /** - * @deprecated use showAllRows instead - * @function - * @see LABKEY.DataRegion#showAllRows - */ - LABKEY.DataRegion.prototype.showAll = LABKEY.DataRegion.prototype.showAllRows; - - /** - * Forces the grid to show only rows that have been selected - */ - LABKEY.DataRegion.prototype.showSelectedRows = function() { - _showRows(this, 'selected'); - }; - /** - * @deprecated use showSelectedRows instead - * @function - * @see LABKEY.DataRegion#showSelectedRows - */ - LABKEY.DataRegion.prototype.showSelected = LABKEY.DataRegion.prototype.showSelectedRows; - - /** - * Forces the grid to show only rows that have not been selected - */ - LABKEY.DataRegion.prototype.showUnselectedRows = function() { - _showRows(this, 'unselected'); - }; - /** - * @deprecated use showUnselectedRows instead - * @function - * @see LABKEY.DataRegion#showUnselectedRows - */ - LABKEY.DataRegion.prototype.showUnselected = LABKEY.DataRegion.prototype.showUnselectedRows; - - /** - * Forces the grid to do paging based on the current maximum number of rows - */ - LABKEY.DataRegion.prototype.showPaged = function() { - _removeParameters(this, [SHOW_ROWS_PREFIX]); - }; - - /** - * Displays the first page of the grid - */ - LABKEY.DataRegion.prototype.showFirstPage = function() { - this.setPageOffset(0); - }; - /** - * @deprecated use showFirstPage instead - * @function - * @see LABKEY.DataRegion#showFirstPage - */ - LABKEY.DataRegion.prototype.pageFirst = LABKEY.DataRegion.prototype.showFirstPage; - - /** - * Changes the current row offset for paged content - * @param rowOffset row index that should be at the top of the grid - */ - LABKEY.DataRegion.prototype.setPageOffset = function(rowOffset) { - var event = $.Event('beforeoffsetchange'); - - $(this).trigger(event, [this, rowOffset]); - - if (event.isDefaultPrevented()) { - return; - } - - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - // clear sibling parameters - this.showRows = undefined; - - if ($.isNumeric(rowOffset)) { - _setParameter(this, OFFSET_PREFIX, rowOffset, [OFFSET_PREFIX, SHOW_ROWS_PREFIX]); - } - else { - _removeParameters(this, [OFFSET_PREFIX, SHOW_ROWS_PREFIX]); - } - }; - /** - * @deprecated use setPageOffset instead - * @function - * @see LABKEY.DataRegion#setPageOffset - */ - LABKEY.DataRegion.prototype.setOffset = LABKEY.DataRegion.prototype.setPageOffset; - - /** - * Changes the maximum number of rows that the grid will display at one time - * @param newmax the maximum number of rows to be shown - */ - LABKEY.DataRegion.prototype.setMaxRows = function(newmax) { - var event = $.Event('beforemaxrowschange'); - $(this).trigger(event, [this, newmax]); - if (event.isDefaultPrevented()) { - return; - } - - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - // clear sibling parameters - this.showRows = undefined; - this.offset = 0; - - _setParameter(this, MAX_ROWS_PREFIX, newmax, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); - }; - - var _initContexts = function() { - // clear old contents - var ctxBar = _getContextBarSelector(this); - ctxBar.find('.labkey-button-bar').remove(); - - var numFilters = ctxBar.find('.fa-filter').length; - var numParams = ctxBar.find('.fa-question').length; - - var html = []; - - if (numParams > 0) { - html = html.concat([ - '
', - 'Clear Variables', - '
' - ]) - } - - if (numFilters >= 2) { - html = html.concat([ - '
', - '' + - (numParams > 0 ? 'Clear Filters' : 'Clear All') + - '', - '
' - ]); - } - - if (html.length) { - ctxBar.append(html.join('')); - ctxBar.find('.ctx-clear-var').off('click').on('click', $.proxy(this.clearAllParameters, this)); - ctxBar.find('.ctx-clear-all').off('click').on('click', $.proxy(this.clearAllFilters, this)); - } - - // Issue 35396: Support ButtonBarOptions - if (LABKEY.Utils.isArray(this.buttonBarOnRenders)) { - for (var i=0; i < this.buttonBarOnRenders.length; i++) { - var scriptFnName = this.buttonBarOnRenders[i]; - var fnParts = scriptFnName.split('.'); - var scope = window; - var called = false; - - for (var j=0; j < fnParts.length; j++) { - scope = scope[fnParts[j]]; - if (!scope) break; - if (j === fnParts.length - 1 && LABKEY.Utils.isFunction(scope)) { - scope(this); - called = true; - } - } - - if (!called) { - console.warn('Unable to call "' + scriptFnName + '" for DataRegion.ButtonBar.onRender.'); - } - } - } - }; - - // - // Customize View - // - var _initCustomViews = function() { - if (this.view && this.view.session) { - // clear old contents - _getViewBarSelector(this).find('.labkey-button-bar').remove(); - - _getViewBarSelector(this).append([ - '
', - 'This grid view has been modified.', - 'Revert', - 'Edit', - 'Save', - '
' - ].join('')); - _getViewBarSelector(this).find('.unsavedview-revert').off('click').on('click', $.proxy(function() { - _revertCustomView(this); - }, this)); - _getViewBarSelector(this).find('.unsavedview-edit').off('click').on('click', $.proxy(function() { - this.showCustomizeView(undefined); - }, this)); - _getViewBarSelector(this).find('.unsavedview-save').off('click').on('click', $.proxy(function() { - _saveSessionCustomView(this); - }, this)); - } - }; - - /** - * Change the currently selected view to the named view - * @param {Object} view An object which contains the following properties. - * @param {String} [view.type] the type of view, either a 'view' or a 'report'. - * @param {String} [view.viewName] If the type is 'view', then the name of the view. - * @param {String} [view.reportId] If the type is 'report', then the report id. - * @param {Object} urlParameters NOTE: Experimental parameter; may change without warning. A set of filter and sorts to apply as URL parameters when changing the view. - */ - LABKEY.DataRegion.prototype.changeView = function(view, urlParameters) { - var event = $.Event('beforechangeview'); - $(this).trigger(event, [this, view, urlParameters]); - if (event.isDefaultPrevented()) { - return; - } - - var paramValPairs = [], - newSort = [], - skipPrefixes = [OFFSET_PREFIX, SHOW_ROWS_PREFIX, VIEWNAME_PREFIX, REPORTID_PREFIX]; - - // clear sibling parameters - this.viewName = undefined; - this.reportId = undefined; - - if (view) { - if (LABKEY.Utils.isString(view)) { - paramValPairs.push([VIEWNAME_PREFIX, view]); - this.viewName = view; - } - else if (view.type === 'report') { - paramValPairs.push([REPORTID_PREFIX, view.reportId]); - this.reportId = view.reportId; - } - else if (view.type === 'view' && view.viewName) { - paramValPairs.push([VIEWNAME_PREFIX, view.viewName]); - this.viewName = view.viewName; - } - } - - if (urlParameters) { - $.each(urlParameters.filter, function(i, filter) { - paramValPairs.push(['.' + filter.fieldKey + '~' + filter.op, filter.value]); - }); - - if (urlParameters.sort && urlParameters.sort.length > 0) { - $.each(urlParameters.sort, function(i, sort) { - newSort.push((sort.dir === '+' ? '' : sort.dir) + sort.fieldKey); - }); - paramValPairs.push([SORT_PREFIX, newSort.join(',')]); - } - - if (urlParameters.containerFilter) { - paramValPairs.push([CONTAINER_FILTER_NAME, urlParameters.containerFilter]); - } - - // removes all filter, sort, and container filter parameters - skipPrefixes = skipPrefixes.concat([ - ALL_FILTERS_SKIP_PREFIX, SORT_PREFIX, COLUMNS_PREFIX, CONTAINER_FILTER_NAME - ]); - } - - // removes all filter, sort, and container filter parameters - _setParameters(this, paramValPairs, skipPrefixes); - }; - - LABKEY.DataRegion.prototype.getQueryDetails = function(success, failure, scope) { - - var userSort = this.getUserSort(), - userColumns = this.getParameter(this.name + COLUMNS_PREFIX), - fields = [], - viewName = (this.view && this.view.name) || this.viewName || ''; - - var userFilter = this.getUserFilterArray().map(function(filter) { - var fieldKey = filter.getColumnName(); - fields.push(fieldKey); - - return { - fieldKey: fieldKey, - op: filter.getFilterType().getURLSuffix(), - value: filter.getValue() - }; - }); - - userSort.forEach(function(sort) { - fields.push(sort.fieldKey); - }); - - LABKEY.Query.getQueryDetails({ - containerPath: this.containerPath, - schemaName: this.schemaName, - queryName: this.queryName, - viewName: viewName, - fields: fields, - initializeMissingView: true, - success: function(queryDetails) { - success.call(scope || this, queryDetails, viewName, userColumns, userFilter, userSort); - }, - failure: failure, - scope: scope - }); - }; - - /** - * Hides the customize view interface if it is visible. - */ - LABKEY.DataRegion.prototype.hideCustomizeView = function() { - if (this.activePanelId === CUSTOM_VIEW_PANELID) { - this.hideButtonPanel(); - } - }; - - /** - * Show the customize view interface. - * @param activeTab {[String]} Optional. One of "ColumnsTab", "FilterTab", or "SortTab". If no value is specified (or undefined), the ColumnsTab will be shown. - */ - LABKEY.DataRegion.prototype.showCustomizeView = function(activeTab) { - var region = this; - - var panelConfig = this.getPanelConfiguration(CUSTOM_VIEW_PANELID); - - if (!panelConfig) { - - // whistle while we wait - var timerId = setTimeout(function() { - timerId = 0; - region.showLoadingMessage("Opening custom view designer..."); - }, 500); - - LABKEY.DataRegion.loadViewDesigner(function() { - - var success = function(queryDetails, viewName, userColumns, userFilter, userSort) { - timerId > 0 ? clearTimeout(timerId) : this.hideLoadingMessage(); - - // If there was an error parsing the query, we won't be able to render the customize view panel. - if (queryDetails.exception) { - var viewSourceUrl = LABKEY.ActionURL.buildURL('query', 'viewQuerySource.view', this.containerPath, { - schemaName: this.schemaName, - 'query.queryName': this.queryName - }); - var msg = LABKEY.Utils.encodeHtml(queryDetails.exception) + - "  View Source"; - - this.showErrorMessage(msg); - return; - } - - this.customizeView = Ext4.create('LABKEY.internal.ViewDesigner.Designer', { - renderTo: Ext4.getBody().createChild({tag: 'div', customizeView: true, style: {display: 'none'}}), - activeTab: activeTab, - dataRegion: this, - containerPath : this.containerPath, - schemaName: this.schemaName, - queryName: this.queryName, - viewName: viewName, - query: queryDetails, - userFilter: userFilter, - userSort: userSort, - userColumns: userColumns, - userContainerFilter: this.getUserContainerFilter(), - allowableContainerFilters: this.allowableContainerFilters - }); - - this.customizeView.on('viewsave', function(designer, savedViewsInfo, urlParameters) { - _onViewSave.apply(this, [this, designer, savedViewsInfo, urlParameters]); - }, this); - - this.customizeView.on({ - beforedeleteview: function(cv, revert) { - _beforeViewDelete(region, revert); - }, - deleteview: function(cv, success, json) { - _onViewDelete(region, success, json); - } - }); - - var first = true; - - // Called when customize view needs to be shown - var showFn = function(id, panel, element, callback, scope) { - if (first) { - panel.hide(); - panel.getEl().appendTo(Ext4.get(element[0])); - first = false; - } - panel.doLayout(); - $(panel.getEl().dom).slideDown(undefined, function() { - panel.show(); - callback.call(scope); - }); - }; - - // Called when customize view needs to be hidden - var hideFn = function(id, panel, element, callback, scope) { - $(panel.getEl().dom).slideUp(undefined, function() { - panel.hide(); - callback.call(scope); - }); - }; - - this.publishPanel(CUSTOM_VIEW_PANELID, this.customizeView, showFn, hideFn, this); - this.showPanel(CUSTOM_VIEW_PANELID); - }; - var failure = function() { - timerId > 0 ? clearTimeout(timerId) : this.hideLoadingMessage(); - }; - - this.getQueryDetails(success, failure, this); - }, region); - } - else { - if (activeTab) { - panelConfig.panel.setActiveDesignerTab(activeTab); - } - this.showPanel(CUSTOM_VIEW_PANELID); - } - }; - - /** - * @ignore - * @private - * Shows/Hides customize view depending on if it is currently shown - */ - LABKEY.DataRegion.prototype.toggleShowCustomizeView = function() { - if (this.activePanelId === CUSTOM_VIEW_PANELID) { - this.hideCustomizeView(); - } - else { - this.showCustomizeView(undefined); - } - }; - - var _defaultShow = function(panelId, panel, ribbon, cb, cbScope) { - $('#' + panelId).slideDown(undefined, function() { - cb.call(cbScope); - }); - }; - - var _defaultHide = function(panelId, panel, ribbon, cb, cbScope) { - $('#' + panelId).slideUp(undefined, function() { - cb.call(cbScope); - }); - }; - - // TODO this is a pretty bad prototype, consider using config parameter with backward compat option - LABKEY.DataRegion.prototype.publishPanel = function(panelId, panel, showFn, hideFn, scope, friendlyName) { - this.panelConfigurations[panelId] = { - panelId: panelId, - panel: panel, - show: $.isFunction(showFn) ? showFn : _defaultShow, - hide: $.isFunction(hideFn) ? hideFn : _defaultHide, - scope: scope - }; - if (friendlyName && friendlyName !== panelId) - this.panelConfigurations[friendlyName] = this.panelConfigurations[panelId]; - return this; - }; - - LABKEY.DataRegion.prototype.getPanelConfiguration = function(panelId) { - return this.panelConfigurations[panelId]; - }; - - /** - * @ignore - * Hides any panel that is currently visible. Returns a callback once the panel is hidden. - */ - LABKEY.DataRegion.prototype.hidePanel = function(callback, scope) { - if (this.activePanelId) { - var config = this.getPanelConfiguration(this.activePanelId); - if (config) { - - // find the ribbon container - var ribbon = _getDrawerSelector(this); - - config.hide.call(config.scope || this, this.activePanelId, config.panel, ribbon, function() { - this.activePanelId = undefined; - ribbon.hide(); - if ($.isFunction(callback)) { - callback.call(scope || this); - } - LABKEY.Utils.signalWebDriverTest("dataRegionPanelHide"); - $(this).trigger($.Event('afterpanelhide'), [this]); - }, this); - } - } - else { - if ($.isFunction(callback)) { - callback.call(scope || this); - } - } - }; - - LABKEY.DataRegion.prototype.showPanel = function(panelId, callback, scope) { - - var config = this.getPanelConfiguration(panelId); - - if (!config) { - console.error('Unable to find panel for id (' + panelId + '). Use publishPanel() to register a panel to be shown.'); - return; - } - - this.hideContext(); - this.hideMessage(true); - - this.hidePanel(function() { - this.activePanelId = config.panelId; - - // ensure the ribbon is visible - var ribbon = _getDrawerSelector(this); - ribbon.show(); - - config.show.call(config.scope || this, this.activePanelId, config.panel, ribbon, function() { - if ($.isFunction(callback)) { - callback.call(scope || this); - } - LABKEY.Utils.signalWebDriverTest("dataRegionPanelShow"); - $(this).trigger($.Event('afterpanelshow'), [this]); - }, this); - }, this); - }; - - function _hasPanelOpen(dr) { - return dr.activePanelId !== undefined; - } - - function _hasButtonBarMenuOpen(dr) { - return _getBarSelector(dr).find(".lk-menu-drop.open").length > 0; - } - - /** - * Returns true if the user has interacted with the DataRegion by changing - * the selection, opening a button menu, or opening a panel. - * @return {boolean} - * @private - */ - LABKEY.DataRegion.prototype.isUserInteracting = function () { - return this.selectionModified || _hasPanelOpen(this) || _hasButtonBarMenuOpen(this); - }; - - // - // Misc - // - - /** - * @private - */ - var _initHeaderLocking = function() { - if (this._allowHeaderLock === true) { - this.hLock = new HeaderLock(this); - } - }; - - /** - * @private - */ - var _initPanes = function() { - var callbacks = _paneCache[this.name]; - if (callbacks) { - var me = this; - callbacks.forEach(function(config) { - config.cb.call(config.scope || me, me); - }); - delete _paneCache[this.name]; - } - }; - - /** - * @private - */ - var _initReport = function() { - if (LABKEY.Utils.isObject(this.report)) { - this.addMessage({ - html: [ - 'Name:', - LABKEY.Utils.encodeHtml(this.report.name), - 'Source:', - LABKEY.Utils.encodeHtml(this.report.source) - ].join(' '), - part: 'report', - }); - } - }; - - // These study specific functions/constants should be moved out of Data Region - // and into their own dependency. - - var COHORT_LABEL = '/Cohort/Label'; - var ADV_COHORT_LABEL = '/InitialCohort/Label'; - var COHORT_ENROLLED = '/Cohort/Enrolled'; - var ADV_COHORT_ENROLLED = '/InitialCohort/Enrolled'; - - /** - * DO NOT CALL DIRECTLY. This method is private and only available for removing cohort/group filters - * for this Data Region. - * @param subjectColumn - * @param groupNames - * @private - */ - LABKEY.DataRegion.prototype._removeCohortGroupFilters = function(subjectColumn, groupNames) { - this.clearSelected({quiet: true}); - var params = _getParameters(this); - var skips = [], i, p, k; - - var keys = [ - subjectColumn + COHORT_LABEL, - subjectColumn + ADV_COHORT_LABEL, - subjectColumn + COHORT_ENROLLED, - subjectColumn + ADV_COHORT_ENROLLED - ]; - - if (LABKEY.Utils.isArray(groupNames)) { - for (k=0; k < groupNames.length; k++) { - keys.push(subjectColumn + '/' + groupNames[k]); - } - } - - for (i = 0; i < params.length; i++) { - p = params[i][0]; - if (p.indexOf(this.name + '.') === 0) { - for (k=0; k < keys.length; k++) { - if (p.indexOf(keys[k] + '~') > -1) { - skips.push(p); - k = keys.length; // break loop - } - } - } - } - - _updateFilter(this, undefined, skips); - }; - - /** - * DO NOT CALL DIRECTLY. This method is private and only available for replacing advanced cohort filters - * for this Data Region. Remove if advanced cohorts are removed. - * @param filter - * @private - */ - LABKEY.DataRegion.prototype._replaceAdvCohortFilter = function(filter) { - this.clearSelected({quiet: true}); - var params = _getParameters(this); - var skips = [], i, p; - - for (i = 0; i < params.length; i++) { - p = params[i][0]; - if (p.indexOf(this.name + '.') === 0) { - if (p.indexOf(COHORT_LABEL) > -1 || p.indexOf(ADV_COHORT_LABEL) > -1 || p.indexOf(COHORT_ENROLLED) > -1 || p.indexOf(ADV_COHORT_ENROLLED)) { - skips.push(p); - } - } - } - - _updateFilter(this, filter, skips); - }; - - /** - * Looks for a column based on fieldKey, name, displayField, or caption (in that order) - * @param columnIdentifier - * @returns {*} - */ - LABKEY.DataRegion.prototype.getColumn = function(columnIdentifier) { - - var column = null, // backwards compat - isString = LABKEY.Utils.isString, - cols = this.columns; - - if (isString(columnIdentifier) && LABKEY.Utils.isArray(cols)) { - $.each(['fieldKey', 'name', 'displayField', 'caption'], function(i, key) { - $.each(cols, function(c, col) { - if (isString(col[key]) && col[key] == columnIdentifier) { - column = col; - return false; - } - }); - if (column) { - return false; - } - }); - } - - return column; - }; - - /** - * Returns a query config object suitable for passing into LABKEY.Query.selectRows() or other LABKEY.Query APIs. - * @returns {Object} Object representing the query configuration that generated this grid. - */ - LABKEY.DataRegion.prototype.getQueryConfig = function() { - var config = { - dataRegionName: this.name, - dataRegionSelectionKey: this.selectionKey, - schemaName: this.schemaName, - viewName: this.viewName, - sort: this.getParameter(this.name + SORT_PREFIX), - // NOTE: The parameterized query values from QWP are included - parameters: this.getParameters(false), - containerFilter: this.containerFilter - }; - - if (this.queryName) { - config.queryName = this.queryName; - } - else if (this.sql) { - config.sql = this.sql; - } - - var filters = this.getUserFilterArray(); - if (filters.length > 0) { - config.filters = filters; - } - - return config; - }; - - /** - * Hide the ribbon panel. If visible the ribbon panel will be hidden. - */ - LABKEY.DataRegion.prototype.hideButtonPanel = function() { - this.hidePanel(); - this.showContext(); - this.showMessageArea(); - }; - - /** - * Allows for asynchronous rendering of the Data Region. This region must be in "async" mode for - * this to do anything. - * @function - * @param {String} [renderTo] - The element ID where to render the data region. If not given it will default to - * the current renderTo target is. - */ - LABKEY.DataRegion.prototype.render = function(renderTo) { - if (!this.RENDER_LOCK && this.async) { - _convertRenderTo(this, renderTo); - this.refresh(); - } - }; - - /** - * Show a ribbon panel. - * - * first arg can be button on the button bar or target panel id/configuration - */ - - LABKEY.DataRegion.prototype.toggleButtonPanelHandler = function(panelButton) { - _toggleButtonPanel( this, $(panelButton).attr('data-labkey-panel-toggle'), null, true); - }; - - LABKEY.DataRegion.prototype.showButtonPanel = function(panel, optionalTab) { - _toggleButtonPanel(this, panel, optionalTab, false); - }; - - LABKEY.DataRegion.prototype.toggleButtonPanel = function(panel, optionalTab) { - _toggleButtonPanel(this, panel, optionalTab, true); - }; - - var _toggleButtonPanel = function(dr, panel, optionalTab, toggle) { - var ribbon = _getDrawerSelector(dr); - // first check if this is a named panel instead of a button element - var panelId, panelSel; - if (typeof panel === 'string' && dr.getPanelConfiguration(panel)) - panelId = dr.getPanelConfiguration(panel).panelId; - else - panelId = panel; - - if (panelId) { - - panelSel = $('#' + panelId); - - // allow for toggling the state - if (panelId === dr.activePanelId) { - if (toggle) { - dr.hideButtonPanel(); - return; - } - } - else { - // determine if the content needs to be moved to the ribbon - if (ribbon.has(panelSel).length === 0) { - panelSel.detach().appendTo(ribbon); - } - - // determine if this panel has been registered - if (!dr.getPanelConfiguration(panelId) && panelSel.length > 0) { - dr.publishPanel(panelId, panelId); - } - - dr.showPanel(panelId); - } - if (optionalTab) - { - var t = panelSel.find('a[data-toggle="tab"][href="#' + optionalTab + '"]'); - if (!t.length) - t = panelSel.find('a[data-toggle="tab"][data-tabName="' + optionalTab + '"]'); - t.tab('show'); - } - } - }; - - LABKEY.DataRegion.prototype.loadFaceting = function(cb, scope) { - - var region = this; - - var onLoad = function() { - region.facetLoaded = true; - if ($.isFunction(cb)) { - cb.call(scope || this); - } - }; - - LABKEY.requiresExt4ClientAPI(function() { - if (LABKEY.devMode) { - // should match study/ParticipantFilter.lib.xml - LABKEY.requiresScript([ - '/study/ReportFilterPanel.js', - '/study/ParticipantFilterPanel.js' - ], function() { - LABKEY.requiresScript('/dataregion/panel/Facet.js', onLoad); - }); - } - else { - LABKEY.requiresScript('/study/ParticipantFilter.min.js', function() { - LABKEY.requiresScript('/dataregion/panel/Facet.js', onLoad); - }); - } - }, this); - }; - - LABKEY.DataRegion.prototype.showFaceting = function() { - if (this.facetLoaded) { - if (!this.facet) { - this.facet = LABKEY.dataregion.panel.Facet.display(this); - } - this.facet.toggleCollapse(); - } - else { - this.loadFaceting(this.showFaceting, this); - } - }; - - LABKEY.DataRegion.prototype.on = function(evt, callback, scope) { - // Prevent from handing back the jQuery event itself. - $(this).bind(evt, function() { callback.apply(scope || this, $(arguments).slice(1)); }); - }; - - LABKEY.DataRegion.prototype.one = function(evt, callback, scope) { - $(this).one(evt, function() { callback.apply(scope || this, $(arguments).slice(1)); }); - }; - - LABKEY.DataRegion.prototype._onButtonClick = function(buttonId) { - var item = this.findButtonById(this.buttonBar.items, buttonId); - if (item && $.isFunction(item.handler)) { - try { - return item.handler.call(item.scope || this, this); - } - catch(ignore) {} - } - return false; - }; - - LABKEY.DataRegion.prototype.findButtonById = function(items, id) { - if (!items || !items.length || items.length <= 0) { - return null; - } - - var ret; - for (var i = 0; i < items.length; i++) { - if (items[i].id == id) { - return items[i]; - } - ret = this.findButtonById(items[i].items, id); - if (null != ret) { - return ret; - } - } - - return null; - }; - - LABKEY.DataRegion.prototype.headerLock = function() { return this._allowHeaderLock === true; }; - - LABKEY.DataRegion.prototype.disableHeaderLock = function() { - if (this.headerLock() && this.hLock) { - this.hLock.disable(); - this.hLock = undefined; - } - }; - - /** - * Add or remove a summary statistic for a given column in the DataRegion query view. - * @param viewName - * @param colFieldKey - * @param summaryStatName - */ - LABKEY.DataRegion.prototype.toggleSummaryStatForCustomView = function(viewName, colFieldKey, summaryStatName) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - var colProviderNames = []; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey === colFieldKey) - colProviderNames.push(existingProvider.name); - }); - - if (colProviderNames.indexOf(summaryStatName) === -1) { - _addAnalyticsProviderToView.call(this, view, colFieldKey, summaryStatName, true); - } - else { - _removeAnalyticsProviderFromView.call(this, view, colFieldKey, summaryStatName, true); - } - } - }, null, this); - }; - - /** - * Get the array of selected ColumnAnalyticsProviders for the given column FieldKey in a view. - * @param viewName - * @param colFieldKey - * @param callback - * @param callbackScope - */ - LABKEY.DataRegion.prototype.getColumnAnalyticsProviders = function(viewName, colFieldKey, callback, callbackScope) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - var colProviderNames = []; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey === colFieldKey) { - colProviderNames.push(existingProvider.name); - } - }); - - if ($.isFunction(callback)) { - callback.call(callbackScope, colProviderNames); - } - } - }, null, this); - }; - - /** - * Set the summary statistic ColumnAnalyticsProviders for the given column FieldKey in the view. - * @param viewName - * @param colFieldKey - * @param summaryStatProviderNames - */ - LABKEY.DataRegion.prototype.setColumnSummaryStatistics = function(viewName, colFieldKey, summaryStatProviderNames) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - var newAnalyticsProviders = []; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey !== colFieldKey || existingProvider.name.indexOf('AGG_') != 0) { - newAnalyticsProviders.push(existingProvider); - } - }); - - $.each(summaryStatProviderNames, function(index, providerName) { - newAnalyticsProviders.push({ - fieldKey: colFieldKey, - name: providerName, - isSummaryStatistic: true - }); - }); - - view.analyticsProviders = newAnalyticsProviders; - _updateSessionCustomView.call(this, view, true); - } - }, null, this); - }; - - /** - * Used via SummaryStatisticsAnalyticsProvider to show a dialog of the applicable summary statistics for a column in the view. - * @param colFieldKey - */ - LABKEY.DataRegion.prototype.showColumnStatisticsDialog = function(colFieldKey) { - LABKEY.requiresScript('query/ColumnSummaryStatistics', function() { - var regionViewName = this.viewName || "", - column = this.getColumn(colFieldKey); - - if (column) { - this.getColumnAnalyticsProviders(regionViewName, colFieldKey, function(colSummaryStats) { - Ext4.create('LABKEY.ext4.ColumnSummaryStatisticsDialog', { - queryConfig: this.getQueryConfig(), - filterArray: LABKEY.Filter.getFiltersFromUrl(this.selectAllURL, 'query'), //Issue 26594 - containerPath: this.containerPath, - column: column, - initSelection: colSummaryStats, - listeners: { - scope: this, - applySelection: function(win, colSummaryStatsNames) { - win.getEl().mask("Applying selection..."); - this.setColumnSummaryStatistics(regionViewName, colFieldKey, colSummaryStatsNames); - win.close(); - } - } - }).show(); - }, this); - } - }, this); - }; - - /** - * Remove a column from the given DataRegion query view. - * @param viewName - * @param colFieldKey - */ - LABKEY.DataRegion.prototype.removeColumn = function(viewName, colFieldKey) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - var colFieldKeys = $.map(view.columns, function (c) { - return c.fieldKey; - }), - fieldKeyIndex = colFieldKeys.indexOf(colFieldKey); - - if (fieldKeyIndex > -1) { - view.columns.splice(fieldKeyIndex, 1); - _updateSessionCustomView.call(this, view, true); - } - } - }, null, this); - }; - - /** - * Add the enabled analytics provider to the custom view definition based on the column fieldKey and provider name. - * In addition, disable the column menu item if the column is visible in the grid. - * @param viewName - * @param colFieldKey - * @param providerName - */ - LABKEY.DataRegion.prototype.addAnalyticsProviderForCustomView = function(viewName, colFieldKey, providerName) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - _addAnalyticsProviderToView.call(this, view, colFieldKey, providerName, false); - _updateAnalyticsProviderMenuItem(this.name + ':' + colFieldKey, providerName, true); - } - }, null, this); - }; - - /** - * Remove an enabled analytics provider from the custom view definition based on the column fieldKey and provider name. - * In addition, enable the column menu item if the column is visible in the grid. - * @param viewName - * @param colFieldKey - * @param providerName - */ - LABKEY.DataRegion.prototype.removeAnalyticsProviderForCustomView = function(viewName, colFieldKey, providerName) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - _removeAnalyticsProviderFromView.call(this, view, colFieldKey, providerName, false); - _updateAnalyticsProviderMenuItem(this.name + ':' + colFieldKey, providerName, false); - } - }, null, this); - }; - - /** - * @private - */ - LABKEY.DataRegion.prototype._openFilter = function(columnName, evt) { - if (evt && $(evt.target).hasClass('fa-close')) { - return; - } - - var column = this.getColumn(columnName); - - if (column) { - var show = function() { - this._dialogLoaded = true; - new LABKEY.FilterDialog({ - dataRegionName: this.name, - column: this.getColumn(columnName), - cacheFacetResults: false // could have changed on Ajax - }).show(); - }.bind(this); - - this._dialogLoaded ? show() : LABKEY.requiresExt3ClientAPI(show); - } - else { - LABKEY.Utils.alert('Column not available', 'Unable to find column "' + columnName + '" in this view.'); - } - }; - - var _updateSessionCustomView = function(customView, requiresRefresh) { - var viewConfig = $.extend({}, customView, { - shared: false, - inherit: false, - hidden: false, - session: true - }); - - LABKEY.Query.saveQueryViews({ - containerPath: this.containerPath, - schemaName: this.schemaName, - queryName: this.queryName, - views: [viewConfig], - scope: this, - success: function(info) { - if (requiresRefresh) { - this.refresh(); - } - else if (info.views.length === 1) { - this.view = info.views[0]; - _initCustomViews.call(this); - this.showContext(); - } - } - }); - }; - - var _addAnalyticsProviderToView = function(view, colFieldKey, providerName, isSummaryStatistic) { - var colProviderNames = []; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey === colFieldKey) - colProviderNames.push(existingProvider.name); - }); - - if (colProviderNames.indexOf(providerName) === -1) { - view.analyticsProviders.push({ - fieldKey: colFieldKey, - name: providerName, - isSummaryStatistic: isSummaryStatistic - }); - - _updateSessionCustomView.call(this, view, isSummaryStatistic); - } - }; - - var _removeAnalyticsProviderFromView = function(view, colFieldKey, providerName, isSummaryStatistic) { - var indexToRemove = null; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey === colFieldKey && existingProvider.name === providerName) { - indexToRemove = index; - return false; - } - }); - - if (indexToRemove != null) { - view.analyticsProviders.splice(indexToRemove, 1); - _updateSessionCustomView.call(this, view, isSummaryStatistic); - } - }; - - /** - * Attempt to find a DataRegion analytics provider column menu item so that it can be either enabled to allow - * it to once again be selected after removal or disabled so that it can't be selected a second time. - * @param columnName the DataRegion column th element column-name attribute - * @param providerName the analytics provider name - * @param disable - * @private - */ - var _updateAnalyticsProviderMenuItem = function(columnName, providerName, disable) { - var menuItemEl = $("th[column-name|='" + columnName + "']").find("a[onclick*='" + providerName + "']").parent(); - if (menuItemEl) { - if (disable) { - menuItemEl.addClass('disabled'); - } - else { - menuItemEl.removeClass('disabled'); - } - } - }; - - // - // PRIVATE FUNCTIONS - // - var _applyOptionalParameters = function(region, params, optionalParams) { - optionalParams.forEach(function(p) { - if (LABKEY.Utils.isObject(p)) { - if (region[p.name] !== undefined) { - if (p.check && !p.check.call(region, region[p.name])) { - return; - } - if (p.prefix) { - params[region.name + '.' + p.name] = region[p.name]; - } - else { - params[p.name] = region[p.name]; - } - } - } - else if (p && region[p] !== undefined) { - params[p] = region[p]; - } - }); - }; - - var _alterSortString = function(region, current, fieldKey, direction /* optional */) { - fieldKey = _resolveFieldKey(region, fieldKey); - - var columnName = fieldKey.toString(), - newSorts = []; - - if (current != null) { - current.split(',').forEach(function(sort) { - if (sort.length > 0 && (sort != columnName) && (sort != SORT_ASC + columnName) && (sort != SORT_DESC + columnName)) { - newSorts.push(sort); - } - }); - } - - if (direction === SORT_ASC) { // Easier to read without the encoded + on the URL... - direction = ''; - } - - if (LABKEY.Utils.isString(direction)) { - newSorts = [direction + columnName].concat(newSorts); - } - - return newSorts.join(','); - }; - - var _ensureFilterDateFormat = function(value) { - if (LABKEY.Utils.isDate(value)) { - value = $.format.date(value, 'yyyy-MM-dd'); - if (LABKEY.Utils.endsWith(value, 'Z')) { - value = value.substring(0, value.length - 1); - } - } - - return value; - } - - var _buildQueryString = function(region, pairs) { - if (!LABKEY.Utils.isArray(pairs)) { - return ''; - } - - var queryParts = [], key, value; - - pairs.forEach(function(pair) { - key = pair[0]; - value = pair.length > 1 ? pair[1] : undefined; - - queryParts.push(encodeURIComponent(key)); - if (LABKEY.Utils.isDefined(value)) { - - value = _ensureFilterDateFormat(value); - queryParts.push('='); - queryParts.push(encodeURIComponent(value)); - } - queryParts.push('&'); - }); - - if (queryParts.length > 0) { - queryParts.pop(); - } - - return queryParts.join(""); - }; - - var _chainSelectionCountCallback = function(region, config) { - - var success = LABKEY.Utils.getOnSuccess(config); - - // On success, update the current selectedCount on this DataRegion and fire the 'selectchange' event - config.success = function(data) { - region.selectionModified = true; - region.selectedCount = data.count; - _onSelectionChange(region); - - // Chain updateSelected with the user-provided success callback - if ($.isFunction(success)) { - success.call(config.scope, data); - } - }; - - return config; - }; - - var _convertRenderTo = function(region, renderTo) { - if (renderTo) { - if (LABKEY.Utils.isString(renderTo)) { - region.renderTo = renderTo; - } - else if (LABKEY.Utils.isString(renderTo.id)) { - region.renderTo = renderTo.id; // support 'Ext' elements - } - else { - throw 'Unsupported "renderTo"'; - } - } - - return region; - }; - - var _deleteTimer; - - var _beforeViewDelete = function(region, revert) { - _deleteTimer = setTimeout(function() { - _deleteTimer = 0; - region.showLoadingMessage(revert ? 'Reverting view...' : 'Deleting view...'); - }, 500); - }; - - var _onViewDelete = function(region, success, json) { - if (_deleteTimer) { - clearTimeout(_deleteTimer); - } - - if (success) { - region.removeMessage.call(region, 'customizeview'); - region.showSuccessMessage.call(region); - - // change view to either a shadowed view or the default view - var config = { type: 'view' }; - if (json.viewName) { - config.viewName = json.viewName; - } - region.changeView.call(region, config); - } - else { - region.removeMessage.call(region, 'customizeview'); - region.showErrorMessage.call(region, json.exception); - } - }; - - // The view can be reverted without ViewDesigner present - var _revertCustomView = function(region) { - _beforeViewDelete(region, true); - - var config = { - schemaName: region.schemaName, - queryName: region.queryName, - containerPath: region.containerPath, - revert: true, - success: function(json) { - _onViewDelete(region, true /* success */, json); - }, - failure: function(json) { - _onViewDelete(region, false /* success */, json); - } - }; - - if (region.viewName) { - config.viewName = region.viewName; - } - - LABKEY.Query.deleteQueryView(config); - }; - - var _getViewFromQueryDetails = function(queryDetails, viewName) { - var matchingView; - - $.each(queryDetails.views, function(index, view) { - if (view.name === viewName) { - matchingView = view; - return false; - } - }); - - return matchingView; - }; - - var _viewContainsColumn = function(view, colFieldKey) { - var keys = $.map(view.columns, function(c) { - return c.fieldKey.toLowerCase(); - }); - var exists = colFieldKey && keys.indexOf(colFieldKey.toLowerCase()) > -1; - - if (!exists) { - console.warn('Unable to find column in view: ' + colFieldKey); - } - - return exists; - }; - - var _getAllRowSelectors = function(region) { - return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".toggle"]'); - }; - - var _getBarSelector = function(region) { - return $('#' + region.domId + '-headerbar'); - }; - - var _getContextBarSelector = function(region) { - return $('#' + region.domId + '-ctxbar'); - }; - - var _getDrawerSelector = function(region) { - return $('#' + region.domId + '-drawer'); - }; - - var _getFormSelector = function(region) { - var form = $('form#' + region.domId + '-form'); - - // derived DataRegion's may not include the form id - if (form.length === 0) { - form = $('#' + region.domId).closest('form'); - } - - return form; - }; - - var _getHeaderSelector = function(region) { - return $('#' + region.domId + '-header'); - }; - - var _getRowSelectors = function(region) { - return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".select"]'); - }; - - var _getSectionSelector = function(region, dir) { - return $('#' + region.domId + '-section-' + dir); - }; - - var _getShowFirstSelector = function(region) { - return $('#' + region.showFirstID); - }; - - var _getShowLastSelector = function(region) { - return $('#' + region.showLastID); - }; - - var _getShowAllSelector = function(region) { - return $('#' + region.showAllID); - }; - - // Formerly, LABKEY.DataRegion.getParamValPairsFromString / LABKEY.DataRegion.getParamValPairs - var _getParameters = function(region, skipPrefixSet /* optional */) { - - var params = []; - var qString = region.requestURL; - - if (LABKEY.Utils.isString(qString) && qString.length > 0) { - - var qmIdx = qString.indexOf('?'); - if (qmIdx > -1) { - qString = qString.substring(qmIdx + 1); - - var poundIdx = qString.indexOf('#'); - if (poundIdx > -1) - qString = qString.substr(0, poundIdx); - - if (qString.length > 1) { - var pairs = qString.split('&'), p, key, - LAST = '.lastFilter', lastIdx, skip = LABKEY.Utils.isArray(skipPrefixSet); - - var exactMatches = EXACT_MATCH_PREFIXES.map(function (prefix) { - return region.name + prefix; - }); - - $.each(pairs, function (i, pair) { - p = pair.split('=', 2); - key = p[0] = decodeURIComponent(p[0]); - lastIdx = key.indexOf(LAST); - - if (lastIdx > -1 && lastIdx === (key.length - LAST.length)) { - return; - } - else if (REQUIRE_NAME_PREFIX.hasOwnProperty(key)) { - // Issue 26686: Block known parameters, should be prefixed by region name - return; - } - - var stop = false; - if (skip) { - $.each(skipPrefixSet, function (j, skipPrefix) { - if (LABKEY.Utils.isString(skipPrefix)) { - - // Special prefix that should remove all filters, but no other parameters for the current grid - if (skipPrefix.indexOf(ALL_FILTERS_SKIP_PREFIX) === (skipPrefix.length - 2)) { - if (key.indexOf(region.name + '.') === 0 && key.indexOf('~') > 0) { - - stop = true; - return false; - } - } - else { - if (exactMatches.indexOf(skipPrefix) > -1) { - if (key === skipPrefix) { - stop = true; - return false; - } - } - else if (key.toLowerCase().indexOf(skipPrefix.toLowerCase()) === 0) { - // only skip filters, parameters, and sorts for the current grid - if (key.indexOf(region.name + '.') === 0 && - - (key === skipPrefix || - key.indexOf('~') > 0 || - key.indexOf(PARAM_PREFIX) > 0 || - key === (skipPrefix + 'sort'))) { - stop = true; - return false; - } - } - } - } - }); - } - - if (!stop) { - if (p.length > 1) { - p[1] = decodeURIComponent(p[1]); - } - params.push(p); - } - }); - } - } - } - - return params; - }; - - /** - * - * @param region - * @param {boolean} [asString=false] - * @private - */ - var _getUserSort = function(region, asString) { - var userSort = [], - sortParam = region.getParameter(region.name + SORT_PREFIX); - - if (asString) { - userSort = sortParam || ''; - } - else { - if (sortParam) { - var fieldKey, dir; - sortParam.split(',').forEach(function(sort) { - fieldKey = sort; - dir = SORT_ASC; - if (sort.charAt(0) === SORT_DESC) { - fieldKey = fieldKey.substring(1); - dir = SORT_DESC; - } - else if (sort.charAt(0) === SORT_ASC) { - fieldKey = fieldKey.substring(1); - } - userSort.push({fieldKey: fieldKey, dir: dir}); - }); - } - } - - return userSort; - }; - - var _getViewBarSelector = function(region) { - return $('#' + region.domId + '-viewbar'); - }; - - var _buttonSelectionBind = function(region, cls, fn) { - var partEl = region.msgbox.getParent().find('div[data-msgpart="selection"]'); - partEl.find('.labkey-button' + cls).off('click').on('click', $.proxy(function() { - fn.call(this); - }, region)); - }; - - var _onRenderMessageArea = function(region, parts) { - var msgArea = region.msgbox; - if (msgArea) { - if (region.showRecordSelectors && parts['selection']) { - _buttonSelectionBind(region, '.select-all', region.selectAll); - _buttonSelectionBind(region, '.select-none', region.clearSelected); - _buttonSelectionBind(region, '.show-all', region.showAll); - _buttonSelectionBind(region, '.show-selected', region.showSelectedRows); - _buttonSelectionBind(region, '.show-unselected', region.showUnselectedRows); - } - else if (parts['customizeview']) { - _buttonSelectionBind(region, '.unsavedview-revert', function() { _revertCustomView(this); }); - _buttonSelectionBind(region, '.unsavedview-edit', function() { this.showCustomizeView(undefined); }); - _buttonSelectionBind(region, '.unsavedview-save', function() { _saveSessionCustomView(this); }); - } - } - }; - - var _onSelectionChange = function(region) { - $(region).trigger('selectchange', [region, region.selectedCount]); - _updateRequiresSelectionButtons(region, region.selectedCount); - LABKEY.Utils.signalWebDriverTest('dataRegionUpdate', region.selectedCount); - LABKEY.Utils.signalWebDriverTest('dataRegionUpdate-' + region.name, region.selectedCount); - }; - - var _onViewSave = function(region, designer, savedViewsInfo, urlParameters) { - if (savedViewsInfo && savedViewsInfo.views.length > 0) { - region.hideCustomizeView.call(region); - region.changeView.call(region, { - type: 'view', - viewName: savedViewsInfo.views[0].name - }, urlParameters); - } - }; - - var _removeParameters = function(region, skipPrefixes /* optional */) { - return _setParameters(region, null, skipPrefixes); - }; - - var _resolveFieldKey = function(region, fieldKey) { - var fk = fieldKey; - if (!(fk instanceof LABKEY.FieldKey)) { - fk = LABKEY.FieldKey.fromString('' + fk); - } - return fk; - }; - - var _saveSessionCustomView = function(region) { - // Note: currently only will save session views. Future version could create a new view using url sort/filters. - if (!(region.view && region.view.session)) { - return; - } - - // Get the canEditSharedViews permission and candidate targetContainers. - var viewName = (region.view && region.view.name) || region.viewName || ''; - - LABKEY.Query.getQueryDetails({ - schemaName: region.schemaName, - queryName: region.queryName, - viewName: viewName, - initializeMissingView: false, - containerPath: region.containerPath, - success: function (json) { - // Display an error if there was an issue error getting the query details - if (json.exception) { - var viewSourceUrl = LABKEY.ActionURL.buildURL('query', 'viewQuerySource.view', null, {schemaName: this.schemaName, "query.queryName": this.queryName}); - var msg = LABKEY.Utils.encodeHtml(json.exception) + "  View Source"; - - this.showErrorMessage.call(this, msg); - return; - } - - _saveSessionShowPrompt(this, json); - }, - scope: region - }); - }; - - var _saveSessionView = function(o, region, win) { - var timerId = setTimeout(function() { - timerId = 0; - Ext4.Msg.progress("Saving...", "Saving custom view..."); - }, 500); - - var jsonData = { - schemaName: region.schemaName, - "query.queryName": region.queryName, - "query.viewName": region.viewName, - newName: o.name, - inherit: o.inherit, - shared: o.shared, - hidden: o.hidden, - replace: o.replace, - }; - - if (o.inherit) { - jsonData.containerPath = o.containerPath; - } - - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'saveSessionView', region.containerPath), - method: 'POST', - jsonData: jsonData, - callback: function() { - if (timerId > 0) - clearTimeout(timerId); - win.close(); - }, - success: function() { - region.showSuccessMessage.call(region); - region.changeView.call(region, {type: 'view', viewName: o.name}); - }, - failure: function(resp) { - var json = resp.responseText ? Ext4.decode(resp.responseText) : resp; - if (json.exception && json.exception.indexOf('A saved view by the name') === 0) { - - Ext4.Msg.show({ - title : "Duplicate View Name", - msg : json.exception + " Would you like to replace it?", - cls : 'data-window', - icon : Ext4.Msg.QUESTION, - buttons : Ext4.Msg.YESNO, - fn : function(btn) { - if (btn === 'yes') { - o.replace = true; - _saveSessionView(o, region, win); - } - }, - scope : this - }); - } - else - Ext4.Msg.alert('Error saving view', json.exception || json.statusText || Ext4.decode(json.responseText).exception); - }, - scope: region - }); - }; - - var _saveSessionShowPrompt = function(region, queryDetails) { - LABKEY.DataRegion.loadViewDesigner(function() { - var config = Ext4.applyIf({ - allowableContainerFilters: region.allowableContainerFilters, - targetContainers: queryDetails.targetContainers, - canEditSharedViews: queryDetails.canEditSharedViews, - canEdit: LABKEY.DataRegion.getCustomViewEditableErrors(config).length == 0, - success: function (win, o) { - _saveSessionView(o, region, win); - }, - scope: region - }, region.view); - - LABKEY.internal.ViewDesigner.Designer.saveCustomizeViewPrompt(config); - }); - }; - - var _setParameter = function(region, param, value, skipPrefixes /* optional */) { - _setParameters(region, [[param, value]], skipPrefixes); - }; - - var _setParameters = function(region, newParamValPairs, skipPrefixes /* optional */) { - // prepend region name - // e.g. ['.hello', '.goodbye'] becomes ['aqwp19.hello', 'aqwp19.goodbye'] - if (LABKEY.Utils.isArray(skipPrefixes)) { - skipPrefixes.forEach(function(skip, i) { - if (skip && skip.indexOf(region.name + '.') !== 0) { - skipPrefixes[i] = region.name + skip; - } - }); - } - - var param, value, - params = _getParameters(region, skipPrefixes); - - if (LABKEY.Utils.isArray(newParamValPairs)) { - newParamValPairs.forEach(function(newPair) { - if (!LABKEY.Utils.isArray(newPair)) { - throw new Error("DataRegion: _setParameters newParamValPairs improperly initialized. It is an array of arrays. You most likely passed in an array of strings."); - } - param = newPair[0]; - value = newPair[1]; - - // Allow value to be null/undefined to support no-value filter types (Is Blank, etc) - if (LABKEY.Utils.isString(param) && param.length > 1) { - if (param.indexOf(region.name) !== 0) { - param = region.name + param; - } - - params.push([param, value]); - } - }); - } - - if (region.async) { - _load(region, params, skipPrefixes); - } - else { - region.setSearchString.call(region, region.name, _buildQueryString(region, params)); - } - }; - - var _showRows = function(region, showRowsEnum) { - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - // clear sibling parameters, could we do this with events? - this.maxRows = undefined; - this.offset = 0; - - _setParameter(region, SHOW_ROWS_PREFIX, showRowsEnum, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); - }; - - var _showSelectMessage = function(region, msg) { - if (region.showRecordSelectors) { - if (region.totalRows && region.totalRows != region.selectedCount) { - msg += " Select All Rows"; - } - - msg += " " + "Select None"; - var showOpts = []; - if (region.showRows !== 'all' && !_isMaxRowsAllRows(region)) - showOpts.push("Show All"); - if (region.showRows !== 'selected') - showOpts.push("Show Selected"); - if (region.showRows !== 'unselected') - showOpts.push("Show Unselected"); - msg += "  " + showOpts.join(" "); - } - - // add the record selector message, the link handlers will get added after render in _onRenderMessageArea - region.addMessage.call(region, msg, 'selection'); - }; - - var _toggleAllRows = function(region, checked) { - var ids = []; - - _getRowSelectors(region).each(function() { - if (!this.disabled) { - this.checked = checked; - ids.push(this.value); - } - }); - - _getAllRowSelectors(region).each(function() { this.checked = checked === true; }); - return ids; - }; - - /** - * Asynchronous loader for a DataRegion - * @param region {DataRegion} - * @param [newParams] {string} - * @param [skipPrefixes] {string[]} - * @param [callback] {Function} - * @param [scope] - * @private - */ - var _load = function(region, newParams, skipPrefixes, callback, scope) { - - var params = _getAsyncParams(region, newParams ? newParams : _getParameters(region), skipPrefixes); - var jsonData = _getAsyncBody(region, params); - - // TODO: This should be done in _getAsyncParams, but is not since _getAsyncBody relies on it. Refactor it. - // ensure SQL is not on the URL -- we allow any property to be pulled through when creating parameters. - if (params.sql) { - delete params.sql; - } - - /** - * The target jQuery element that will be either written to or replaced - */ - var target; - - /** - * Flag used to determine if we should replace target element (default) or write to the target contents - * (used during QWP render for example) - * @type {boolean} - */ - var useReplace = true; - - /** - * The string identifier for where the region will render. Mainly used to display useful messaging upon failure. - * @type {string} - */ - var renderEl; - - if (region.renderTo) { - useReplace = false; - renderEl = region.renderTo; - target = $('#' + region.renderTo); - } - else if (!region.domId) { - throw '"renderTo" must be specified either upon construction or when calling render()'; - } - else { - renderEl = region.domId; - target = $('#' + region.domId); - - // attempt to find the correct node to render to... - var form = _getFormSelector(region); - if (form.length && form.parent('div').length) { - target = form.parent('div'); - } - else { - // next best render target - throw 'unable to find a good target element. Perhaps this region is not using the standard renderer?' - } - } - var timerId = setTimeout(function() { - timerId = 0; - if (target) { - target.html("
" + - "
loading...
" + - "
"); - } - }, 500); - - LABKEY.Ajax.request({ - timeout: region.timeout === undefined ? DEFAULT_TIMEOUT : region.timeout, - url: LABKEY.ActionURL.buildURL('project', 'getWebPart.api', region.containerPath), - method: 'POST', - params: params, - jsonData: jsonData, - success: function(response) { - if (timerId > 0) { - clearTimeout(timerId);//load mask task no longer needed - } - this.hidePanel(function() { - if (target.length) { - - this.destroy(); - - LABKEY.Utils.loadAjaxContent(response, target, function() { - - if ($.isFunction(callback)) { - callback.call(scope); - } - - if ($.isFunction(this._success)) { - this._success.call(this.scope || this, this, response); - } - - $(this).trigger('success', [this, response]); - - this.RENDER_LOCK = true; - $(this).trigger('render', this); - this.RENDER_LOCK = false; - }, this, useReplace); - } - else { - // not finding element considered a failure - if ($.isFunction(this._failure)) { - this._failure.call(this.scope || this, response /* json */, response, undefined /* options */, target); - } - else if (!this.suppressRenderErrors) { - LABKEY.Utils.alert('Rendering Error', 'The element "' + renderEl + '" does not exist in the document. You may need to specify "renderTo".'); - } - } - }, this); - }, - failure: LABKEY.Utils.getCallbackWrapper(function(json, response, options) { - - if (target.length) { - if ($.isFunction(this._failure)) { - this._failure.call(this.scope || this, json, response, options); - } - else if (this.errorType === 'html') { - if (useReplace) { - target.replaceWith('
' + LABKEY.Utils.encodeHtml(json.exception) + '
'); - } - else { - target.html('
' + LABKEY.Utils.encodeHtml(json.exception) + '
'); - } - } - } - else if (!this.suppressRenderErrors) { - LABKEY.Utils.alert('Rendering Error', 'The element "' + renderEl + '" does not exist in the document. You may need to specify "renderTo".'); - } - }, region, true), - scope: region - }); - - if (region.async && !region.complete && region.showPaginationCountAsync && !region.skipTotalRowCount) { - _loadAsyncTotalRowCount(region, params, jsonData); - } - region.skipTotalRowCount = false; - }; - - var totalRowCountRequests = {}; // track the request per region name so that we cancel the correct request when necessary - var _loadAsyncTotalRowCount = function(region, params, jsonData) { - // if there is a previous request pending, abort it before starting a new one - var totalRowCountRequest = totalRowCountRequests[region.name]; - if (totalRowCountRequest !== undefined) { - totalRowCountRequest.abort(); - } - - region.totalRows = undefined; - region.loadingTotalRows = true; - - totalRowCountRequests[region.name] = LABKEY.Query.selectRows({ - ...region.getQueryConfig(), - method: 'POST', - containerPath: region.containerPath, - filterArray: LABKEY.Filter.getFiltersFromParameters({ ...params, ...jsonData.filters }, params.dataRegionName), - sort: undefined, - maxRows: 1, - offset: 0, - includeMetadata: false, - includeDetailsColumn: false, - includeUpdateColumn: false, - includeTotalCount: true, - success: function(json) { - totalRowCountRequests[region.name] = undefined; - region.loadingTotalRows = false; - - if (json !== undefined && json.rowCount !== undefined) { - region.totalRows = json.rowCount; - - // update the pagination button disabled state for 'Show Last' and 'Show All' since they include the totalRows count in their calc - var showLast = _showLastEnabled(region); - if (showLast) { - _getShowLastSelector(region).parent('li').removeClass('disabled'); - _getShowAllSelector(region).parent('li').removeClass('disabled'); - } - } - // note: use _getFormSelector instead of _getBarSelector so that we get the floating header as well - _getFormSelector(region).find('a.labkey-paginationText').html(_getPaginationText(region)); - }, - failure: function(error, request) { - var aborted = request.status === 0; - if (!aborted) { - console.error(error); - totalRowCountRequests[region.name] = undefined; - region.loadingTotalRows = false; - _getFormSelector(region).find('a.labkey-paginationText').html(_getPaginationText(region)); - } - } - }); - }; - - var _getAsyncBody = function(region, params) { - var json = {}; - - if (params.sql) { - json.sql = LABKEY.Utils.wafEncode(params.sql); - } - - _processButtonBar(region, json); - - // Issue 10505: add non-removable sorts and filters to json (not url params). - if (region.sort || region.filters || region.aggregates) { - json.filters = {}; - - if (region.filters) { - LABKEY.Filter.appendFilterParams(json.filters, region.filters, region.name); - } - - if (region.sort) { - json.filters[region.dataRegionName + SORT_PREFIX] = region.sort; - } - - if (region.aggregates) { - LABKEY.Filter.appendAggregateParams(json.filters, region.aggregates, region.name); - } - } - - if (region.metadata) { - json.metadata = { - type: region.metadata.type, - value: LABKEY.Utils.wafEncode(region.metadata.value) - }; - } - - return json; - }; - - var _processButtonBar = function(region, json) { - - var bar = region.buttonBar; - - if (bar && (bar.position || (bar.items && bar.items.length > 0))) { - _processButtonBarItems(region, bar.items); - - // only attach if valid - json.buttonBar = bar; - } - }; - - var _processButtonBarItems = function(region, items) { - if (LABKEY.Utils.isArray(items) && items.length > 0) { - for (var i = 0; i < items.length; i++) { - var item = items[i]; - - if (item && $.isFunction(item.handler)) { - item.id = item.id || LABKEY.Utils.id(); - // TODO: A better way? This exposed _onButtonClick isn't very awesome - item.onClick = "return LABKEY.DataRegions['" + region.name + "']._onButtonClick('" + item.id + "');"; - } - - if (item.items) { - _processButtonBarItems(region, item.items); - } - } - } - }; - - var _isFilter = function(region, parameter) { - return parameter && parameter.indexOf(region.name + '.') === 0 && parameter.indexOf('~') > 0; - }; - - var _getAsyncParams = function(region, newParams, skipPrefixes) { - - var params = {}; - var name = region.name; - - // - // Certain parameters are only included if the region is 'async'. These - // were formerly a part of Query Web Part. - // - if (region.async) { - params[name + '.async'] = true; - - if (LABKEY.Utils.isString(region.frame)) { - params['webpart.frame'] = region.frame; - } - - if (LABKEY.Utils.isString(region.bodyClass)) { - params['webpart.bodyClass'] = region.bodyClass; - } - - if (LABKEY.Utils.isString(region.title)) { - params['webpart.title'] = region.title; - } - - if (LABKEY.Utils.isString(region.titleHref)) { - params['webpart.titleHref'] = region.titleHref; - } - - if (LABKEY.Utils.isString(region.columns)) { - params[region.name + '.columns'] = region.columns; - } - - _applyOptionalParameters(region, params, [ - 'allowChooseQuery', - 'allowChooseView', - 'allowHeaderLock', - 'buttonBarPosition', - 'detailsURL', - 'deleteURL', - 'importURL', - 'insertURL', - 'linkTarget', - 'updateURL', - 'shadeAlternatingRows', - 'showBorders', - 'showDeleteButton', - 'showDetailsColumn', - 'showExportButtons', - 'showRStudioButton', - 'showImportDataButton', - 'showInsertNewButton', - 'showPagination', - 'showPaginationCount', - 'showReports', - 'showSurroundingBorder', - 'showFilterDescription', - 'showUpdateColumn', - 'showViewPanel', - 'timeout', - {name: 'disableAnalytics', prefix: true}, - {name: 'maxRows', prefix: true, check: function(v) { return v > 0; }}, - {name: 'showRows', prefix: true}, - {name: 'offset', prefix: true, check: function(v) { return v !== 0; }}, - {name: 'reportId', prefix: true}, - {name: 'viewName', prefix: true} - ]); - - // Sorts configured by the user when interacting with the grid. We need to pass these as URL parameters. - if (LABKEY.Utils.isString(region._userSort) && region._userSort.length > 0) { - params[name + SORT_PREFIX] = region._userSort; - } - - if (region.userFilters) { - $.each(region.userFilters, function(filterExp, filterValue) { - if (params[filterExp] == undefined) { - params[filterExp] = []; - } - params[filterExp].push(filterValue); - }); - region.userFilters = {}; // they've been applied - } - - // TODO: Get rid of this and incorporate it with the normal containerFilter checks - if (region.userContainerFilter) { - params[name + CONTAINER_FILTER_NAME] = region.userContainerFilter; - } - - if (region.parameters) { - var paramPrefix = name + PARAM_PREFIX; - $.each(region.parameters, function(parameter, value) { - var key = parameter; - if (parameter.indexOf(paramPrefix) !== 0) { - key = paramPrefix + parameter; - } - params[key] = value; - }); - } - } - - // - // apply all parameters - // - - var newParamPrefixes = {}; - - if (newParams) { - newParams.forEach(function(pair) { - // Issue 25337: Filters may repeat themselves - if (_isFilter(region, pair[0])) { - if (params[pair[0]] == undefined) { - params[pair[0]] = []; - } - else if (!LABKEY.Utils.isArray(params[pair[0]])) { - params[pair[0]] = [params[pair[0]]]; - } - - var value = pair[1]; - - // Issue 47735: QWP date filter not being formatted - // This needs to be formatted for the response passed back to the grid for the filter display and - // filter dialog to render correctly - value = _ensureFilterDateFormat(value); - - params[pair[0]].push(value); - } - else { - params[pair[0]] = pair[1]; - } - - newParamPrefixes[pair[0]] = true; - }); - } - - // Issue 40226: Don't include parameters that are being logically excluded - if (skipPrefixes) { - skipPrefixes.forEach(function(skipKey) { - if (params.hasOwnProperty(skipKey) && !newParamPrefixes.hasOwnProperty(skipKey)) { - delete params[skipKey]; - } - }); - } - - // - // Properties that cannot be modified - // - - params.dataRegionName = region.name; - params.schemaName = region.schemaName; - params.viewName = region.viewName; - params.reportId = region.reportId; - params.returnUrl = window.location.href; - params['webpart.name'] = 'Query'; - - if (region.queryName) { - params.queryName = region.queryName; - } - else if (region.sql) { - params.sql = region.sql; - } - - var key = region.name + CONTAINER_FILTER_NAME; - var cf = region.getContainerFilter.call(region); - if (cf && !(key in params)) { - params[key] = cf; - } - - return params; - }; - - var _updateFilter = function(region, filter, skipPrefixes) { - var params = []; - if (filter) { - params.push([filter.getURLParameterName(region.name), filter.getURLParameterValue()]); - } - _setParameters(region, params, [OFFSET_PREFIX].concat(skipPrefixes)); - }; - - var _updateRequiresSelectionButtons = function(region, selectedCount) { - - // update the 'select all on page' checkbox state - _getAllRowSelectors(region).each(function() { - if (region.isPageSelected.call(region)) { - this.checked = true; - this.indeterminate = false; - } - else if (region.selectedCount > 0) { - // There are rows selected, but the are not visible on this page. - this.checked = false; - this.indeterminate = true; - } - else { - this.checked = false; - this.indeterminate = false; - } - }); - - // If not all rows are visible and some rows are selected, show selection message - if (region.totalRows && 0 !== region.selectedCount && !region.complete) { - var msg = (region.selectedCount === region.totalRows) ? - 'All ' + region.totalRows + ' rows selected.' : - 'Selected ' + region.selectedCount + ' of ' + region.totalRows + ' rows.'; - _showSelectMessage(region, msg); - } - - // Issue 10566: for javascript perf on IE stash the requires selection buttons - if (!region._requiresSelectionButtons) { - // escape ', ", and \ - var escaped = region.name.replace(/('|"|\\)/g, "\\$1"); - region._requiresSelectionButtons = $("a[data-labkey-requires-selection='" + escaped + "']"); - } - - region._requiresSelectionButtons.each(function() { - var el = $(this); - - var isDropdown = false; - var dropdownBtn = el.parent(); - if (dropdownBtn && dropdownBtn.hasClass('lk-menu-drop') && dropdownBtn.hasClass('dropdown')) - isDropdown = true; - - // handle min-count - var minCount = el.attr('data-labkey-requires-selection-min-count'); - if (minCount) { - minCount = parseInt(minCount); - } - if (minCount === undefined) { - minCount = 1; - } - - // handle max-count - var maxCount = el.attr('data-labkey-requires-selection-max-count'); - if (maxCount) { - maxCount = parseInt(maxCount); - } - - if (minCount <= selectedCount && (!maxCount || maxCount >= selectedCount)) { - el.removeClass('labkey-disabled-button'); - if (isDropdown) - dropdownBtn.removeClass('labkey-disabled-button'); - } - else { - el.addClass('labkey-disabled-button'); - if (isDropdown) - dropdownBtn.addClass('labkey-disabled-button'); - } - }); - }; - - var HeaderLock = function(region) { - - // init - if (!region.headerLock()) { - region._allowHeaderLock = false; - return; - } - - this.region = region; - - var table = $('#' + region.domId); - var firstRow = table.find('tr.labkey-alternate-row').first().children('td'); - - // If no data rows exist just turn off header locking - if (firstRow.length === 0) { - firstRow = table.find('tr.labkey-row').first().children('td'); - if (firstRow.length === 0) { - region._allowHeaderLock = false; - return; - } - } - - var headerRowId = region.domId + '-column-header-row'; - var headerRow = $('#' + headerRowId); - - if (headerRow.length === 0) { - region._allowHeaderLock = false; - return; - } - - var BOTTOM_OFFSET = 100; - - var me = this, - timeout, - locked = false, - lastLeft = 0, - pos = [ 0, 0, 0, 0 ], - domObserver = null; - - // init - var floatRow = headerRow - .clone() - // TODO: Possibly namespace all the ids underneath - .attr('id', headerRowId + '-float') - .css({ - 'box-shadow': '0 4px 4px #DCDCDC', - display: 'none', - position: 'fixed', - top: 0, - 'z-index': 2 - }); - - floatRow.insertAfter(headerRow); - - // respect showPagination but do not use it directly as it may change - var isPagingFloat = region.showPagination; - var floatPaging, floatPagingWidth = 0; - - if (isPagingFloat) { - var pageWidget = _getBarSelector(region).find('.labkey-pagination'); - if (pageWidget.children().length) { - floatPaging = $('
') - .css({ - 'background-color': 'white', - 'box-shadow': '0 4px 4px #DCDCDC', - display: 'none', - 'min-width': pageWidget.width(), - opacity: 0.7, - position: 'fixed', - top: floatRow.height(), - 'z-index': 1 - }) - .on('mouseover', function() { - $(this).css('opacity', '1.0'); - }) - .on('mouseout', function() { - $(this).css('opacity', '0.7') - }); - - var floatingPageWidget = pageWidget.clone(true).css('padding', '4px 8px'); - - // adjust padding when buttons aren't shown - if (!pageWidget.find('.btn-group').length) { - floatingPageWidget.css('padding-bottom', '8px') - } - - floatPaging.append(floatingPageWidget); - table.parent().append(floatPaging); - floatPagingWidth = floatPaging.width(); - } else { - isPagingFloat = false; - } - } - - var disable = function() { - me.region._allowHeaderLock = false; - - if (timeout) { - clearTimeout(timeout); - } - - $(window) - .unbind('load', domTask) - .unbind('resize', resizeTask) - .unbind('scroll', onScroll); - - if (domObserver) { - domObserver.disconnect(); - domObserver = null; - } - }; - - /** - * Configures the 'pos' array containing the following values: - * [0] - X-coordinate of the top of the object relative to the offset parent. - * [1] - Y-coordinate of the top of the object relative to the offset parent. - * [2] - Y-coordinate of the bottom of the object. - * [3] - width of the object - * This method assumes interaction with the Header of the Data Region. - */ - var loadPosition = function() { - var header = headerRow.offset() || {top: 0}; - var table = $('#' + region.domId); - - var bottom = header.top + table.height() - BOTTOM_OFFSET; - var width = headerRow.width(); - pos = [ header.left, header.top, bottom, width ]; - }; - - loadPosition(); - - var onResize = function() { - loadPosition(); - var sub_h = headerRow.find('th'); - - floatRow.width(headerRow.width()).find('th').each(function(i, el) { - $(el).width($(sub_h[i]).width()); - }); - - isPagingFloat && floatPaging.css({ - left: pos[0] - window.pageXOffset + floatRow.width() - floatPaging.width(), - top: floatRow.height() - }); - }; - - /** - * WARNING: This function is called often. Performance implications for each line. - */ - var onScroll = function() { - if (window.pageYOffset >= pos[1] && window.pageYOffset < pos[2]) { - var newLeft = pos[0] - window.pageXOffset; - var newPagingLeft = isPagingFloat ? newLeft + pos[3] - floatPagingWidth : 0; - - var floatRowCSS = { - top: 0 - }; - var pagingCSS = isPagingFloat && { - top: floatRow.height() - }; - - if (!locked) { - locked = true; - floatRowCSS.display = 'table-row'; - floatRowCSS.left = newLeft; - - pagingCSS.display = 'block'; - pagingCSS.left = newPagingLeft; - } - else if (lastLeft !== newLeft) { - floatRowCSS.left = newLeft; - - pagingCSS.left = newPagingLeft; - } - - floatRow.css(floatRowCSS); - isPagingFloat && floatPaging.css(pagingCSS); - - lastLeft = newLeft; - } - else if (locked && window.pageYOffset >= pos[2]) { - var newTop = pos[2] - window.pageYOffset; - - floatRow.css({ - top: newTop - }); - - isPagingFloat && floatPaging.css({ - top: newTop + floatRow.height() - }); - } - else if (locked) { - locked = false; - floatRow.hide(); - isPagingFloat && floatPaging.hide(); - } - }; - - var resizeTask = function(immediate) { - clearTimeout(timeout); - if (immediate) { - onResize(); - } - else { - timeout = setTimeout(onResize, 110); - } - }; - - var isDOMInit = false; - - var domTask = function() { - if (!isDOMInit) { - isDOMInit = true; - // fire immediate to prevent flicker of components when reloading region - resizeTask(true); - } - else { - resizeTask(); - } - onScroll(); - }; - - $(window) - .one('load', domTask) - .on('resize', resizeTask) - .on('scroll', onScroll); - - domObserver = new MutationObserver(mutationList => - mutationList.filter(m => m.type === 'childList').forEach(m => { - m.addedNodes.forEach(domTask); - })); - domObserver.observe(document,{childList: true, subtree: true}); // Issue 13121, 50939 - - // ensure that resize/scroll fire at the end of initialization - domTask(); - - return { - disable: disable - } - }; - - // - // LOADER - // - LABKEY.DataRegion.create = function(config) { - - var region = LABKEY.DataRegions[config.name]; - - if (region) { - // region already exists, update properties - $.each(config, function(key, value) { - region[key] = value; - }); - if (!config.view) { - // when switching back to 'default' view, needs to clear region.view - region.view = undefined; - } - _init.call(region, config); - } - else { - // instantiate a new region - region = new LABKEY.DataRegion(config); - LABKEY.DataRegions[region.name] = region; - } - - return region; - }; - - LABKEY.DataRegion.loadViewDesigner = function(cb, scope) { - LABKEY.requiresExt4Sandbox(function() { - LABKEY.requiresScript('internal/ViewDesigner', cb, scope); - }); - }; - - LABKEY.DataRegion.getCustomViewEditableErrors = function(customView) { - var errors = []; - if (customView && !customView.editable) { - errors.push("The view is read-only and cannot be edited."); - } - return errors; - }; - - LABKEY.DataRegion.registerPane = function(regionName, callback, scope) { - var region = LABKEY.DataRegions[regionName]; - if (region) { - callback.call(scope || region, region); - return; - } - else if (!_paneCache[regionName]) { - _paneCache[regionName] = []; - } - - _paneCache[regionName].push({cb: callback, scope: scope}); - }; - - LABKEY.DataRegion.selectAll = function(config) { - var params = {}; - if (!config.url) { - // DataRegion doesn't have selectAllURL so generate url and query parameters manually - config.url = LABKEY.ActionURL.buildURL('query', 'selectAll.api', config.containerPath); - - config.dataRegionName = config.dataRegionName || 'query'; - - params = LABKEY.Query.buildQueryParams( - config.schemaName, - config.queryName, - config.filters, - null, - config.dataRegionName - ); - - if (config.viewName) - params[config.dataRegionName + VIEWNAME_PREFIX] = config.viewName; - - if (config.containerFilter) - params.containerFilter = config.containerFilter; - - if (config.selectionKey) - params[config.dataRegionName + '.selectionKey'] = config.selectionKey; - - $.each(config.parameters, function(propName, value) { - params[config.dataRegionName + PARAM_PREFIX + propName] = value; - }); - - if (config.ignoreFilter) { - params[config.dataRegionName + '.ignoreFilter'] = true; - } - - // NOTE: ignore maxRows, showRows, and offset - } - - LABKEY.Ajax.request({ - url: config.url, - method: 'POST', - params: params, - success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), - failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) - }); - }; - - /** - * Static method to add or remove items from the selection for a given {@link #selectionKey}. - * - * @param config A configuration object with the following properties: - * @param {String} config.selectionKey See {@link #selectionKey}. - * @param {Array} config.ids Array of primary key ids for each row to select/unselect. - * @param {Boolean} config.checked If true, the ids will be selected, otherwise unselected. - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'count' to indicate the updated selection count. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion#getSelected - * @see LABKEY.DataRegion#clearSelected - */ - LABKEY.DataRegion.setSelected = function(config) { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'setSelected.api', config.containerPath), - method: 'POST', - jsonData: { - checked: config.checked, - id: config.ids || config.id, - key: config.selectionKey, - }, - scope: config.scope, - success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), - failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) - }); - }; - - /** - * Static method to clear all selected items for a given {@link #selectionKey}. - * - * @param config A configuration object with the following properties: - * @param {String} config.selectionKey See {@link #selectionKey}. - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'count' of 0 to indicate an empty selection. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion#setSelected - * @see LABKEY.DataRegion#getSelected - */ - LABKEY.DataRegion.clearSelected = function(config) { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'clearSelected.api', config.containerPath), - method: 'POST', - jsonData: { key: config.selectionKey }, - success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), - failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) - }); - }; - - /** - * Static method to get all selected items for a given {@link #selectionKey}. - * - * @param config A configuration object with the following properties: - * @param {String} config.selectionKey See {@link #selectionKey}. - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'selected' that is an array of the primary keys for the selected rows. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * @param {boolean} [config.clearSelected] If true, clear the session-based selection for this Data Region after - * retrieving the current selection. Defaults to false. - * - * @see LABKEY.DataRegion#setSelected - * @see LABKEY.DataRegion#clearSelected - */ - LABKEY.DataRegion.getSelected = function(config) { - var jsonData = { key: config.selectionKey }; - - // Issue 41705: Support clearing selection from getSelected() - if (config.clearSelected) { - jsonData.clearSelected = true; - } - - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'getSelected.api', config.containerPath), - method: 'POST', - jsonData: jsonData, - success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), - failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) - }); - }; - - /** - * MessageArea wraps the display of messages in a DataRegion. - * @param dataRegion - The dataregion that the MessageArea will bind itself to. - * @param {String[]} [messages] - An initial messages object containing mappings of 'part' to 'msg' - * @constructor - */ - var MessageArea = function(dataRegion, messages) { - this.bindRegion(dataRegion); - - if (messages) { - this.setMessages(messages); - } - }; - - var MsgProto = MessageArea.prototype; - - MsgProto.bindRegion = function(region) { - this.parentSel = '#' + region.domId + '-msgbox'; - }; - - MsgProto.toJSON = function() { - return this.parts; - }; - - MsgProto.addMessage = function(msg, part, append) { - part = part || 'info'; - - var p = part.toLowerCase(); - if (append && this.parts.hasOwnProperty(p)) - { - this.parts[p] += msg; - this.render(p, msg); - } - else { - this.parts[p] = msg; - this.render(p); - } - }; - - MsgProto.getMessage = function(part) { - return this.parts[part.toLowerCase()]; - }; - - MsgProto.hasMessage = function(part) { - return this.getMessage(part) !== undefined; - }; - - MsgProto.hasContent = function() { - return this.parts && Object.keys(this.parts).length > 0; - }; - - MsgProto.removeAll = function() { - this.parts = {}; - this.render(); - }; - - MsgProto.removeMessage = function(part) { - var p = part.toLowerCase(); - if (this.parts.hasOwnProperty(p)) { - this.parts[p] = undefined; - this.render(); - } - }; - - MsgProto.setMessages = function(messages) { - if (LABKEY.Utils.isObject(messages)) { - this.parts = messages; - } - else { - this.parts = {}; - } - }; - - MsgProto.getParent = function() { - return $(this.parentSel); - }; - - MsgProto.render = function(partToUpdate, appendMsg) { - var hasMsg = false, - me = this, - parent = this.getParent(); - - $.each(this.parts, function(part, msg) { - - if (msg) { - // If this is modified, update the server-side renderer in DataRegion.java renderMessages() - var partEl = parent.find('div[data-msgpart="' + part + '"]'); - if (partEl.length === 0) { - parent.append([ - '
', - msg, - '
' - ].join('')); - } - else if (partToUpdate !== undefined && partToUpdate === part) { - if (appendMsg !== undefined) - partEl.append(appendMsg); - else - partEl.html(msg) - } - - hasMsg = true; - } - else { - parent.find('div[data-msgpart="' + part + '"]').remove(); - delete me.parts[part]; - } - }); - - if (hasMsg) { - this.show(); - $(this).trigger('rendermsg', [this, this.parts]); - } - else { - this.hide(); - parent.html(''); - } - }; - - MsgProto.show = function() { this.getParent().show(); }; - MsgProto.hide = function() { this.getParent().hide(); }; - MsgProto.isVisible = function() { return $(this.parentSel + ':visible').length > 0; }; - MsgProto.find = function(selector) { - return this.getParent().find('.dataregion_msgbox_ct').find(selector); - }; - MsgProto.on = function(evt, callback, scope) { $(this).bind(evt, $.proxy(callback, scope)); }; - - /** - * @description Constructs a LABKEY.QueryWebPart class instance - * @class The LABKEY.QueryWebPart simplifies the task of dynamically adding a query web part to your page. Please use - * this class for adding query web parts to a page instead of {@link LABKEY.WebPart}, - * which can be used for other types of web parts. - *

Additional Documentation: - *

- *

- * @constructor - * @param {Object} config A configuration object with the following possible properties: - * @param {String} config.schemaName The name of the schema the web part will query. - * @param {String} config.queryName The name of the query within the schema the web part will select and display. - * @param {String} [config.viewName] the name of a saved view you wish to display for the given schema and query name. - * @param {String} [config.reportId] the report id of a saved report you wish to display for the given schema and query name. - * @param {Mixed} [config.renderTo] The element id, DOM element, or Ext element inside of which the part should be rendered. This is typically a <div>. - * If not supplied in the configuration, you must call the render() method to render the part into the page. - * @param {String} [config.errorType] A parameter to specify how query parse errors are returned. (default 'html'). Valid - * values are either 'html' or 'json'. If 'html' is specified the error will be rendered to an HTML view, if 'json' is specified - * the errors will be returned to the callback handlers as an array of objects named 'parseErrors' with the following properties: - *
    - *
  • msg: The error message.
  • - *
  • line: The line number the error occurred at (optional).
  • - *
  • col: The column number the error occurred at (optional).
  • - *
  • errorStr: The line from the source query that caused the error (optional).
  • - *
- * @param {String} [config.sql] A SQL query that can be used instead of an existing schema name/query name combination. - * @param {Object} [config.metadata] Metadata that can be applied to the properties of the table fields. Currently, this option is only - * available if the query has been specified through the config.sql option. For full documentation on - * available properties, see LabKey XML Schema Reference. - * This object may contain the following properties: - *
    - *
  • type: The type of metadata being specified. Currently, only 'xml' is supported.
  • - *
  • value: The metadata XML value as a string. For example: '<tables xmlns="http://labkey.org/data/xml"><table tableName="Announcement" tableDbType="NOT_IN_DB"><columns><column columnName="Title"><columnTitle>Custom Title</columnTitle></column></columns></table></tables>'
  • - *
- * @param {String} [config.title] A title for the web part. If not supplied, the query name will be used as the title. - * @param {String} [config.titleHref] If supplied, the title will be rendered as a hyperlink with this value as the href attribute. - * @param {String} [config.buttonBarPosition] DEPRECATED--see config.buttonBar.position - * @param {boolean} [config.allowChooseQuery] If the button bar is showing, whether or not it should be include a button - * to let the user choose a different query. - * @param {boolean} [config.allowChooseView] If the button bar is showing, whether or not it should be include a button - * to let the user choose a different view. - * @param {String} [config.detailsURL] Specify or override the default details URL for the table with one of the form - * "/controller/action.view?id=${RowId}" or "org.labkey.package.MyController$ActionAction.class?id=${RowId}" - * @param {boolean} [config.showDetailsColumn] If the underlying table has a details URL, show a column that renders a [details] link (default true). If true, the record selectors will be included regardless of the 'showRecordSelectors' config option. - * @param {String} [config.updateURL] Specify or override the default updateURL for the table with one of the form - * "/controller/action.view?id=${RowId}" or "org.labkey.package.MyController$ActionAction.class?id=${RowId}" - * @param {boolean} [config.showUpdateColumn] If the underlying table has an update URL, show a column that renders an [edit] link (default true). - * @param {String} [config.insertURL] Specify or override the default insert URL for the table with one of the form - * "/controller/insertAction.view" or "org.labkey.package.MyController$InsertActionAction.class" - * @param {String} [config.importURL] Specify or override the default bulk import URL for the table with one of the form - * "/controller/importAction.view" or "org.labkey.package.MyController$ImportActionAction.class" - * @param {String} [config.deleteURL] Specify or override the default delete URL for the table with one of the form - * "/controller/action.view" or "org.labkey.package.MyController$ActionAction.class". The keys for the selected rows - * will be included in the POST. - * @param {boolean} [config.showImportDataButton] If the underlying table has an import URL, show an "Import Bulk Data" button in the button bar (default true). - * @param {boolean} [config.showInsertNewButton] If the underlying table has an insert URL, show an "Insert New" button in the button bar (default true). - * @param {boolean} [config.showDeleteButton] Show a "Delete" button in the button bar (default true). - * @param {boolean} [config.showReports] If true, show reports on the Views menu (default true). - * @param {boolean} [config.showExportButtons] Show the export button menu in the button bar (default true). - * @param {boolean} [config.showRStudioButton] Show the export to RStudio button menu in the button bar. Requires export button to work. (default false). - * @param {boolean} [config.showBorders] Render the table with borders (default true). - * @param {boolean} [config.showSurroundingBorder] Render the table with a surrounding border (default true). - * @param {boolean} [config.showFilterDescription] Include filter and parameter values in the grid header, if present (default true). - * @param {boolean} [config.showRecordSelectors] Render the select checkbox column (default undefined, meaning they will be shown if the query is updatable by the current user). - * Both 'showDeleteButton' and 'showExportButtons' must be set to false for the 'showRecordSelectors = false' setting to hide the checkboxes. - * @param {boolean} [config.showPagination] Show the pagination links and count (default true). - * @param {boolean} [config.showPaginationCount] Show the total count of rows in the pagination information text (default true). - * @param {boolean} [config.showPaginationCountAsync] Show the total count of rows in the pagination information text, but query for it asynchronously so that the grid data can load initially without it (default false). - * @param {boolean} [config.shadeAlternatingRows] Shade every other row with a light gray background color (default true). - * @param {boolean} [config.suppressRenderErrors] If true, no alert will appear if there is a problem rendering the QueryWebpart. This is most often encountered if page configuration changes between the time when a request was made and the content loads. Defaults to false. - * @param {Object} [config.buttonBar] Button bar configuration. This object may contain any of the following properties: - *
    - *
  • position: Configures where the button bar will appear with respect to the data grid: legal values are 'top', or 'none'. Default is 'top'.
  • - *
  • includeStandardButtons: If true, all standard buttons not specifically mentioned in the items array will be included at the end of the button bar. Default is false.
  • - *
  • items: An array of button bar items. Each item may be either a reference to a standard button, or a new button configuration. - * to reference standard buttons, use one of the properties on {@link #standardButtons}, or simply include a string - * that matches the button's caption. To include a new button configuration, create an object with the following properties: - *
      - *
    • text: The text you want displayed on the button (aka the caption).
    • - *
    • url: The URL to navigate to when the button is clicked. You may use LABKEY.ActionURL to build URLs to controller actions. - * Specify this or a handler function, but not both.
    • - *
    • handler: A reference to the JavaScript function you want called when the button is clicked.
    • - *
    • permission: Optional. Permission that the current user must possess to see the button. - * Valid options are 'READ', 'INSERT', 'UPDATE', 'DELETE', and 'ADMIN'. - * Default is 'READ' if permissionClass is not specified.
    • - *
    • permissionClass: Optional. If permission (see above) is not specified, the fully qualified Java class - * name of the permission that the user must possess to view the button.
    • - *
    • requiresSelection: A boolean value (true/false) indicating whether the button should only be enabled when - * data rows are checked/selected.
    • - *
    • items: To create a drop-down menu button, set this to an array of menu item configurations. - * Each menu item configuration can specify any of the following properties: - *
        - *
      • text: The text of the menu item.
      • - *
      • handler: A reference to the JavaScript function you want called when the menu item is clicked.
      • - *
      • icon: A url to an image to use as the menu item's icon.
      • - *
      • items: An array of sub-menu item configurations. Used for fly-out menus.
      • - *
      - *
    • - *
    - *
  • - *
- * @param {String} [config.columns] Comma-separated list of column names to be shown in the grid, overriding - * whatever might be set in a custom view. - * @param {String} [config.sort] A base sort order to use. This is a comma-separated list of column names, each of - * which may have a - prefix to indicate a descending sort. It will be treated as the final sort, after any that the user - * has defined in a custom view or through interacting with the grid column headers. - * @param {String} [config.removeableSort] An additional sort order to use. This is a comma-separated list of column names, each of - * which may have a - prefix to indicate a descending sort. It will be treated as the first sort, before any that the user - * has defined in a custom view or through interacting with the grid column headers. - * @param {Array} [config.filters] A base set of filters to apply. This should be an array of {@link LABKEY.Filter} objects - * each of which is created using the {@link LABKEY.Filter.create} method. These filters cannot be removed by the user - * interacting with the UI. - * For compatibility with the {@link LABKEY.Query} object, you may also specify base filters using config.filterArray. - * @param {Array} [config.removeableFilters] A set of filters to apply. This should be an array of {@link LABKEY.Filter} objects - * each of which is created using the {@link LABKEY.Filter.create} method. These filters can be modified or removed by the user - * interacting with the UI. - * @param {Object} [config.parameters] Map of name (string)/value pairs for the values of parameters if the SQL - * references underlying queries that are parameterized. For example, the following passes two parameters to the query: {'Gender': 'M', 'CD4': '400'}. - * The parameters are written to the request URL as follows: query.param.Gender=M&query.param.CD4=400. For details on parameterized SQL queries, see - * Parameterized SQL Queries. - * @param {Array} [config.aggregates] An array of aggregate definitions. The objects in this array should have the properties: - *
    - *
  • column: The name of the column to be aggregated.
  • - *
  • type: The aggregate type (see {@link LABKEY.AggregateTypes})
  • - *
  • label: Optional label used when rendering the aggregate row. - *
- * @param {String} [config.showRows] Either 'paginated' (the default) 'selected', 'unselected', 'all', or 'none'. - * When 'paginated', the maxRows and offset parameters can be used to page through the query's result set rows. - * When 'selected' or 'unselected' the set of rows selected or unselected by the user in the grid view will be returned. - * You can programmatically get and set the selection using the {@link LABKEY.DataRegion.setSelected} APIs. - * Setting config.maxRows to -1 is the same as 'all' - * and setting config.maxRows to 0 is the same as 'none'. - * @param {Integer} [config.maxRows] The maximum number of rows to return from the server (defaults to 100). - * If you want to return all possible rows, set this config property to -1. - * @param {Integer} [config.offset] The index of the first row to return from the server (defaults to 0). - * Use this along with the maxRows config property to request pages of data. - * @param {String} [config.dataRegionName] The name to be used for the data region. This should be unique within - * the set of query views on the page. If not supplied, a unique name is generated for you. - * @param {String} [config.linkTarget] The name of a browser window/tab in which to open URLs rendered in the - * QueryWebPart. If not supplied, links will generally be opened in the same browser window/tab where the QueryWebPart. - * @param {String} [config.frame] The frame style to use for the web part. This may be one of the following: - * 'div', 'portal', 'none', 'dialog', 'title', 'left-nav'. - * @param {String} [config.showViewPanel] Open the customize view panel after rendering. The value of this option can be "true" or one of "ColumnsTab", "FilterTab", or "SortTab". - * @param {String} [config.bodyClass] A CSS style class that will be added to the enclosing element for the web part. - * Note, this may not be applied when used in conjunction with some "frame" types (e.g. 'none'). - * @param {Function} [config.success] A function to call after the part has been rendered. It will be passed two arguments: - *
    - *
  • dataRegion: the LABKEY.DataRegion object representing the rendered QueryWebPart
  • - *
  • request: the XMLHTTPRequest that was issued to the server
  • - *
- * @param {Function} [config.failure] A function to call if the request to retrieve the content fails. It will be passed three arguments: - *
    - *
  • json: JSON object containing the exception.
  • - *
  • response: The XMLHttpRequest object containing the response data.
  • - *
  • options: The parameter to the request call.
  • - *
- * @param {Object} [config.scope] An object to use as the callback function's scope. Defaults to this. - * @param {int} [config.timeout] A timeout for the AJAX call, in milliseconds. Default is 30000 (30 seconds). - * @param {String} [config.containerPath] The container path in which the schema and query name are defined. If not supplied, the current container path will be used. - * @param {String} [config.containerFilter] One of the values of {@link LABKEY.Query.containerFilter} that sets the scope of this query. If not supplied, the current folder will be used. - * @example - * <div id='queryTestDiv1'/> - * <script type="text/javascript"> - var qwp1 = new LABKEY.QueryWebPart({ - - renderTo: 'queryTestDiv1', - title: 'My Query Web Part', - schemaName: 'lists', - queryName: 'People', - buttonBarPosition: 'none', - aggregates: [ - {column: 'First', type: LABKEY.AggregateTypes.COUNT, label: 'Total People'}, - {column: 'Age', type: LABKEY.AggregateTypes.MEAN} - ], - filters: [ - LABKEY.Filter.create('Last', 'Flintstone') - ], - sort: '-Last' - }); - - //note that you may also register for the 'render' event - //instead of using the success config property. - //registering for events is done using Ext event registration. - //Example: - qwp1.on("render", onRender); - function onRender() - { - //...do something after the part has rendered... - } - - /////////////////////////////////////// - // Custom Button Bar Example - - var qwp1 = new LABKEY.QueryWebPart({ - renderTo: 'queryTestDiv1', - title: 'My Query Web Part', - schemaName: 'lists', - queryName: 'People', - buttonBar: { - includeStandardButtons: true, - items:[ - LABKEY.QueryWebPart.standardButtons.views, - {text: 'Test', url: LABKEY.ActionURL.buildURL('project', 'begin')}, - {text: 'Test Script', onClick: "alert('Hello World!'); return false;"}, - {text: 'Test Handler', handler: onTestHandler}, - {text: 'Test Menu', items: [ - {text: 'Item 1', handler: onItem1Handler}, - {text: 'Fly Out', items: [ - {text: 'Sub Item 1', handler: onItem1Handler} - ]}, - '-', //separator - {text: 'Item 2', handler: onItem2Handler} - ]}, - LABKEY.QueryWebPart.standardButtons.exportRows - ]} - }); - - function onTestHandler(dataRegion) - { - alert("onTestHandler called!"); - return false; - } - - function onItem1Handler(dataRegion) - { - alert("onItem1Handler called!"); - } - - function onItem2Handler(dataRegion) - { - alert("onItem2Handler called!"); - } - - </script> - */ - LABKEY.QueryWebPart = function(config) { - config._useQWPDefaults = true; - return LABKEY.DataRegion.create(config); - }; -})(jQuery); - -/** - * A read-only object that exposes properties representing standard buttons shown in LabKey data grids. - * These are used in conjunction with the buttonBar configuration. The following buttons are currently defined: - *
    - *
  • LABKEY.QueryWebPart.standardButtons.query
  • - *
  • LABKEY.QueryWebPart.standardButtons.views
  • - *
  • LABKEY.QueryWebPart.standardButtons.charts
  • - *
  • LABKEY.QueryWebPart.standardButtons.insertNew
  • - *
  • LABKEY.QueryWebPart.standardButtons.deleteRows
  • - *
  • LABKEY.QueryWebPart.standardButtons.exportRows
  • - *
  • LABKEY.QueryWebPart.standardButtons.print
  • - *
- * @name standardButtons - * @memberOf LABKEY.QueryWebPart# - */ -LABKEY.QueryWebPart.standardButtons = { - query: 'query', - views: 'grid views', - charts: 'charts', - insertNew: 'insert', - deleteRows: 'delete', - exportRows: 'export', - print: 'print' -}; - -/** - * Requests the query web part content and renders it within the element identified by the renderTo parameter. - * Note that you do not need to call this method explicitly if you specify a renderTo property on the config object - * handed to the class constructor. If you do not specify renderTo in the config, then you must call this method - * passing the id of the element in which you want the part rendered - * @function - * @param renderTo The id of the element in which you want the part rendered. - */ - -LABKEY.QueryWebPart.prototype.render = LABKEY.DataRegion.prototype.render; - -/** - * @returns {LABKEY.DataRegion} - */ -LABKEY.QueryWebPart.prototype.getDataRegion = LABKEY.DataRegion.prototype.getDataRegion; - -LABKEY.AggregateTypes = { - /** - * Displays the sum of the values in the specified column - */ - SUM: 'sum', - /** - * Displays the mean of the values in the specified column - */ - MEAN: 'mean', - /** - * Displays the count of the non-blank values in the specified column - */ - COUNT: 'count', - /** - * Displays the maximum value from the specified column - */ - MIN: 'min', - /** - * Displays the minimum values from the specified column - */ - MAX: 'max', - - /** - * Deprecated - */ - AVG: 'mean' - - // TODO how to allow premium module additions to aggregate types? -}; +/* + * Copyright (c) 2015-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +if (!LABKEY.DataRegions) { + LABKEY.DataRegions = {}; +} + +(function($) { + + // + // CONSTANTS + // + // Issue 48715: Limit the number of rows that can be displayed in a data region + var ALL_ROWS_MAX = 5_000; + var CUSTOM_VIEW_PANELID = '~~customizeView~~'; + var DEFAULT_TIMEOUT = 30_000; + const MAX_SELECTION_SIZE = 1_000; + var PARAM_PREFIX = '.param.'; + var SORT_ASC = '+'; + var SORT_DESC = '-'; + + // + // URL PREFIXES + // + var ALL_FILTERS_SKIP_PREFIX = '.~'; + var COLUMNS_PREFIX = '.columns'; + var CONTAINER_FILTER_NAME = '.containerFilterName'; + var MAX_ROWS_PREFIX = '.maxRows'; + var OFFSET_PREFIX = '.offset'; + var REPORTID_PREFIX = '.reportId'; + var SORT_PREFIX = '.sort'; + var SHOW_ROWS_PREFIX = '.showRows'; + var VIEWNAME_PREFIX = '.viewName'; + + // Issue 33536: These prefixes should match the URL parameter key exactly + var EXACT_MATCH_PREFIXES = [ + COLUMNS_PREFIX, + CONTAINER_FILTER_NAME, + MAX_ROWS_PREFIX, + OFFSET_PREFIX, + REPORTID_PREFIX, + SORT_PREFIX, + SHOW_ROWS_PREFIX, + VIEWNAME_PREFIX + ]; + + var VALID_LISTENERS = [ + /** + * @memberOf LABKEY.DataRegion.prototype + * @name afterpanelhide + * @event LABKEY.DataRegion.prototype#hidePanel + * @description Fires after hiding a visible 'Customize Grid' panel. + */ + 'afterpanelhide', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name afterpanelshow + * @event LABKEY.DataRegion.prototype.showPanel + * @description Fires after showing 'Customize Grid' panel. + */ + 'afterpanelshow', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforechangeview + * @event + * @description Fires before changing grid/view/report. + * @see LABKEY.DataRegion#changeView + */ + 'beforechangeview', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforeclearsort + * @event + * @description Fires before clearing sort applied to grid. + * @see LABKEY.DataRegion#clearSort + */ + 'beforeclearsort', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforemaxrowschange + * @event + * @description Fires before change page size. + * @see LABKEY.DataRegion#setMaxRows + */ + 'beforemaxrowschange', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforeoffsetchange + * @event + * @description Fires before change page number. + * @see LABKEY.DataRegion#setPageOffset + */ + 'beforeoffsetchange', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforerefresh + * @event + * @description Fires before refresh grid. + * @see LABKEY.DataRegion#refresh + */ + 'beforerefresh', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforesetparameters + * @event + * @description Fires before setting the parameterized query values for this query. + * @see LABKEY.DataRegion#setParameters + */ + 'beforesetparameters', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforesortchange + * @event + * @description Fires before change sorting on the grid. + * @see LABKEY.DataRegion#changeSort + */ + 'beforesortchange', + /** + * @memberOf LABKEY.DataRegion.prototype + * @member + * @name render + * @event + * @description Fires when data region renders. + */ + 'render', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name selectchange + * @event + * @description Fires when data region selection changes. + */ + 'selectchange', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name success + * @event + * @description Fires when data region loads successfully. + */ + 'success']; + + // TODO: Update constants to not include '.' so mapping can be used easier + var REQUIRE_NAME_PREFIX = { + '~': true, + 'columns': true, + 'param': true, + 'reportId': true, + 'sort': true, + 'offset': true, + 'maxRows': true, + 'showRows': true, + 'containerFilterName': true, + 'viewName': true, + 'disableAnalytics': true + }; + + // + // PRIVATE VARIABLES + // + var _paneCache = {}; + + /** + * The DataRegion constructor is private - to get a LABKEY.DataRegion object, use LABKEY.DataRegions['dataregionname']. + * @class LABKEY.DataRegion + * The DataRegion class allows you to interact with LabKey grids, including querying and modifying selection state, filters, and more. + * @constructor + */ + LABKEY.DataRegion = function(config) { + _init.call(this, config, true); + }; + + LABKEY.DataRegion.prototype.toJSON = function() { + return { + name: this.name, + schemaName: this.schemaName, + queryName: this.queryName, + viewName: this.viewName, + offset: this.offset, + maxRows: this.maxRows, + messages: this.msgbox.toJSON() // hmm, unsure exactly how this works + }; + }; + + /** + * + * @param {Object} config + * @param {Boolean} [applyDefaults=false] + * @private + */ + var _init = function(config, applyDefaults) { + + // ensure name + if (!config.dataRegionName) { + if (!config.name) { + this.name = LABKEY.Utils.id('aqwp'); + } + else { + this.name = config.name; + } + } + else if (!config.name) { + this.name = config.dataRegionName; + } + else { + this.name = config.name; + } + + if (!this.name) { + throw '"name" is required to initialize a LABKEY.DataRegion'; + } + + // _useQWPDefaults is only used on initial construction + var isQWP = config._useQWPDefaults === true; + delete config._useQWPDefaults; + + if (config.buttonBar && config.buttonBar.items && LABKEY.Utils.isArray(config.buttonBar.items)) { + // Be tolerant of the caller passing in undefined items, as pageSize has been removed as an option. Strip + // them out so they don't cause problems downstream. See Issue 34562. + config.buttonBar.items = config.buttonBar.items.filter(function (value, index, arr) { + return value; + }); + } + + var settings; + + if (applyDefaults) { + + // defensively remove, not allowed to be set + delete config._userSort; + + /** + * Config Options + */ + var defaults = { + + _allowHeaderLock: isQWP, + + _failure: isQWP ? LABKEY.Utils.getOnFailure(config) : undefined, + + _success: isQWP ? LABKEY.Utils.getOnSuccess(config) : undefined, + + aggregates: undefined, + + allowChooseQuery: undefined, + + allowChooseView: undefined, + + async: isQWP, + + bodyClass: undefined, + + buttonBar: undefined, + + buttonBarPosition: undefined, + + chartWizardURL: undefined, + + /** + * All rows visible on the current page. + */ + complete: false, + + /** + * The currently applied container filter. Note, this is only if it is set on the URL, otherwise + * the containerFilter could come from the view configuration. Use getContainerFilter() + * on this object to get the right value. + */ + containerFilter: undefined, + + containerPath: undefined, + + /** + * @deprecated use region.name instead + */ + dataRegionName: this.name, + + detailsURL: undefined, + + domId: undefined, + + /** + * The faceted filter pane as been loaded + * @private + */ + facetLoaded: false, + + filters: undefined, + + frame: isQWP ? undefined : 'none', + + errorType: 'html', + + /** + * Id of the DataRegion. Same as name property. + */ + id: this.name, + + deleteURL: undefined, + + importURL: undefined, + + insertURL: undefined, + + linkTarget: undefined, + + /** + * Maximum number of rows to be displayed. 0 if the count is not limited. Read-only. + */ + maxRows: 0, + + metadata: undefined, + + /** + * Name of the DataRegion. Should be unique within a given page. Read-only. This will also be used as the id. + */ + name: this.name, + + /** + * The index of the first row to return from the server (defaults to 0). Use this along with the maxRows config property to request pages of data. + */ + offset: 0, + + parameters: undefined, + + /** + * Name of the query to which this DataRegion is bound. Read-only. + */ + queryName: '', + + disableAnalytics: false, + + removeableContainerFilter: undefined, + + removeableFilters: undefined, + + removeableSort: undefined, + + renderTo: undefined, + + reportId: undefined, + + requestURL: isQWP ? window.location.href : (document.location.search.substring(1) /* strip the ? */ || ''), + + returnUrl: isQWP ? window.location.href : undefined, + + /** + * Schema name of the query to which this DataRegion is bound. Read-only. + */ + schemaName: '', + + /** + * An object to use as the callback function's scope. Defaults to this. + */ + scope: this, + + /** + * URL to use when selecting all rows in the grid. May be null. Read-only. + */ + selectAllURL: undefined, + + selectedCount: 0, + + shadeAlternatingRows: undefined, + + showBorders: undefined, + + showDeleteButton: undefined, + + showDetailsColumn: undefined, + + showExportButtons: undefined, + + showRStudioButton: undefined, + + showImportDataButton: undefined, + + showInsertNewButton: undefined, + + showPagination: undefined, + + showPaginationCount: undefined, + + showPaginationCountAsync: false, + + showRecordSelectors: false, + + showFilterDescription: true, + + showReports: undefined, + + /** + * An enum declaring which set of rows to show. all | selected | unselected | paginated + */ + showRows: 'paginated', + + showSurroundingBorder: undefined, + + showUpdateColumn: undefined, + + /** + * Open the customize view panel after rendering. The value of this option can be "true" or one of "ColumnsTab", "FilterTab", or "SortTab". + */ + showViewPanel: undefined, + + sort: undefined, + + sql: undefined, + + /** + * If true, no alert will appear if there is a problem rendering the QueryWebpart. This is most often encountered if page configuration changes between the time when a request was made and the content loads. Defaults to false. + */ + suppressRenderErrors: false, + + /** + * A timeout for the AJAX call, in milliseconds. + */ + timeout: undefined, + + title: undefined, + + titleHref: undefined, + + totalRows: undefined, // totalRows isn't available when showing all rows. + + updateURL: undefined, + + userContainerFilter: undefined, // TODO: Incorporate this with the standard containerFilter + + userFilters: {}, + + /** + * Name of the custom view to which this DataRegion is bound, may be blank. Read-only. + */ + viewName: null + }; + + settings = $.extend({}, defaults, config); + } + else { + settings = $.extend({}, config); + } + + // if showPaginationCountAsync is set to true, make sure that showPaginationCount is false + if (settings.showPaginationCountAsync && settings.showPaginationCount) { + settings.showPaginationCount = false; + } + + // if 'filters' is not specified and 'filterArray' is, use 'filterArray' + if (!LABKEY.Utils.isArray(settings.filters) && LABKEY.Utils.isArray(config.filterArray)) { + settings.filters = config.filterArray; + } + + // Any 'key' of this object will not be copied from settings to the region instance + var blackList = { + failure: true, + success: true + }; + + for (var s in settings) { + if (settings.hasOwnProperty(s) && !blackList[s]) { + this[s] = settings[s]; + } + } + + if (config.renderTo) { + _convertRenderTo(this, config.renderTo); + } + + if (LABKEY.Utils.isArray(this.removeableFilters)) { + LABKEY.Filter.appendFilterParams(this.userFilters, this.removeableFilters, this.name); + delete this.removeableFilters; // they've been applied + } + + // initialize sorting + if (this._userSort === undefined) { + this._userSort = _getUserSort(this, true /* asString */); + } + + if (LABKEY.Utils.isString(this.removeableSort)) { + this._userSort = this.removeableSort + (this._userSort ? this._userSort : ''); + delete this.removeableSort; + } + + this._allowHeaderLock = this.allowHeaderLock === true; + + if (!config.messages) { + this.messages = {}; + } + + /** + * @ignore + * Non-configurable Options + */ + this.selectionModified = false; + + if (this.panelConfigurations === undefined) { + this.panelConfigurations = {}; + } + + if (isQWP && this.renderTo) { + _load(this); + } + else if (!isQWP) { + _initContexts.call(this); + _initMessaging.call(this); + _initSelection.call(this); + _initPaging.call(this); + _initHeaderLocking.call(this); + _initCustomViews.call(this); + _initPanes.call(this); + _initReport.call(this); + } + // else the user needs to call render + + // bind supported listeners + if (isQWP) { + var me = this; + if (config.listeners) { + var scope = config.listeners.scope || me; + $.each(config.listeners, function(event, handler) { + if ($.inArray(event, VALID_LISTENERS) > -1) { + + // support either "event: function" or "event: { fn: function }" + var callback; + if ($.isFunction(handler)) { + callback = handler; + } + else if ($.isFunction(handler.fn)) { + callback = handler.fn; + } + else { + throw 'Unsupported listener configuration: ' + event; + } + + $(me).bind(event, function() { + callback.apply(scope, $(arguments).slice(1)); + }); + } + else if (event != 'scope') { + throw 'Unsupported listener: ' + event; + } + }); + } + } + }; + + LABKEY.DataRegion.prototype.destroy = function() { + // clean-up panel configurations because we preserve this in init + this.panelConfigurations = {}; + + // currently a no-op, but should be used to clean-up after ourselves + this.disableHeaderLock(); + }; + + /** + * Refreshes the grid, via AJAX region is in async mode (loaded through a QueryWebPart), + * and via a page reload otherwise. Can be prevented with a listener + * on the 'beforerefresh' + * event. + */ + LABKEY.DataRegion.prototype.refresh = function() { + $(this).trigger('beforerefresh', this); + + if (this.async) { + _load(this); + } + else { + window.location.reload(); + } + }; + + // + // Filtering + // + + /** + * Add a filter to this Data Region. + * @param {LABKEY.Filter} filter + * @see LABKEY.DataRegion.addFilter static method. + */ + LABKEY.DataRegion.prototype.addFilter = function(filter) { + this.clearSelected({quiet: true}); + _updateFilter(this, filter); + }; + + /** + * Removes all filters from the DataRegion + */ + LABKEY.DataRegion.prototype.clearAllFilters = function() { + this.clearSelected({quiet: true}); + if (this.async) { + this.offset = 0; + this.userFilters = {}; + } + + _removeParameters(this, [ALL_FILTERS_SKIP_PREFIX, OFFSET_PREFIX]); + }; + + /** + * Removes all the filters for a particular field + * @param {string|FieldKey} fieldKey the name of the field from which all filters should be removed + */ + LABKEY.DataRegion.prototype.clearFilter = function(fieldKey) { + this.clearSelected({quiet: true}); + var fk = _resolveFieldKey(this, fieldKey); + + if (fk) { + var columnPrefix = '.' + fk.toString() + '~'; + + if (this.async) { + this.offset = 0; + + if (this.userFilters) { + var namePrefix = this.name + columnPrefix, + me = this; + + $.each(this.userFilters, function(name, v) { + if (name.indexOf(namePrefix) >= 0) { + delete me.userFilters[name]; + } + }); + } + } + + _removeParameters(this, [columnPrefix, OFFSET_PREFIX]); + } + }; + + /** + * Returns an Array of LABKEY.Filter instances applied when creating this DataRegion. These cannot be removed through the UI. + * @returns {Array} Array of {@link LABKEY.Filter} objects that represent currently applied base filters. + */ + LABKEY.DataRegion.prototype.getBaseFilters = function() { + if (this.filters) { + return this.filters.slice(); + } + + return []; + }; + + /** + * Returns the {@link LABKEY.Query.containerFilter} currently applied to the DataRegion. Defaults to LABKEY.Query.containerFilter.current. + * @returns {String} The container filter currently applied to this DataRegion. Defaults to 'undefined' if a container filter is not specified by the configuration. + * @see LABKEY.DataRegion#getUserContainerFilter to get the containerFilter value from the URL. + */ + LABKEY.DataRegion.prototype.getContainerFilter = function() { + var cf; + + if (LABKEY.Utils.isString(this.containerFilter) && this.containerFilter.length > 0) { + cf = this.containerFilter; + } + else if (LABKEY.Utils.isObject(this.view) && LABKEY.Utils.isString(this.view.containerFilter) && this.view.containerFilter.length > 0) { + cf = this.view.containerFilter; + } + + return cf; + }; + + LABKEY.DataRegion.prototype.getDataRegion = function() { + return this; + }; + + /** + * Returns the user {@link LABKEY.Query.containerFilter} parameter from the URL. + * @returns {LABKEY.Query.containerFilter} The user container filter. + */ + LABKEY.DataRegion.prototype.getUserContainerFilter = function() { + return this.getParameter(this.name + CONTAINER_FILTER_NAME); + }; + + /** + * Returns the user filter from the URL. The filter is represented as an Array of objects of the form: + *
    + *
  • fieldKey: {String} The field key of the filter. + *
  • op: {String} The filter operator (eg. "eq" or "in") + *
  • value: {String} Optional value to filter by. + *
+ * @returns {Object} Object representing the user filter. + * @deprecated 12.2 Use getUserFilterArray instead + */ + LABKEY.DataRegion.prototype.getUserFilter = function() { + + if (LABKEY.devMode) { + console.warn([ + 'LABKEY.DataRegion.getUserFilter() is deprecated since release 12.2.', + 'Consider using getUserFilterArray() instead.' + ].join(' ')); + } + + return this.getUserFilterArray().map(function(filter) { + return { + fieldKey: filter.getColumnName(), + op: filter.getFilterType().getURLSuffix(), + value: filter.getValue() + }; + }); + }; + + /** + * Returns an Array of LABKEY.Filter instances constructed from the URL. + * @returns {Array} Array of {@link LABKEY.Filter} objects that represent currently applied filters. + */ + LABKEY.DataRegion.prototype.getUserFilterArray = function() { + var userFilter = [], me = this; + + _getParameters(this).forEach(function(pair) { + if (pair[0].indexOf(me.name + '.') == 0 && pair[0].indexOf('~') > -1) { + var tilde = pair[0].indexOf('~'); + var fieldKey = pair[0].substring(me.name.length + 1, tilde); + var op = pair[0].substring(tilde + 1); + userFilter.push(LABKEY.Filter.create(fieldKey, pair[1], LABKEY.Filter.getFilterTypeForURLSuffix(op))); + } + }); + + return userFilter; + }; + + /** + * Remove a filter on this DataRegion. + * @param {LABKEY.Filter} filter + */ + LABKEY.DataRegion.prototype.removeFilter = function(filter) { + this.clearSelected({quiet: true}); + if (LABKEY.Utils.isObject(filter) && LABKEY.Utils.isFunction(filter.getColumnName)) { + _updateFilter(this, null, [this.name + '.' + filter.getColumnName() + '~']); + } + }; + + /** + * Replace a filter on this Data Region. Optionally, supply another filter to replace for cases when the filter + * columns don't match exactly. + * @param {LABKEY.Filter} filter + * @param {LABKEY.Filter} [filterToReplace] + */ + LABKEY.DataRegion.prototype.replaceFilter = function(filter, filterToReplace) { + this.clearSelected({quiet: true}); + var target = filterToReplace ? filterToReplace : filter; + _updateFilter(this, filter, [this.name + '.' + target.getColumnName() + '~']); + }; + + /** + * @ignore + * @param filters + * @param columnNames + */ + LABKEY.DataRegion.prototype.replaceFilters = function(filters, columnNames) { + this.clearSelected({quiet: true}); + var filterPrefixes = [], + filterParams = [], + me = this; + + if (LABKEY.Utils.isArray(filters)) { + filters.forEach(function(filter) { + filterPrefixes.push(me.name + '.' + filter.getColumnName() + '~'); + filterParams.push([filter.getURLParameterName(me.name), filter.getURLParameterValue()]); + }); + } + + var fieldKeys = []; + + if (LABKEY.Utils.isArray(columnNames)) { + fieldKeys = fieldKeys.concat(columnNames); + } + else if ($.isPlainObject(columnNames) && columnNames.fieldKey) { + fieldKeys.push(columnNames.fieldKey.toString()); + } + + // support fieldKeys (e.g. ["ColumnA", "ColumnA/Sub1"]) + // A special case of fieldKey is "SUBJECT_PREFIX/", used by participant group facet + if (fieldKeys.length > 0) { + _getParameters(this).forEach(function(param) { + var p = param[0]; + if (p.indexOf(me.name + '.') === 0 && p.indexOf('~') > -1) { + $.each(fieldKeys, function(j, name) { + var postfix = name && name.length && name[name.length - 1] == '/' ? '' : '~'; + if (p.indexOf(me.name + '.' + name + postfix) > -1) { + filterPrefixes.push(p); + } + }); + } + }); + } + + _setParameters(this, filterParams, [OFFSET_PREFIX].concat($.unique(filterPrefixes))); + }; + + /** + * @private + * @param filter + * @param filterMatch + */ + LABKEY.DataRegion.prototype.replaceFilterMatch = function(filter, filterMatch) { + this.clearSelected({quiet: true}); + var skips = [], me = this; + + _getParameters(this).forEach(function(param) { + if (param[0].indexOf(me.name + '.') === 0 && param[0].indexOf(filterMatch) > -1) { + skips.push(param[0]); + } + }); + + _updateFilter(this, filter, skips); + }; + + // + // Selection + // + + /** + * @private + */ + var _initSelection = function() { + + var me = this, + form = _getFormSelector(this); + + if (form && form.length) { + // backwards compatibility -- some references use this directly + // if you're looking to use this internally to the region use _getFormSelector() instead + this.form = form[0]; + } + + if (form && this.showRecordSelectors) { + _onSelectionChange(this); + } + + // Bind Events + _getAllRowSelectors(this).on('click', function(evt) { + evt.stopPropagation(); + me.selectPage.call(me, this.checked); + }); + _getRowSelectors(this).on('click', function() { me.selectRow.call(me, this); }); + + // click row highlight + var rows = form.find('.labkey-data-region > tbody > tr'); + rows.on('click', function(e) { + if (e.target && e.target.tagName.toLowerCase() === 'td') { + $(this).siblings('tr').removeClass('lk-row-hl'); + $(this).addClass('lk-row-hl'); + _selClickLock = me; + } + }); + rows.on('mouseenter', function() { + $(this).siblings('tr').removeClass('lk-row-over'); + $(this).addClass('lk-row-over'); + }); + rows.on('mouseleave', function() { + $(this).removeClass('lk-row-over'); + }); + + if (!_selDocClick) { + _selDocClick = $(document).on('click', _onDocumentClick); + } + }; + + var _selClickLock; // lock to prevent removing a row highlight that was just applied + var _selDocClick; // global (shared across all Data Region instances) click event handler instance + + // Issue 32898: Clear row highlights on document click + var _onDocumentClick = function() { + if (_selClickLock) { + var form = _getFormSelector(_selClickLock); + _selClickLock = undefined; + + $('.lk-row-hl').each(function() { + if (!form.has($(this)).length) { + $(this).removeClass('lk-row-hl'); + } + }); + } + else { + $('.lk-row-hl').removeClass('lk-row-hl'); + } + }; + + /** + * Clear all selected items for the current DataRegion. + * + * @param config A configuration object with the following properties: + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'count' of 0 to indicate an empty selection. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion#selectPage + * @see LABKEY.DataRegion.clearSelected static method. + */ + LABKEY.DataRegion.prototype.clearSelected = function(config) { + config = config || {}; + config.selectionKey = this.selectionKey; + config.scope = config.scope || this; + + this.selectedCount = 0; + if (!config.quiet) + { + _onSelectionChange(this); + } + + if (config.selectionKey) { + LABKEY.DataRegion.clearSelected(config); + } + + if (this.showRows == 'selected') { + _removeParameters(this, [SHOW_ROWS_PREFIX]); + } + else if (this.showRows == 'unselected') { + // keep "SHOW_ROWS_PREFIX=unselected" parameter + window.location.reload(true); + } + else { + _toggleAllRows(this, false); + this.removeMessage('selection'); + } + }; + + /** + * Get selected items on the current page of the DataRegion, based on the current state of the checkboxes in the + * browser's DOM. Note, if the region is paginated, selected items may exist on other pages which will not be + * included in the results of this function. + * @see LABKEY.DataRegion#getSelected + */ + LABKEY.DataRegion.prototype.getChecked = function() { + var values = []; + _getRowSelectors(this).each(function() { + if (this.checked) { + values.push(this.value); + } + }); + return values; + }; + + /** + * Get all selected items for this DataRegion, as maintained in server-state. This will include rows on any + * pages of a paginated grid, and may not correspond directly with the state of the checkboxes in the current + * browser window's DOM if the server-side state has been modified. + * + * @param config A configuration object with the following properties: + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'selected' that is an array of the primary keys for the selected rows. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion.getSelected static method. + */ + LABKEY.DataRegion.prototype.getSelected = function(config) { + if (!this.selectionKey) + return; + + config = config || {}; + config.selectionKey = this.selectionKey; + LABKEY.DataRegion.getSelected(config); + }; + + /** + * Returns the number of selected rows on the current page of the DataRegion. Selected items may exist on other pages. + * @returns {Integer} the number of selected rows on the current page of the DataRegion. + * @see LABKEY.DataRegion#getSelected to get all selected rows. + */ + LABKEY.DataRegion.prototype.getSelectionCount = function() { + if (!$('#' + this.domId)) { + return 0; + } + + var count = 0; + _getRowSelectors(this).each(function() { + if (this.checked === true) { + count++; + } + }); + + return count; + }; + + /** + * Returns true if any row is checked on the current page of the DataRegion. Selected items may exist on other pages. + * @returns {Boolean} true if any row is checked on the current page of the DataRegion. + * @see LABKEY.DataRegion#getSelected to get all selected rows. + */ + LABKEY.DataRegion.prototype.hasSelected = function() { + return this.getSelectionCount() > 0; + }; + + /** + * Returns true if all rows are checked on the current page of the DataRegion and at least one row is present. + * @returns {Boolean} true if all rows are checked on the current page of the DataRegion and at least one row is present. + * @see LABKEY.DataRegion#getSelected to get all selected rows. + */ + LABKEY.DataRegion.prototype.isPageSelected = function() { + var checkboxes = _getRowSelectors(this); + var i=0; + + for (; i < checkboxes.length; i++) { + if (!checkboxes[i].checked) { + return false; + } + } + return i > 0; + }; + + LABKEY.DataRegion.prototype.selectAll = function(config) { + if (this.selectionKey) { + config = config || {}; + config.scope = config.scope || this; + + // Either use the selectAllURL provided or create a query config + // object that can be used with the generic query/selectAll.api action. + if (this.selectAllURL) { + config.url = this.selectAllURL; + } + else { + config = LABKEY.Utils.apply(config, this.getQueryConfig()); + } + + config = _chainSelectionCountCallback(this, config); + + LABKEY.DataRegion.selectAll(config); + + if (this.showRows === "selected") { + // keep "SHOW_ROWS_PREFIX=selected" parameter + window.location.reload(true); + } + else if (this.showRows === "unselected") { + _removeParameters(this, [SHOW_ROWS_PREFIX]); + } + else { + _toggleAllRows(this, true); + } + } + }; + + /** + * @deprecated use clearSelected instead + * @function + * @see LABKEY.DataRegion#clearSelected + */ + LABKEY.DataRegion.prototype.selectNone = LABKEY.DataRegion.prototype.clearSelected; + + /** + * Set the selection state for all checkboxes on the current page of the DataRegion. + * @param checked whether all of the rows on the current page should be selected or unselected + * @returns {Array} Array of ids that were selected or unselected. + * + * @see LABKEY.DataRegion#setSelected to set selected items on the current page of the DataRegion. + * @see LABKEY.DataRegion#clearSelected to clear all selected. + */ + LABKEY.DataRegion.prototype.selectPage = function(checked) { + var _check = (checked === true); + var ids = _toggleAllRows(this, _check); + var me = this; + + if (ids.length > 0) { + _getAllRowSelectors(this).each(function() { this.checked = _check}); + this.setSelected({ + ids: ids, + checked: _check, + success: function(data) { + if (data && data.count > 0 && !this.complete) { + var count = data.count; + var msg; + if (me.totalRows) { + if (count == me.totalRows) { + msg = 'All ' + this.totalRows + ' rows selected.'; + } + else { + msg = 'Selected ' + count + ' of ' + this.totalRows + ' rows.'; + } + } + else { + // totalRows isn't available when showing all rows. + msg = 'Selected ' + count + ' rows.'; + } + _showSelectMessage(me, msg); + } + else { + this.removeMessage('selection'); + } + } + }); + } + + return ids; + }; + + /** + * @ignore + * @param el + */ + LABKEY.DataRegion.prototype.selectRow = function(el) { + this.setSelected({ + ids: [el.value], + checked: el.checked + }); + + if (!el.checked) { + this.removeMessage('selection'); + } + }; + + /** + * Add or remove items from the selection associated with this DataRegion. + * + * @param config A configuration object with the following properties: + * @param {Array} config.ids Array of primary key ids for each row to select/unselect. + * @param {Boolean} config.checked If true, the ids will be selected, otherwise unselected. + * @param {Function} [config.success] The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'count' to indicate the updated selection count. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion#getSelected to get the selected items for this DataRegion. + * @see LABKEY.DataRegion#clearSelected to clear all selected items for this DataRegion. + */ + LABKEY.DataRegion.prototype.setSelected = function(config) { + if (!config || !LABKEY.Utils.isArray(config.ids) || config.ids.length === 0) { + return; + } + + var me = this; + config = config || {}; + config.selectionKey = this.selectionKey; + config.scope = config.scope || me; + + config = _chainSelectionCountCallback(this, config); + + var failure = LABKEY.Utils.getOnFailure(config); + if ($.isFunction(failure)) { + config.failure = failure; + } + else { + config.failure = function(error) { + let msg = 'Error setting selection'; + if (error && error.exception) msg += ': ' + error.exception; + me.addMessage(msg, 'selection'); + }; + } + + if (config.selectionKey) { + LABKEY.DataRegion.setSelected(config); + } + else if ($.isFunction(config.success)) { + // Don't send the selection change to the server if there is no selectionKey. + // Call the success callback directly. + config.success.call(config.scope, {count: this.getSelectionCount()}); + } + }; + + // + // Parameters + // + + /** + * Removes all parameters from the DataRegion + */ + LABKEY.DataRegion.prototype.clearAllParameters = function() { + if (this.async) { + this.offset = 0; + this.parameters = undefined; + } + + _removeParameters(this, [PARAM_PREFIX, OFFSET_PREFIX]); + }; + + /** + * Returns the specified parameter from the URL. Note, this is not related specifically + * to parameterized query values (e.g. setParameters()/getParameters()) + * @param {String} paramName + * @returns {*} + */ + LABKEY.DataRegion.prototype.getParameter = function(paramName) { + var param = null; + + $.each(_getParameters(this), function(i, pair) { + if (pair.length > 0 && pair[0] === paramName) { + param = pair.length > 1 ? pair[1] : ''; + return false; + } + }); + + return param; + }; + + /** + * Get the parameterized query values for this query. These parameters + * are named by the query itself. + * @param {boolean} toLowercase If true, all parameter names will be converted to lowercase + * returns params An Object of key/val pairs. + */ + LABKEY.DataRegion.prototype.getParameters = function(toLowercase) { + + var params = this.parameters ? this.parameters : {}, + re = new RegExp('^' + LABKEY.Utils.escapeRe(this.name) + LABKEY.Utils.escapeRe(PARAM_PREFIX), 'i'), + name; + + _getParameters(this).forEach(function(pair) { + if (pair.length > 0 && pair[0].match(re)) { + name = pair[0].replace(re, ''); + if (toLowercase === true) { + name = name.toLowerCase(); + } + + // URL parameters will override this.parameters values + params[name] = pair[1]; + } + }); + + return params; + }; + + /** + * Set the parameterized query values for this query. These parameters + * are named by the query itself. + * @param {Mixed} params An Object or Array of Array key/val pairs. + */ + LABKEY.DataRegion.prototype.setParameters = function(params) { + var event = $.Event('beforesetparameters'); + + $(this).trigger(event); + + if (event.isDefaultPrevented()) { + return; + } + + var paramPrefix = this.name + PARAM_PREFIX, _params = []; + var newParameters = this.parameters ? this.parameters : {}; + + function applyParameters(pKey, pValue) { + var key = pKey; + if (pKey.indexOf(paramPrefix) !== 0) { + key = paramPrefix + pKey; + } + newParameters[key.replace(paramPrefix, '')] = pValue; + _params.push([key, pValue]); + } + + // convert Object into Array of Array pairs and prefix the parameter name if necessary. + if (LABKEY.Utils.isObject(params)) { + $.each(params, applyParameters); + } + else if (LABKEY.Utils.isArray(params)) { + params.forEach(function(pair) { + if (LABKEY.Utils.isArray(pair) && pair.length > 1) { + applyParameters(pair[0], pair[1]); + } + }); + } + else { + return; // invalid argument shape + } + + this.parameters = newParameters; + + _setParameters(this, _params, [PARAM_PREFIX, OFFSET_PREFIX]); + }; + + /** + * @ignore + * @Deprecated + */ + LABKEY.DataRegion.prototype.setSearchString = function(regionName, search) { + this.savedSearchString = search || ""; + // If the search string doesn't change and there is a hash on the url, the page won't reload. + // Remove the hash by setting the full path plus search string. + window.location.assign(window.location.pathname + (this.savedSearchString.length > 0 ? "?" + this.savedSearchString : "")); + }; + + // + // Messaging + // + + /** + * @private + */ + var _initMessaging = function() { + if (!this.msgbox) { + this.msgbox = new MessageArea(this); + this.msgbox.on('rendermsg', function(evt, msgArea, parts) { _onRenderMessageArea(this, parts); }, this); + } + else { + this.msgbox.bindRegion(this); + } + + if (this.messages) { + this.msgbox.setMessages(this.messages); + this.msgbox.render(); + } + }; + + /** + * Show a message in the header of this DataRegion. + * @param {String / Object} config the HTML source of the message to be shown or a config object with the following properties: + *
    + *
  • html: {String} the HTML source of the message to be shown.
  • + *
  • part: {String} The part of the message area to render the message to.
  • + *
  • duration: {Integer} The amount of time (in milliseconds) the message will stay visible.
  • + *
  • hideButtonPanel: {Boolean} If true the button panel (customize view, export, etc.) will be hidden if visible.
  • + *
  • append: {Boolean} If true the msg is appended to any existing content for the given part.
  • + *
+ * @param part The part of the message area to render the message to. Used to scope messages so they can be added + * and removed without clearing other messages. + */ + LABKEY.DataRegion.prototype.addMessage = function(config, part) { + this.hidePanel(); + + if (LABKEY.Utils.isString(config)) { + this.msgbox.addMessage(config, part); + } + else if (LABKEY.Utils.isObject(config)) { + this.msgbox.addMessage(config.html, config.part || part, config.append); + + if (config.hideButtonPanel) { + this.hideButtonPanel(); + } + + if (config.duration) { + var dr = this; + setTimeout(function() { + dr.removeMessage(config.part || part); + _getHeaderSelector(dr).trigger('resize'); + }, config.duration); + } + } + }; + + /** + * Clear the message box contents. + */ + LABKEY.DataRegion.prototype.clearMessage = function() { + if (this.msgbox) this.msgbox.removeAll(); + }; + + /** + * @param part The part of the message area to render the message to. Used to scope messages so they can be added + * and removed without clearing other messages. + * @return {String} The message for 'part'. Could be undefined. + */ + LABKEY.DataRegion.prototype.getMessage = function(part) { + if (this.msgbox) { return this.msgbox.getMessage(part); } // else undefined + }; + + /** + * @param part The part of the message area to render the message to. Used to scope messages so they can be added + * and removed without clearing other messages. + * @return {Boolean} true iff there is a message area for this region and it has the message keyed by 'part'. + */ + LABKEY.DataRegion.prototype.hasMessage = function(part) { + return this.msgbox && this.msgbox.hasMessage(part); + }; + + LABKEY.DataRegion.prototype.hideContext = function() { + _getContextBarSelector(this).hide(); + _getViewBarSelector(this).hide(); + }; + + /** + * If a message is currently showing, hide it and clear out its contents + * @param keepContent If true don't remove the message area content + */ + LABKEY.DataRegion.prototype.hideMessage = function(keepContent) { + if (this.msgbox) { + this.msgbox.hide(); + + if (!keepContent) + this.removeAllMessages(); + } + }; + + /** + * Returns true if a message is currently being shown for this DataRegion. Messages are shown as a header. + * @return {Boolean} true if a message is showing. + */ + LABKEY.DataRegion.prototype.isMessageShowing = function() { + return this.msgbox && this.msgbox.isVisible(); + }; + + /** + * Removes all messages from this Data Region. + */ + LABKEY.DataRegion.prototype.removeAllMessages = function() { + if (this.msgbox) { this.msgbox.removeAll(); } + }; + + /** + * If a message is currently showing, remove the specified part + */ + LABKEY.DataRegion.prototype.removeMessage = function(part) { + if (this.msgbox) { this.msgbox.removeMessage(part); } + }; + + /** + * Show a message in the header of this DataRegion with a loading indicator. + * @param html the HTML source of the message to be shown + */ + LABKEY.DataRegion.prototype.showLoadingMessage = function(html) { + html = html || "Loading..."; + this.addMessage('
 ' + html + '
', 'drloading'); + }; + + LABKEY.DataRegion.prototype.hideLoadingMessage = function() { + this.removeMessage('drloading'); + }; + + /** + * Show a success message in the header of this DataRegion. + * @param html the HTML source of the message to be shown + */ + LABKEY.DataRegion.prototype.showSuccessMessage = function(html) { + html = html || "Completed successfully."; + this.addMessage('
' + html + '
'); + }; + + /** + * Show an error message in the header of this DataRegion. + * @param html the HTML source of the message to be shown + */ + LABKEY.DataRegion.prototype.showErrorMessage = function(html) { + html = html || "An error occurred."; + this.addMessage('
' + html + '
'); + }; + + LABKEY.DataRegion.prototype.showContext = function() { + _initContexts(); + + var contexts = [ + _getContextBarSelector(this), + _getViewBarSelector(this) + ]; + + for (var i = 0; i < contexts.length; i++) { + var ctx = contexts[i]; + var html = ctx.html(); + + if (html && html.trim() !== '') { + ctx.show(); + } + } + }; + + /** + * Show a message in the header of this DataRegion. + * @param msg the HTML source of the message to be shown + * @deprecated use addMessage(msg, part) instead. + */ + LABKEY.DataRegion.prototype.showMessage = function(msg) { + if (this.msgbox) { + this.msgbox.addMessage(msg); + } + }; + + LABKEY.DataRegion.prototype.showMessageArea = function() { + if (this.msgbox && this.msgbox.hasContent()) { + this.msgbox.show(); + } + }; + + // + // Sections + // + + LABKEY.DataRegion.prototype.displaySection = function(options) { + var dir = options && options.dir ? options.dir : 'n'; + + var sec = _getSectionSelector(this, dir); + if (options && options.html) { + options.append === true ? sec.append(options.html) : sec.html(options.html); + } + sec.show(); + }; + + LABKEY.DataRegion.prototype.hideSection = function(options) { + var dir = options && options.dir ? options.dir : 'n'; + var sec = _getSectionSelector(this, dir); + + sec.hide(); + + if (options && options.clear === true) { + sec.html(''); + } + }; + + LABKEY.DataRegion.prototype.writeSection = function(content, options) { + var append = options && options.append === true; + var dir = options && options.dir ? options.dir : 'n'; + + var sec = _getSectionSelector(this, dir); + append ? sec.append(content) : sec.html(content); + }; + + // + // Sorting + // + + /** + * Replaces the sort on the given column, if present, or sets a brand new sort + * @param {string or LABKEY.FieldKey} fieldKey name of the column to be sorted + * @param {string} [sortDir=+] Set to '+' for ascending or '-' for descending + */ + LABKEY.DataRegion.prototype.changeSort = function(fieldKey, sortDir) { + if (!fieldKey) + return; + + fieldKey = _resolveFieldKey(this, fieldKey); + + var columnName = fieldKey.toString(); + + var event = $.Event("beforesortchange"); + + $(this).trigger(event, [this, columnName, sortDir]); + + if (event.isDefaultPrevented()) { + return; + } + + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + this._userSort = _alterSortString(this, this._userSort, fieldKey, sortDir); + _setParameter(this, SORT_PREFIX, this._userSort, [SORT_PREFIX, OFFSET_PREFIX]); + }; + + /** + * Removes the sort on a specified column + * @param {string or LABKEY.FieldKey} fieldKey name of the column + */ + LABKEY.DataRegion.prototype.clearSort = function(fieldKey) { + if (!fieldKey) + return; + + fieldKey = _resolveFieldKey(this, fieldKey); + + var columnName = fieldKey.toString(); + + var event = $.Event("beforeclearsort"); + + $(this).trigger(event, [this, columnName]); + + if (event.isDefaultPrevented()) { + return; + } + + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + this._userSort = _alterSortString(this, this._userSort, fieldKey); + if (this._userSort.length > 0) { + _setParameter(this, SORT_PREFIX, this._userSort, [SORT_PREFIX, OFFSET_PREFIX]); + } + else { + _removeParameters(this, [SORT_PREFIX, OFFSET_PREFIX]); + } + }; + + /** + * Returns the user sort from the URL. The sort is represented as an Array of objects of the form: + *
    + *
  • fieldKey: {String} The field key of the sort. + *
  • dir: {String} The sort direction, either "+" or "-". + *
+ * @returns {Object} Object representing the user sort. + */ + LABKEY.DataRegion.prototype.getUserSort = function() { + return _getUserSort(this); + }; + + // + // Paging + // + + var _initPaging = function() { + if (this.showPagination) { + // Issue 51036: load totalRows count async for DataRegions + if (!this.complete && this.showPaginationCountAsync && !this.skipTotalRowCount && this.loadingTotalRows === undefined) { + var params = _getAsyncParams(this, _getParameters(this), false); + var jsonData = _getAsyncBody(this, params); + _loadAsyncTotalRowCount(this, params, jsonData); + } + + var ct = _getBarSelector(this).find('.labkey-pagination'); + + if (ct && ct.length) { + var hasOffset = $.isNumeric(this.offset); + var hasTotal = $.isNumeric(this.totalRows); + + // display the counts + if (hasOffset) { + + // small result set + if (hasTotal && this.totalRows < 5) { + return; + } + + var low = this.offset + 1; + var high = this.offset + this.rowCount; + + // user has opted to show all rows + if (hasTotal && (this.rowCount === null || this.rowCount < 1)) { + high = this.totalRows; + } + + var showFirst = _showFirstEnabled(this); + var showLast = _showLastEnabled(this); + var showAll = _showAllEnabled(this); + this.showFirstID = LABKEY.Utils.id(); + this.showLastID = LABKEY.Utils.id(); + this.showAllID = LABKEY.Utils.id(); + + // If modifying this ensure it is consistent with DOM generated by PopupMenu.java + var elems = [ + ''); + ct.append(elems.join('')); + + //bind functions to menu items + _getShowFirstSelector(this).click(_firstPage.bind(this)); + _getShowLastSelector(this).click(_lastPage.bind(this)); + _getShowAllSelector(this).click(this.showAllRows.bind(this)); + + if (_isMaxRowsAllRows(this) && this.totalRows > this.maxRows) { + this.addMessage('Show all: Displaying the first ' + ALL_ROWS_MAX.toLocaleString() + ' rows. Use paging to see more results.'); + } + + for (var key in offsetIds) { + if (offsetIds.hasOwnProperty(key)) { + $('#' + key).click(_setMaxRows.bind(this, offsetIds[key])); + } + } + + // only display buttons if all the results are not shown + if (low === 1 && high === this.totalRows) { + _getBarSelector(this).find('.paging-widget').css("top", "4px"); + return; + } + + var canNext = this.maxRows > 0 && high !== this.totalRows, + canPrev = this.maxRows > 0 && low > 1, + prevId = LABKEY.Utils.id(), + nextId = LABKEY.Utils.id(); + + ct.append([ + '
', + '', + '', + '
' + ].join('')); + + var prev = $('#' + prevId); + prev.click(_page.bind(this, this.offset - this.maxRows, canPrev)); + if (!canPrev) { + prev.addClass('disabled'); + } + + var next = $('#' + nextId); + next.click(_page.bind(this, this.offset + this.maxRows, canNext)); + if (!canNext) { + next.addClass('disabled'); + } + } + } + } + else { + _getHeaderSelector(this).find('div.labkey-pagination').css('visibility', 'visible'); + } + }; + + var _showFirstEnabled = function(region) { + return region.offset && region.offset > 0; + }; + + var _showLastEnabled = function(region) { + var low = region.offset + 1; + var high = region.offset + region.rowCount; + return !(low === 1 && high === region.totalRows) && (region.offset + region.maxRows <= region.totalRows); + }; + + var _showAllEnabled = function(region) { + return (_showFirstEnabled(region) || _showLastEnabled(region)) && !_isMaxRowsAllRows(region); + }; + + var _getPaginationText = function(region) { + var hasTotal = $.isNumeric(region.totalRows); + var low = region.offset + 1; + var high = region.offset + region.rowCount; + + var paginationText = low.toLocaleString() + ' - ' + high.toLocaleString(); + if (region.showPaginationCount || region.showPaginationCountAsync) { + if (hasTotal) { + paginationText += ' of ' + region.totalRows.toLocaleString(); + } else if (region.loadingTotalRows) { + paginationText += ' of '; + } + } + + return paginationText; + }; + + var _page = function(offset, enabled) { + if (enabled) { + this.setPageOffset(offset); + } + return false; + }; + + var _firstPage = function() { + if (_showFirstEnabled(this)) { + this.setPageOffset(0); + } + return false; + }; + + var _lastPage = function() { + if (_showLastEnabled(this)) { + var lastPageSize = this.totalRows % this.maxRows === 0 ? this.maxRows : this.totalRows % this.maxRows; + this.setPageOffset(this.totalRows - lastPageSize); + } + return false; + }; + + var _setMaxRows = function(rows) { + if (this.maxRows !== rows) { + this.setMaxRows(rows); + } + return false; + }; + + var _isMaxRowsAllRows = function(region) { + return region.maxRows === ALL_ROWS_MAX; + }; + + /** + * Forces the grid to show all rows, up to ALL_ROWS_MAX, without any paging + */ + LABKEY.DataRegion.prototype.showAllRows = function() { + _setMaxRows.bind(this, ALL_ROWS_MAX)(); + }; + + /** + * @deprecated use showAllRows instead + * @function + * @see LABKEY.DataRegion#showAllRows + */ + LABKEY.DataRegion.prototype.showAll = LABKEY.DataRegion.prototype.showAllRows; + + /** + * Forces the grid to show only rows that have been selected + */ + LABKEY.DataRegion.prototype.showSelectedRows = function() { + _showRows(this, 'selected'); + }; + /** + * @deprecated use showSelectedRows instead + * @function + * @see LABKEY.DataRegion#showSelectedRows + */ + LABKEY.DataRegion.prototype.showSelected = LABKEY.DataRegion.prototype.showSelectedRows; + + /** + * Forces the grid to show only rows that have not been selected + */ + LABKEY.DataRegion.prototype.showUnselectedRows = function() { + _showRows(this, 'unselected'); + }; + /** + * @deprecated use showUnselectedRows instead + * @function + * @see LABKEY.DataRegion#showUnselectedRows + */ + LABKEY.DataRegion.prototype.showUnselected = LABKEY.DataRegion.prototype.showUnselectedRows; + + /** + * Forces the grid to do paging based on the current maximum number of rows + */ + LABKEY.DataRegion.prototype.showPaged = function() { + _removeParameters(this, [SHOW_ROWS_PREFIX]); + }; + + /** + * Displays the first page of the grid + */ + LABKEY.DataRegion.prototype.showFirstPage = function() { + this.setPageOffset(0); + }; + /** + * @deprecated use showFirstPage instead + * @function + * @see LABKEY.DataRegion#showFirstPage + */ + LABKEY.DataRegion.prototype.pageFirst = LABKEY.DataRegion.prototype.showFirstPage; + + /** + * Changes the current row offset for paged content + * @param rowOffset row index that should be at the top of the grid + */ + LABKEY.DataRegion.prototype.setPageOffset = function(rowOffset) { + var event = $.Event('beforeoffsetchange'); + + $(this).trigger(event, [this, rowOffset]); + + if (event.isDefaultPrevented()) { + return; + } + + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + // clear sibling parameters + this.showRows = undefined; + + if ($.isNumeric(rowOffset)) { + _setParameter(this, OFFSET_PREFIX, rowOffset, [OFFSET_PREFIX, SHOW_ROWS_PREFIX]); + } + else { + _removeParameters(this, [OFFSET_PREFIX, SHOW_ROWS_PREFIX]); + } + }; + /** + * @deprecated use setPageOffset instead + * @function + * @see LABKEY.DataRegion#setPageOffset + */ + LABKEY.DataRegion.prototype.setOffset = LABKEY.DataRegion.prototype.setPageOffset; + + /** + * Changes the maximum number of rows that the grid will display at one time + * @param newmax the maximum number of rows to be shown + */ + LABKEY.DataRegion.prototype.setMaxRows = function(newmax) { + var event = $.Event('beforemaxrowschange'); + $(this).trigger(event, [this, newmax]); + if (event.isDefaultPrevented()) { + return; + } + + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + // clear sibling parameters + this.showRows = undefined; + this.offset = 0; + + _setParameter(this, MAX_ROWS_PREFIX, newmax, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); + }; + + var _initContexts = function() { + // clear old contents + var ctxBar = _getContextBarSelector(this); + ctxBar.find('.labkey-button-bar').remove(); + + var numFilters = ctxBar.find('.fa-filter').length; + var numParams = ctxBar.find('.fa-question').length; + + var html = []; + + if (numParams > 0) { + html = html.concat([ + '
', + 'Clear Variables', + '
' + ]) + } + + if (numFilters >= 2) { + html = html.concat([ + '
', + '' + + (numParams > 0 ? 'Clear Filters' : 'Clear All') + + '', + '
' + ]); + } + + if (html.length) { + ctxBar.append(html.join('')); + ctxBar.find('.ctx-clear-var').off('click').on('click', $.proxy(this.clearAllParameters, this)); + ctxBar.find('.ctx-clear-all').off('click').on('click', $.proxy(this.clearAllFilters, this)); + } + + // Issue 35396: Support ButtonBarOptions + if (LABKEY.Utils.isArray(this.buttonBarOnRenders)) { + for (var i=0; i < this.buttonBarOnRenders.length; i++) { + var scriptFnName = this.buttonBarOnRenders[i]; + var fnParts = scriptFnName.split('.'); + var scope = window; + var called = false; + + for (var j=0; j < fnParts.length; j++) { + scope = scope[fnParts[j]]; + if (!scope) break; + if (j === fnParts.length - 1 && LABKEY.Utils.isFunction(scope)) { + scope(this); + called = true; + } + } + + if (!called) { + console.warn('Unable to call "' + scriptFnName + '" for DataRegion.ButtonBar.onRender.'); + } + } + } + }; + + // + // Customize View + // + var _initCustomViews = function() { + if (this.view && this.view.session) { + // clear old contents + _getViewBarSelector(this).find('.labkey-button-bar').remove(); + + _getViewBarSelector(this).append([ + '
', + 'This grid view has been modified.', + 'Revert', + 'Edit', + 'Save', + '
' + ].join('')); + _getViewBarSelector(this).find('.unsavedview-revert').off('click').on('click', $.proxy(function() { + _revertCustomView(this); + }, this)); + _getViewBarSelector(this).find('.unsavedview-edit').off('click').on('click', $.proxy(function() { + this.showCustomizeView(undefined); + }, this)); + _getViewBarSelector(this).find('.unsavedview-save').off('click').on('click', $.proxy(function() { + _saveSessionCustomView(this); + }, this)); + } + }; + + /** + * Change the currently selected view to the named view + * @param {Object} view An object which contains the following properties. + * @param {String} [view.type] the type of view, either a 'view' or a 'report'. + * @param {String} [view.viewName] If the type is 'view', then the name of the view. + * @param {String} [view.reportId] If the type is 'report', then the report id. + * @param {Object} urlParameters NOTE: Experimental parameter; may change without warning. A set of filter and sorts to apply as URL parameters when changing the view. + */ + LABKEY.DataRegion.prototype.changeView = function(view, urlParameters) { + var event = $.Event('beforechangeview'); + $(this).trigger(event, [this, view, urlParameters]); + if (event.isDefaultPrevented()) { + return; + } + + var paramValPairs = [], + newSort = [], + skipPrefixes = [OFFSET_PREFIX, SHOW_ROWS_PREFIX, VIEWNAME_PREFIX, REPORTID_PREFIX]; + + // clear sibling parameters + this.viewName = undefined; + this.reportId = undefined; + + if (view) { + if (LABKEY.Utils.isString(view)) { + paramValPairs.push([VIEWNAME_PREFIX, view]); + this.viewName = view; + } + else if (view.type === 'report') { + paramValPairs.push([REPORTID_PREFIX, view.reportId]); + this.reportId = view.reportId; + } + else if (view.type === 'view' && view.viewName) { + paramValPairs.push([VIEWNAME_PREFIX, view.viewName]); + this.viewName = view.viewName; + } + } + + if (urlParameters) { + $.each(urlParameters.filter, function(i, filter) { + paramValPairs.push(['.' + filter.fieldKey + '~' + filter.op, filter.value]); + }); + + if (urlParameters.sort && urlParameters.sort.length > 0) { + $.each(urlParameters.sort, function(i, sort) { + newSort.push((sort.dir === '+' ? '' : sort.dir) + sort.fieldKey); + }); + paramValPairs.push([SORT_PREFIX, newSort.join(',')]); + } + + if (urlParameters.containerFilter) { + paramValPairs.push([CONTAINER_FILTER_NAME, urlParameters.containerFilter]); + } + + // removes all filter, sort, and container filter parameters + skipPrefixes = skipPrefixes.concat([ + ALL_FILTERS_SKIP_PREFIX, SORT_PREFIX, COLUMNS_PREFIX, CONTAINER_FILTER_NAME + ]); + } + + // removes all filter, sort, and container filter parameters + _setParameters(this, paramValPairs, skipPrefixes); + }; + + LABKEY.DataRegion.prototype.getQueryDetails = function(success, failure, scope) { + + var userSort = this.getUserSort(), + userColumns = this.getParameter(this.name + COLUMNS_PREFIX), + fields = [], + viewName = (this.view && this.view.name) || this.viewName || ''; + + var userFilter = this.getUserFilterArray().map(function(filter) { + var fieldKey = filter.getColumnName(); + fields.push(fieldKey); + + return { + fieldKey: fieldKey, + op: filter.getFilterType().getURLSuffix(), + value: filter.getValue() + }; + }); + + userSort.forEach(function(sort) { + fields.push(sort.fieldKey); + }); + + LABKEY.Query.getQueryDetails({ + containerPath: this.containerPath, + schemaName: this.schemaName, + queryName: this.queryName, + viewName: viewName, + fields: fields, + initializeMissingView: true, + success: function(queryDetails) { + success.call(scope || this, queryDetails, viewName, userColumns, userFilter, userSort); + }, + failure: failure, + scope: scope + }); + }; + + /** + * Hides the customize view interface if it is visible. + */ + LABKEY.DataRegion.prototype.hideCustomizeView = function() { + if (this.activePanelId === CUSTOM_VIEW_PANELID) { + this.hideButtonPanel(); + } + }; + + /** + * Show the customize view interface. + * @param activeTab {[String]} Optional. One of "ColumnsTab", "FilterTab", or "SortTab". If no value is specified (or undefined), the ColumnsTab will be shown. + */ + LABKEY.DataRegion.prototype.showCustomizeView = function(activeTab) { + var region = this; + + var panelConfig = this.getPanelConfiguration(CUSTOM_VIEW_PANELID); + + if (!panelConfig) { + + // whistle while we wait + var timerId = setTimeout(function() { + timerId = 0; + region.showLoadingMessage("Opening custom view designer..."); + }, 500); + + LABKEY.DataRegion.loadViewDesigner(function() { + + var success = function(queryDetails, viewName, userColumns, userFilter, userSort) { + timerId > 0 ? clearTimeout(timerId) : this.hideLoadingMessage(); + + // If there was an error parsing the query, we won't be able to render the customize view panel. + if (queryDetails.exception) { + var viewSourceUrl = LABKEY.ActionURL.buildURL('query', 'viewQuerySource.view', this.containerPath, { + schemaName: this.schemaName, + 'query.queryName': this.queryName + }); + var msg = LABKEY.Utils.encodeHtml(queryDetails.exception) + + "  View Source"; + + this.showErrorMessage(msg); + return; + } + + this.customizeView = Ext4.create('LABKEY.internal.ViewDesigner.Designer', { + renderTo: Ext4.getBody().createChild({tag: 'div', customizeView: true, style: {display: 'none'}}), + activeTab: activeTab, + dataRegion: this, + containerPath : this.containerPath, + schemaName: this.schemaName, + queryName: this.queryName, + viewName: viewName, + query: queryDetails, + userFilter: userFilter, + userSort: userSort, + userColumns: userColumns, + userContainerFilter: this.getUserContainerFilter(), + allowableContainerFilters: this.allowableContainerFilters + }); + + this.customizeView.on('viewsave', function(designer, savedViewsInfo, urlParameters) { + _onViewSave.apply(this, [this, designer, savedViewsInfo, urlParameters]); + }, this); + + this.customizeView.on({ + beforedeleteview: function(cv, revert) { + _beforeViewDelete(region, revert); + }, + deleteview: function(cv, success, json) { + _onViewDelete(region, success, json); + } + }); + + var first = true; + + // Called when customize view needs to be shown + var showFn = function(id, panel, element, callback, scope) { + if (first) { + panel.hide(); + panel.getEl().appendTo(Ext4.get(element[0])); + first = false; + } + panel.doLayout(); + $(panel.getEl().dom).slideDown(undefined, function() { + panel.show(); + callback.call(scope); + }); + }; + + // Called when customize view needs to be hidden + var hideFn = function(id, panel, element, callback, scope) { + $(panel.getEl().dom).slideUp(undefined, function() { + panel.hide(); + callback.call(scope); + }); + }; + + this.publishPanel(CUSTOM_VIEW_PANELID, this.customizeView, showFn, hideFn, this); + this.showPanel(CUSTOM_VIEW_PANELID); + }; + var failure = function() { + timerId > 0 ? clearTimeout(timerId) : this.hideLoadingMessage(); + }; + + this.getQueryDetails(success, failure, this); + }, region); + } + else { + if (activeTab) { + panelConfig.panel.setActiveDesignerTab(activeTab); + } + this.showPanel(CUSTOM_VIEW_PANELID); + } + }; + + /** + * @ignore + * @private + * Shows/Hides customize view depending on if it is currently shown + */ + LABKEY.DataRegion.prototype.toggleShowCustomizeView = function() { + if (this.activePanelId === CUSTOM_VIEW_PANELID) { + this.hideCustomizeView(); + } + else { + this.showCustomizeView(undefined); + } + }; + + var _defaultShow = function(panelId, panel, ribbon, cb, cbScope) { + $('#' + panelId).slideDown(undefined, function() { + cb.call(cbScope); + }); + }; + + var _defaultHide = function(panelId, panel, ribbon, cb, cbScope) { + $('#' + panelId).slideUp(undefined, function() { + cb.call(cbScope); + }); + }; + + // TODO this is a pretty bad prototype, consider using config parameter with backward compat option + LABKEY.DataRegion.prototype.publishPanel = function(panelId, panel, showFn, hideFn, scope, friendlyName) { + this.panelConfigurations[panelId] = { + panelId: panelId, + panel: panel, + show: $.isFunction(showFn) ? showFn : _defaultShow, + hide: $.isFunction(hideFn) ? hideFn : _defaultHide, + scope: scope + }; + if (friendlyName && friendlyName !== panelId) + this.panelConfigurations[friendlyName] = this.panelConfigurations[panelId]; + return this; + }; + + LABKEY.DataRegion.prototype.getPanelConfiguration = function(panelId) { + return this.panelConfigurations[panelId]; + }; + + /** + * @ignore + * Hides any panel that is currently visible. Returns a callback once the panel is hidden. + */ + LABKEY.DataRegion.prototype.hidePanel = function(callback, scope) { + if (this.activePanelId) { + var config = this.getPanelConfiguration(this.activePanelId); + if (config) { + + // find the ribbon container + var ribbon = _getDrawerSelector(this); + + config.hide.call(config.scope || this, this.activePanelId, config.panel, ribbon, function() { + this.activePanelId = undefined; + ribbon.hide(); + if ($.isFunction(callback)) { + callback.call(scope || this); + } + LABKEY.Utils.signalWebDriverTest("dataRegionPanelHide"); + $(this).trigger($.Event('afterpanelhide'), [this]); + }, this); + } + } + else { + if ($.isFunction(callback)) { + callback.call(scope || this); + } + } + }; + + LABKEY.DataRegion.prototype.showPanel = function(panelId, callback, scope) { + + var config = this.getPanelConfiguration(panelId); + + if (!config) { + console.error('Unable to find panel for id (' + panelId + '). Use publishPanel() to register a panel to be shown.'); + return; + } + + this.hideContext(); + this.hideMessage(true); + + this.hidePanel(function() { + this.activePanelId = config.panelId; + + // ensure the ribbon is visible + var ribbon = _getDrawerSelector(this); + ribbon.show(); + + config.show.call(config.scope || this, this.activePanelId, config.panel, ribbon, function() { + if ($.isFunction(callback)) { + callback.call(scope || this); + } + LABKEY.Utils.signalWebDriverTest("dataRegionPanelShow"); + $(this).trigger($.Event('afterpanelshow'), [this]); + }, this); + }, this); + }; + + function _hasPanelOpen(dr) { + return dr.activePanelId !== undefined; + } + + function _hasButtonBarMenuOpen(dr) { + return _getBarSelector(dr).find(".lk-menu-drop.open").length > 0; + } + + /** + * Returns true if the user has interacted with the DataRegion by changing + * the selection, opening a button menu, or opening a panel. + * @return {boolean} + * @private + */ + LABKEY.DataRegion.prototype.isUserInteracting = function () { + return this.selectionModified || _hasPanelOpen(this) || _hasButtonBarMenuOpen(this); + }; + + // + // Misc + // + + /** + * @private + */ + var _initHeaderLocking = function() { + if (this._allowHeaderLock === true) { + this.hLock = new HeaderLock(this); + } + }; + + /** + * @private + */ + var _initPanes = function() { + var callbacks = _paneCache[this.name]; + if (callbacks) { + var me = this; + callbacks.forEach(function(config) { + config.cb.call(config.scope || me, me); + }); + delete _paneCache[this.name]; + } + }; + + /** + * @private + */ + var _initReport = function() { + if (LABKEY.Utils.isObject(this.report)) { + this.addMessage({ + html: [ + 'Name:', + LABKEY.Utils.encodeHtml(this.report.name), + 'Source:', + LABKEY.Utils.encodeHtml(this.report.source) + ].join(' '), + part: 'report', + }); + } + }; + + // These study specific functions/constants should be moved out of Data Region + // and into their own dependency. + + var COHORT_LABEL = '/Cohort/Label'; + var ADV_COHORT_LABEL = '/InitialCohort/Label'; + var COHORT_ENROLLED = '/Cohort/Enrolled'; + var ADV_COHORT_ENROLLED = '/InitialCohort/Enrolled'; + + /** + * DO NOT CALL DIRECTLY. This method is private and only available for removing cohort/group filters + * for this Data Region. + * @param subjectColumn + * @param groupNames + * @private + */ + LABKEY.DataRegion.prototype._removeCohortGroupFilters = function(subjectColumn, groupNames) { + this.clearSelected({quiet: true}); + var params = _getParameters(this); + var skips = [], i, p, k; + + var keys = [ + subjectColumn + COHORT_LABEL, + subjectColumn + ADV_COHORT_LABEL, + subjectColumn + COHORT_ENROLLED, + subjectColumn + ADV_COHORT_ENROLLED + ]; + + if (LABKEY.Utils.isArray(groupNames)) { + for (k=0; k < groupNames.length; k++) { + keys.push(subjectColumn + '/' + groupNames[k]); + } + } + + for (i = 0; i < params.length; i++) { + p = params[i][0]; + if (p.indexOf(this.name + '.') === 0) { + for (k=0; k < keys.length; k++) { + if (p.indexOf(keys[k] + '~') > -1) { + skips.push(p); + k = keys.length; // break loop + } + } + } + } + + _updateFilter(this, undefined, skips); + }; + + /** + * DO NOT CALL DIRECTLY. This method is private and only available for replacing advanced cohort filters + * for this Data Region. Remove if advanced cohorts are removed. + * @param filter + * @private + */ + LABKEY.DataRegion.prototype._replaceAdvCohortFilter = function(filter) { + this.clearSelected({quiet: true}); + var params = _getParameters(this); + var skips = [], i, p; + + for (i = 0; i < params.length; i++) { + p = params[i][0]; + if (p.indexOf(this.name + '.') === 0) { + if (p.indexOf(COHORT_LABEL) > -1 || p.indexOf(ADV_COHORT_LABEL) > -1 || p.indexOf(COHORT_ENROLLED) > -1 || p.indexOf(ADV_COHORT_ENROLLED)) { + skips.push(p); + } + } + } + + _updateFilter(this, filter, skips); + }; + + /** + * Looks for a column based on fieldKey, name, displayField, or caption (in that order) + * @param columnIdentifier + * @returns {*} + */ + LABKEY.DataRegion.prototype.getColumn = function(columnIdentifier) { + + var column = null, // backwards compat + isString = LABKEY.Utils.isString, + cols = this.columns; + + if (isString(columnIdentifier) && LABKEY.Utils.isArray(cols)) { + $.each(['fieldKey', 'name', 'displayField', 'caption'], function(i, key) { + $.each(cols, function(c, col) { + if (isString(col[key]) && col[key] == columnIdentifier) { + column = col; + return false; + } + }); + if (column) { + return false; + } + }); + } + + return column; + }; + + /** + * Returns a query config object suitable for passing into LABKEY.Query.selectRows() or other LABKEY.Query APIs. + * @returns {Object} Object representing the query configuration that generated this grid. + */ + LABKEY.DataRegion.prototype.getQueryConfig = function() { + var config = { + dataRegionName: this.name, + dataRegionSelectionKey: this.selectionKey, + schemaName: this.schemaName, + viewName: this.viewName, + sort: this.getParameter(this.name + SORT_PREFIX), + // NOTE: The parameterized query values from QWP are included + parameters: this.getParameters(false), + containerFilter: this.containerFilter + }; + + if (this.queryName) { + config.queryName = this.queryName; + } + else if (this.sql) { + config.sql = this.sql; + } + + var filters = this.getUserFilterArray(); + if (filters.length > 0) { + config.filters = filters; + } + + return config; + }; + + /** + * Hide the ribbon panel. If visible the ribbon panel will be hidden. + */ + LABKEY.DataRegion.prototype.hideButtonPanel = function() { + this.hidePanel(); + this.showContext(); + this.showMessageArea(); + }; + + /** + * Allows for asynchronous rendering of the Data Region. This region must be in "async" mode for + * this to do anything. + * @function + * @param {String} [renderTo] - The element ID where to render the data region. If not given it will default to + * the current renderTo target is. + */ + LABKEY.DataRegion.prototype.render = function(renderTo) { + if (!this.RENDER_LOCK && this.async) { + _convertRenderTo(this, renderTo); + this.refresh(); + } + }; + + /** + * Show a ribbon panel. + * + * first arg can be button on the button bar or target panel id/configuration + */ + + LABKEY.DataRegion.prototype.toggleButtonPanelHandler = function(panelButton) { + _toggleButtonPanel( this, $(panelButton).attr('data-labkey-panel-toggle'), null, true); + }; + + LABKEY.DataRegion.prototype.showButtonPanel = function(panel, optionalTab) { + _toggleButtonPanel(this, panel, optionalTab, false); + }; + + LABKEY.DataRegion.prototype.toggleButtonPanel = function(panel, optionalTab) { + _toggleButtonPanel(this, panel, optionalTab, true); + }; + + var _toggleButtonPanel = function(dr, panel, optionalTab, toggle) { + var ribbon = _getDrawerSelector(dr); + // first check if this is a named panel instead of a button element + var panelId, panelSel; + if (typeof panel === 'string' && dr.getPanelConfiguration(panel)) + panelId = dr.getPanelConfiguration(panel).panelId; + else + panelId = panel; + + if (panelId) { + + panelSel = $('#' + panelId); + + // allow for toggling the state + if (panelId === dr.activePanelId) { + if (toggle) { + dr.hideButtonPanel(); + return; + } + } + else { + // determine if the content needs to be moved to the ribbon + if (ribbon.has(panelSel).length === 0) { + panelSel.detach().appendTo(ribbon); + } + + // determine if this panel has been registered + if (!dr.getPanelConfiguration(panelId) && panelSel.length > 0) { + dr.publishPanel(panelId, panelId); + } + + dr.showPanel(panelId); + } + if (optionalTab) + { + var t = panelSel.find('a[data-toggle="tab"][href="#' + optionalTab + '"]'); + if (!t.length) + t = panelSel.find('a[data-toggle="tab"][data-tabName="' + optionalTab + '"]'); + t.tab('show'); + } + } + }; + + LABKEY.DataRegion.prototype.loadFaceting = function(cb, scope) { + + var region = this; + + var onLoad = function() { + region.facetLoaded = true; + if ($.isFunction(cb)) { + cb.call(scope || this); + } + }; + + LABKEY.requiresExt4ClientAPI(function() { + if (LABKEY.devMode) { + // should match study/ParticipantFilter.lib.xml + LABKEY.requiresScript([ + '/study/ReportFilterPanel.js', + '/study/ParticipantFilterPanel.js' + ], function() { + LABKEY.requiresScript('/dataregion/panel/Facet.js', onLoad); + }); + } + else { + LABKEY.requiresScript('/study/ParticipantFilter.min.js', function() { + LABKEY.requiresScript('/dataregion/panel/Facet.js', onLoad); + }); + } + }, this); + }; + + LABKEY.DataRegion.prototype.showFaceting = function() { + if (this.facetLoaded) { + if (!this.facet) { + this.facet = LABKEY.dataregion.panel.Facet.display(this); + } + this.facet.toggleCollapse(); + } + else { + this.loadFaceting(this.showFaceting, this); + } + }; + + LABKEY.DataRegion.prototype.on = function(evt, callback, scope) { + // Prevent from handing back the jQuery event itself. + $(this).bind(evt, function() { callback.apply(scope || this, $(arguments).slice(1)); }); + }; + + LABKEY.DataRegion.prototype.one = function(evt, callback, scope) { + $(this).one(evt, function() { callback.apply(scope || this, $(arguments).slice(1)); }); + }; + + LABKEY.DataRegion.prototype._onButtonClick = function(buttonId) { + var item = this.findButtonById(this.buttonBar.items, buttonId); + if (item && $.isFunction(item.handler)) { + try { + return item.handler.call(item.scope || this, this); + } + catch(ignore) {} + } + return false; + }; + + LABKEY.DataRegion.prototype.findButtonById = function(items, id) { + if (!items || !items.length || items.length <= 0) { + return null; + } + + var ret; + for (var i = 0; i < items.length; i++) { + if (items[i].id == id) { + return items[i]; + } + ret = this.findButtonById(items[i].items, id); + if (null != ret) { + return ret; + } + } + + return null; + }; + + LABKEY.DataRegion.prototype.headerLock = function() { return this._allowHeaderLock === true; }; + + LABKEY.DataRegion.prototype.disableHeaderLock = function() { + if (this.headerLock() && this.hLock) { + this.hLock.disable(); + this.hLock = undefined; + } + }; + + /** + * Add or remove a summary statistic for a given column in the DataRegion query view. + * @param viewName + * @param colFieldKey + * @param summaryStatName + */ + LABKEY.DataRegion.prototype.toggleSummaryStatForCustomView = function(viewName, colFieldKey, summaryStatName) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + var colProviderNames = []; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey === colFieldKey) + colProviderNames.push(existingProvider.name); + }); + + if (colProviderNames.indexOf(summaryStatName) === -1) { + _addAnalyticsProviderToView.call(this, view, colFieldKey, summaryStatName, true); + } + else { + _removeAnalyticsProviderFromView.call(this, view, colFieldKey, summaryStatName, true); + } + } + }, null, this); + }; + + /** + * Get the array of selected ColumnAnalyticsProviders for the given column FieldKey in a view. + * @param viewName + * @param colFieldKey + * @param callback + * @param callbackScope + */ + LABKEY.DataRegion.prototype.getColumnAnalyticsProviders = function(viewName, colFieldKey, callback, callbackScope) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + var colProviderNames = []; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey === colFieldKey) { + colProviderNames.push(existingProvider.name); + } + }); + + if ($.isFunction(callback)) { + callback.call(callbackScope, colProviderNames); + } + } + }, null, this); + }; + + /** + * Set the summary statistic ColumnAnalyticsProviders for the given column FieldKey in the view. + * @param viewName + * @param colFieldKey + * @param summaryStatProviderNames + */ + LABKEY.DataRegion.prototype.setColumnSummaryStatistics = function(viewName, colFieldKey, summaryStatProviderNames) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + var newAnalyticsProviders = []; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey !== colFieldKey || existingProvider.name.indexOf('AGG_') != 0) { + newAnalyticsProviders.push(existingProvider); + } + }); + + $.each(summaryStatProviderNames, function(index, providerName) { + newAnalyticsProviders.push({ + fieldKey: colFieldKey, + name: providerName, + isSummaryStatistic: true + }); + }); + + view.analyticsProviders = newAnalyticsProviders; + _updateSessionCustomView.call(this, view, true); + } + }, null, this); + }; + + /** + * Used via SummaryStatisticsAnalyticsProvider to show a dialog of the applicable summary statistics for a column in the view. + * @param colFieldKey + */ + LABKEY.DataRegion.prototype.showColumnStatisticsDialog = function(colFieldKey) { + LABKEY.requiresScript('query/ColumnSummaryStatistics', function() { + var regionViewName = this.viewName || "", + column = this.getColumn(colFieldKey); + + if (column) { + this.getColumnAnalyticsProviders(regionViewName, colFieldKey, function(colSummaryStats) { + Ext4.create('LABKEY.ext4.ColumnSummaryStatisticsDialog', { + queryConfig: this.getQueryConfig(), + filterArray: LABKEY.Filter.getFiltersFromUrl(this.selectAllURL, 'query'), //Issue 26594 + containerPath: this.containerPath, + column: column, + initSelection: colSummaryStats, + listeners: { + scope: this, + applySelection: function(win, colSummaryStatsNames) { + win.getEl().mask("Applying selection..."); + this.setColumnSummaryStatistics(regionViewName, colFieldKey, colSummaryStatsNames); + win.close(); + } + } + }).show(); + }, this); + } + }, this); + }; + + /** + * Remove a column from the given DataRegion query view. + * @param viewName + * @param colFieldKey + */ + LABKEY.DataRegion.prototype.removeColumn = function(viewName, colFieldKey) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + var colFieldKeys = $.map(view.columns, function (c) { + return c.fieldKey; + }), + fieldKeyIndex = colFieldKeys.indexOf(colFieldKey); + + if (fieldKeyIndex > -1) { + view.columns.splice(fieldKeyIndex, 1); + _updateSessionCustomView.call(this, view, true); + } + } + }, null, this); + }; + + /** + * Add the enabled analytics provider to the custom view definition based on the column fieldKey and provider name. + * In addition, disable the column menu item if the column is visible in the grid. + * @param viewName + * @param colFieldKey + * @param providerName + */ + LABKEY.DataRegion.prototype.addAnalyticsProviderForCustomView = function(viewName, colFieldKey, providerName) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + _addAnalyticsProviderToView.call(this, view, colFieldKey, providerName, false); + _updateAnalyticsProviderMenuItem(this.name + ':' + colFieldKey, providerName, true); + } + }, null, this); + }; + + /** + * Remove an enabled analytics provider from the custom view definition based on the column fieldKey and provider name. + * In addition, enable the column menu item if the column is visible in the grid. + * @param viewName + * @param colFieldKey + * @param providerName + */ + LABKEY.DataRegion.prototype.removeAnalyticsProviderForCustomView = function(viewName, colFieldKey, providerName) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + _removeAnalyticsProviderFromView.call(this, view, colFieldKey, providerName, false); + _updateAnalyticsProviderMenuItem(this.name + ':' + colFieldKey, providerName, false); + } + }, null, this); + }; + + /** + * @private + */ + LABKEY.DataRegion.prototype._openFilter = function(columnName, evt) { + if (evt && $(evt.target).hasClass('fa-close')) { + return; + } + + var column = this.getColumn(columnName); + + if (column) { + var show = function() { + this._dialogLoaded = true; + new LABKEY.FilterDialog({ + dataRegionName: this.name, + column: this.getColumn(columnName), + cacheFacetResults: false // could have changed on Ajax + }).show(); + }.bind(this); + + this._dialogLoaded ? show() : LABKEY.requiresExt3ClientAPI(show); + } + else { + LABKEY.Utils.alert('Column not available', 'Unable to find column "' + columnName + '" in this view.'); + } + }; + + var _updateSessionCustomView = function(customView, requiresRefresh) { + var viewConfig = $.extend({}, customView, { + shared: false, + inherit: false, + hidden: false, + session: true + }); + + LABKEY.Query.saveQueryViews({ + containerPath: this.containerPath, + schemaName: this.schemaName, + queryName: this.queryName, + views: [viewConfig], + scope: this, + success: function(info) { + if (requiresRefresh) { + this.refresh(); + } + else if (info.views.length === 1) { + this.view = info.views[0]; + _initCustomViews.call(this); + this.showContext(); + } + } + }); + }; + + var _addAnalyticsProviderToView = function(view, colFieldKey, providerName, isSummaryStatistic) { + var colProviderNames = []; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey === colFieldKey) + colProviderNames.push(existingProvider.name); + }); + + if (colProviderNames.indexOf(providerName) === -1) { + view.analyticsProviders.push({ + fieldKey: colFieldKey, + name: providerName, + isSummaryStatistic: isSummaryStatistic + }); + + _updateSessionCustomView.call(this, view, isSummaryStatistic); + } + }; + + var _removeAnalyticsProviderFromView = function(view, colFieldKey, providerName, isSummaryStatistic) { + var indexToRemove = null; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey === colFieldKey && existingProvider.name === providerName) { + indexToRemove = index; + return false; + } + }); + + if (indexToRemove != null) { + view.analyticsProviders.splice(indexToRemove, 1); + _updateSessionCustomView.call(this, view, isSummaryStatistic); + } + }; + + /** + * Attempt to find a DataRegion analytics provider column menu item so that it can be either enabled to allow + * it to once again be selected after removal or disabled so that it can't be selected a second time. + * @param columnName the DataRegion column th element column-name attribute + * @param providerName the analytics provider name + * @param disable + * @private + */ + var _updateAnalyticsProviderMenuItem = function(columnName, providerName, disable) { + var menuItemEl = $("th[column-name|='" + columnName + "']").find("a[onclick*='" + providerName + "']").parent(); + if (menuItemEl) { + if (disable) { + menuItemEl.addClass('disabled'); + } + else { + menuItemEl.removeClass('disabled'); + } + } + }; + + // + // PRIVATE FUNCTIONS + // + var _applyOptionalParameters = function(region, params, optionalParams) { + optionalParams.forEach(function(p) { + if (LABKEY.Utils.isObject(p)) { + if (region[p.name] !== undefined) { + if (p.check && !p.check.call(region, region[p.name])) { + return; + } + if (p.prefix) { + params[region.name + '.' + p.name] = region[p.name]; + } + else { + params[p.name] = region[p.name]; + } + } + } + else if (p && region[p] !== undefined) { + params[p] = region[p]; + } + }); + }; + + var _alterSortString = function(region, current, fieldKey, direction /* optional */) { + fieldKey = _resolveFieldKey(region, fieldKey); + + var columnName = fieldKey.toString(), + newSorts = []; + + if (current != null) { + current.split(',').forEach(function(sort) { + if (sort.length > 0 && (sort != columnName) && (sort != SORT_ASC + columnName) && (sort != SORT_DESC + columnName)) { + newSorts.push(sort); + } + }); + } + + if (direction === SORT_ASC) { // Easier to read without the encoded + on the URL... + direction = ''; + } + + if (LABKEY.Utils.isString(direction)) { + newSorts = [direction + columnName].concat(newSorts); + } + + return newSorts.join(','); + }; + + var _ensureFilterDateFormat = function(value) { + if (LABKEY.Utils.isDate(value)) { + value = $.format.date(value, 'yyyy-MM-dd'); + if (LABKEY.Utils.endsWith(value, 'Z')) { + value = value.substring(0, value.length - 1); + } + } + + return value; + } + + var _buildQueryString = function(region, pairs) { + if (!LABKEY.Utils.isArray(pairs)) { + return ''; + } + + var queryParts = [], key, value; + + pairs.forEach(function(pair) { + key = pair[0]; + value = pair.length > 1 ? pair[1] : undefined; + + queryParts.push(encodeURIComponent(key)); + if (LABKEY.Utils.isDefined(value)) { + + value = _ensureFilterDateFormat(value); + queryParts.push('='); + queryParts.push(encodeURIComponent(value)); + } + queryParts.push('&'); + }); + + if (queryParts.length > 0) { + queryParts.pop(); + } + + return queryParts.join(""); + }; + + var _chainSelectionCountCallback = function(region, config) { + + var success = LABKEY.Utils.getOnSuccess(config); + + // On success, update the current selectedCount on this DataRegion and fire the 'selectchange' event + config.success = function(data) { + region.removeMessage('selection'); + region.selectionModified = true; + region.selectedCount = data.count; + _onSelectionChange(region); + + // Chain updateSelected with the user-provided success callback + if ($.isFunction(success)) { + success.call(config.scope, data); + } + }; + + return config; + }; + + var _convertRenderTo = function(region, renderTo) { + if (renderTo) { + if (LABKEY.Utils.isString(renderTo)) { + region.renderTo = renderTo; + } + else if (LABKEY.Utils.isString(renderTo.id)) { + region.renderTo = renderTo.id; // support 'Ext' elements + } + else { + throw 'Unsupported "renderTo"'; + } + } + + return region; + }; + + var _deleteTimer; + + var _beforeViewDelete = function(region, revert) { + _deleteTimer = setTimeout(function() { + _deleteTimer = 0; + region.showLoadingMessage(revert ? 'Reverting view...' : 'Deleting view...'); + }, 500); + }; + + var _onViewDelete = function(region, success, json) { + if (_deleteTimer) { + clearTimeout(_deleteTimer); + } + + if (success) { + region.removeMessage.call(region, 'customizeview'); + region.showSuccessMessage.call(region); + + // change view to either a shadowed view or the default view + var config = { type: 'view' }; + if (json.viewName) { + config.viewName = json.viewName; + } + region.changeView.call(region, config); + } + else { + region.removeMessage.call(region, 'customizeview'); + region.showErrorMessage.call(region, json.exception); + } + }; + + // The view can be reverted without ViewDesigner present + var _revertCustomView = function(region) { + _beforeViewDelete(region, true); + + var config = { + schemaName: region.schemaName, + queryName: region.queryName, + containerPath: region.containerPath, + revert: true, + success: function(json) { + _onViewDelete(region, true /* success */, json); + }, + failure: function(json) { + _onViewDelete(region, false /* success */, json); + } + }; + + if (region.viewName) { + config.viewName = region.viewName; + } + + LABKEY.Query.deleteQueryView(config); + }; + + var _getViewFromQueryDetails = function(queryDetails, viewName) { + var matchingView; + + $.each(queryDetails.views, function(index, view) { + if (view.name === viewName) { + matchingView = view; + return false; + } + }); + + return matchingView; + }; + + var _viewContainsColumn = function(view, colFieldKey) { + var keys = $.map(view.columns, function(c) { + return c.fieldKey.toLowerCase(); + }); + var exists = colFieldKey && keys.indexOf(colFieldKey.toLowerCase()) > -1; + + if (!exists) { + console.warn('Unable to find column in view: ' + colFieldKey); + } + + return exists; + }; + + var _getAllRowSelectors = function(region) { + return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".toggle"]'); + }; + + var _getBarSelector = function(region) { + return $('#' + region.domId + '-headerbar'); + }; + + var _getContextBarSelector = function(region) { + return $('#' + region.domId + '-ctxbar'); + }; + + var _getDrawerSelector = function(region) { + return $('#' + region.domId + '-drawer'); + }; + + var _getFormSelector = function(region) { + var form = $('form#' + region.domId + '-form'); + + // derived DataRegion's may not include the form id + if (form.length === 0) { + form = $('#' + region.domId).closest('form'); + } + + return form; + }; + + var _getHeaderSelector = function(region) { + return $('#' + region.domId + '-header'); + }; + + var _getRowSelectors = function(region) { + return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".select"]'); + }; + + var _getSectionSelector = function(region, dir) { + return $('#' + region.domId + '-section-' + dir); + }; + + var _getShowFirstSelector = function(region) { + return $('#' + region.showFirstID); + }; + + var _getShowLastSelector = function(region) { + return $('#' + region.showLastID); + }; + + var _getShowAllSelector = function(region) { + return $('#' + region.showAllID); + }; + + // Formerly, LABKEY.DataRegion.getParamValPairsFromString / LABKEY.DataRegion.getParamValPairs + var _getParameters = function(region, skipPrefixSet /* optional */) { + + var params = []; + var qString = region.requestURL; + + if (LABKEY.Utils.isString(qString) && qString.length > 0) { + + var qmIdx = qString.indexOf('?'); + if (qmIdx > -1) { + qString = qString.substring(qmIdx + 1); + + var poundIdx = qString.indexOf('#'); + if (poundIdx > -1) + qString = qString.substr(0, poundIdx); + + if (qString.length > 1) { + var pairs = qString.split('&'), p, key, + LAST = '.lastFilter', lastIdx, skip = LABKEY.Utils.isArray(skipPrefixSet); + + var exactMatches = EXACT_MATCH_PREFIXES.map(function (prefix) { + return region.name + prefix; + }); + + $.each(pairs, function (i, pair) { + p = pair.split('=', 2); + key = p[0] = decodeURIComponent(p[0]); + lastIdx = key.indexOf(LAST); + + if (lastIdx > -1 && lastIdx === (key.length - LAST.length)) { + return; + } + else if (REQUIRE_NAME_PREFIX.hasOwnProperty(key)) { + // Issue 26686: Block known parameters, should be prefixed by region name + return; + } + + var stop = false; + if (skip) { + $.each(skipPrefixSet, function (j, skipPrefix) { + if (LABKEY.Utils.isString(skipPrefix)) { + + // Special prefix that should remove all filters, but no other parameters for the current grid + if (skipPrefix.indexOf(ALL_FILTERS_SKIP_PREFIX) === (skipPrefix.length - 2)) { + if (key.indexOf(region.name + '.') === 0 && key.indexOf('~') > 0) { + + stop = true; + return false; + } + } + else { + if (exactMatches.indexOf(skipPrefix) > -1) { + if (key === skipPrefix) { + stop = true; + return false; + } + } + else if (key.toLowerCase().indexOf(skipPrefix.toLowerCase()) === 0) { + // only skip filters, parameters, and sorts for the current grid + if (key.indexOf(region.name + '.') === 0 && + + (key === skipPrefix || + key.indexOf('~') > 0 || + key.indexOf(PARAM_PREFIX) > 0 || + key === (skipPrefix + 'sort'))) { + stop = true; + return false; + } + } + } + } + }); + } + + if (!stop) { + if (p.length > 1) { + p[1] = decodeURIComponent(p[1]); + } + params.push(p); + } + }); + } + } + } + + return params; + }; + + /** + * + * @param region + * @param {boolean} [asString=false] + * @private + */ + var _getUserSort = function(region, asString) { + var userSort = [], + sortParam = region.getParameter(region.name + SORT_PREFIX); + + if (asString) { + userSort = sortParam || ''; + } + else { + if (sortParam) { + var fieldKey, dir; + sortParam.split(',').forEach(function(sort) { + fieldKey = sort; + dir = SORT_ASC; + if (sort.charAt(0) === SORT_DESC) { + fieldKey = fieldKey.substring(1); + dir = SORT_DESC; + } + else if (sort.charAt(0) === SORT_ASC) { + fieldKey = fieldKey.substring(1); + } + userSort.push({fieldKey: fieldKey, dir: dir}); + }); + } + } + + return userSort; + }; + + var _getViewBarSelector = function(region) { + return $('#' + region.domId + '-viewbar'); + }; + + var _buttonSelectionBind = function(region, cls, fn) { + var partEl = region.msgbox.getParent().find('div[data-msgpart="selection"]'); + partEl.find('.labkey-button' + cls).off('click').on('click', $.proxy(function() { + fn.call(this); + }, region)); + }; + + var _onRenderMessageArea = function(region, parts) { + var msgArea = region.msgbox; + if (msgArea) { + if (region.showRecordSelectors && parts['selection']) { + _buttonSelectionBind(region, '.select-all', region.selectAll); + _buttonSelectionBind(region, '.select-none', region.clearSelected); + _buttonSelectionBind(region, '.show-all', region.showAll); + _buttonSelectionBind(region, '.show-selected', region.showSelectedRows); + _buttonSelectionBind(region, '.show-unselected', region.showUnselectedRows); + } + else if (parts['customizeview']) { + _buttonSelectionBind(region, '.unsavedview-revert', function() { _revertCustomView(this); }); + _buttonSelectionBind(region, '.unsavedview-edit', function() { this.showCustomizeView(undefined); }); + _buttonSelectionBind(region, '.unsavedview-save', function() { _saveSessionCustomView(this); }); + } + } + }; + + var _onSelectionChange = function(region) { + $(region).trigger('selectchange', [region, region.selectedCount]); + _updateRequiresSelectionButtons(region, region.selectedCount); + LABKEY.Utils.signalWebDriverTest('dataRegionUpdate', region.selectedCount); + LABKEY.Utils.signalWebDriverTest('dataRegionUpdate-' + region.name, region.selectedCount); + }; + + var _onViewSave = function(region, designer, savedViewsInfo, urlParameters) { + if (savedViewsInfo && savedViewsInfo.views.length > 0) { + region.hideCustomizeView.call(region); + region.changeView.call(region, { + type: 'view', + viewName: savedViewsInfo.views[0].name + }, urlParameters); + } + }; + + var _removeParameters = function(region, skipPrefixes /* optional */) { + return _setParameters(region, null, skipPrefixes); + }; + + var _resolveFieldKey = function(region, fieldKey) { + var fk = fieldKey; + if (!(fk instanceof LABKEY.FieldKey)) { + fk = LABKEY.FieldKey.fromString('' + fk); + } + return fk; + }; + + var _saveSessionCustomView = function(region) { + // Note: currently only will save session views. Future version could create a new view using url sort/filters. + if (!(region.view && region.view.session)) { + return; + } + + // Get the canEditSharedViews permission and candidate targetContainers. + var viewName = (region.view && region.view.name) || region.viewName || ''; + + LABKEY.Query.getQueryDetails({ + schemaName: region.schemaName, + queryName: region.queryName, + viewName: viewName, + initializeMissingView: false, + containerPath: region.containerPath, + success: function (json) { + // Display an error if there was an issue error getting the query details + if (json.exception) { + var viewSourceUrl = LABKEY.ActionURL.buildURL('query', 'viewQuerySource.view', null, {schemaName: this.schemaName, "query.queryName": this.queryName}); + var msg = LABKEY.Utils.encodeHtml(json.exception) + "  View Source"; + + this.showErrorMessage.call(this, msg); + return; + } + + _saveSessionShowPrompt(this, json); + }, + scope: region + }); + }; + + var _saveSessionView = function(o, region, win) { + var timerId = setTimeout(function() { + timerId = 0; + Ext4.Msg.progress("Saving...", "Saving custom view..."); + }, 500); + + var jsonData = { + schemaName: region.schemaName, + "query.queryName": region.queryName, + "query.viewName": region.viewName, + newName: o.name, + inherit: o.inherit, + shared: o.shared, + hidden: o.hidden, + replace: o.replace, + }; + + if (o.inherit) { + jsonData.containerPath = o.containerPath; + } + + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'saveSessionView', region.containerPath), + method: 'POST', + jsonData: jsonData, + callback: function() { + if (timerId > 0) + clearTimeout(timerId); + win.close(); + }, + success: function() { + region.showSuccessMessage.call(region); + region.changeView.call(region, {type: 'view', viewName: o.name}); + }, + failure: function(resp) { + var json = resp.responseText ? Ext4.decode(resp.responseText) : resp; + if (json.exception && json.exception.indexOf('A saved view by the name') === 0) { + + Ext4.Msg.show({ + title : "Duplicate View Name", + msg : json.exception + " Would you like to replace it?", + cls : 'data-window', + icon : Ext4.Msg.QUESTION, + buttons : Ext4.Msg.YESNO, + fn : function(btn) { + if (btn === 'yes') { + o.replace = true; + _saveSessionView(o, region, win); + } + }, + scope : this + }); + } + else + Ext4.Msg.alert('Error saving view', json.exception || json.statusText || Ext4.decode(json.responseText).exception); + }, + scope: region + }); + }; + + var _saveSessionShowPrompt = function(region, queryDetails) { + LABKEY.DataRegion.loadViewDesigner(function() { + var config = Ext4.applyIf({ + allowableContainerFilters: region.allowableContainerFilters, + targetContainers: queryDetails.targetContainers, + canEditSharedViews: queryDetails.canEditSharedViews, + canEdit: LABKEY.DataRegion.getCustomViewEditableErrors(config).length == 0, + success: function (win, o) { + _saveSessionView(o, region, win); + }, + scope: region + }, region.view); + + LABKEY.internal.ViewDesigner.Designer.saveCustomizeViewPrompt(config); + }); + }; + + var _setParameter = function(region, param, value, skipPrefixes /* optional */) { + _setParameters(region, [[param, value]], skipPrefixes); + }; + + var _setParameters = function(region, newParamValPairs, skipPrefixes /* optional */) { + // prepend region name + // e.g. ['.hello', '.goodbye'] becomes ['aqwp19.hello', 'aqwp19.goodbye'] + if (LABKEY.Utils.isArray(skipPrefixes)) { + skipPrefixes.forEach(function(skip, i) { + if (skip && skip.indexOf(region.name + '.') !== 0) { + skipPrefixes[i] = region.name + skip; + } + }); + } + + var param, value, + params = _getParameters(region, skipPrefixes); + + if (LABKEY.Utils.isArray(newParamValPairs)) { + newParamValPairs.forEach(function(newPair) { + if (!LABKEY.Utils.isArray(newPair)) { + throw new Error("DataRegion: _setParameters newParamValPairs improperly initialized. It is an array of arrays. You most likely passed in an array of strings."); + } + param = newPair[0]; + value = newPair[1]; + + // Allow value to be null/undefined to support no-value filter types (Is Blank, etc) + if (LABKEY.Utils.isString(param) && param.length > 1) { + if (param.indexOf(region.name) !== 0) { + param = region.name + param; + } + + params.push([param, value]); + } + }); + } + + if (region.async) { + _load(region, params, skipPrefixes); + } + else { + region.setSearchString.call(region, region.name, _buildQueryString(region, params)); + } + }; + + var _showRows = function(region, showRowsEnum) { + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + // clear sibling parameters, could we do this with events? + this.maxRows = undefined; + this.offset = 0; + + _setParameter(region, SHOW_ROWS_PREFIX, showRowsEnum, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); + }; + + var _showSelectMessage = function(region, msg) { + if (region.showRecordSelectors) { + if (region.totalRows && region.totalRows !== region.selectedCount && region.selectedCount < MAX_SELECTION_SIZE) { + let text = 'Select All Rows'; + if (region.totalRows > MAX_SELECTION_SIZE) { + text = `Select First ${MAX_SELECTION_SIZE.toLocaleString()} Rows`; + } + msg += " " + text + ""; + } + + msg += " " + "Select None"; + var showOpts = []; + if (region.showRows !== 'all' && !_isMaxRowsAllRows(region)) + showOpts.push("Show All"); + if (region.showRows !== 'selected') + showOpts.push("Show Selected"); + if (region.showRows !== 'unselected') + showOpts.push("Show Unselected"); + msg += "  " + showOpts.join(" "); + } + + // add the record selector message, the link handlers will get added after render in _onRenderMessageArea + region.addMessage.call(region, msg, 'selection'); + }; + + var _toggleAllRows = function(region, checked) { + var ids = []; + + _getRowSelectors(region).each(function() { + if (!this.disabled) { + this.checked = checked; + ids.push(this.value); + } + }); + + _getAllRowSelectors(region).each(function() { this.checked = checked === true; }); + return ids; + }; + + /** + * Asynchronous loader for a DataRegion + * @param region {DataRegion} + * @param [newParams] {string} + * @param [skipPrefixes] {string[]} + * @param [callback] {Function} + * @param [scope] + * @private + */ + var _load = function(region, newParams, skipPrefixes, callback, scope) { + + var params = _getAsyncParams(region, newParams ? newParams : _getParameters(region), skipPrefixes); + var jsonData = _getAsyncBody(region, params); + + // TODO: This should be done in _getAsyncParams, but is not since _getAsyncBody relies on it. Refactor it. + // ensure SQL is not on the URL -- we allow any property to be pulled through when creating parameters. + if (params.sql) { + delete params.sql; + } + + /** + * The target jQuery element that will be either written to or replaced + */ + var target; + + /** + * Flag used to determine if we should replace target element (default) or write to the target contents + * (used during QWP render for example) + * @type {boolean} + */ + var useReplace = true; + + /** + * The string identifier for where the region will render. Mainly used to display useful messaging upon failure. + * @type {string} + */ + var renderEl; + + if (region.renderTo) { + useReplace = false; + renderEl = region.renderTo; + target = $('#' + region.renderTo); + } + else if (!region.domId) { + throw '"renderTo" must be specified either upon construction or when calling render()'; + } + else { + renderEl = region.domId; + target = $('#' + region.domId); + + // attempt to find the correct node to render to... + var form = _getFormSelector(region); + if (form.length && form.parent('div').length) { + target = form.parent('div'); + } + else { + // next best render target + throw 'unable to find a good target element. Perhaps this region is not using the standard renderer?' + } + } + var timerId = setTimeout(function() { + timerId = 0; + if (target) { + target.html("
" + + "
loading...
" + + "
"); + } + }, 500); + + LABKEY.Ajax.request({ + timeout: region.timeout === undefined ? DEFAULT_TIMEOUT : region.timeout, + url: LABKEY.ActionURL.buildURL('project', 'getWebPart.api', region.containerPath), + method: 'POST', + params: params, + jsonData: jsonData, + success: function(response) { + if (timerId > 0) { + clearTimeout(timerId);//load mask task no longer needed + } + this.hidePanel(function() { + if (target.length) { + + this.destroy(); + + LABKEY.Utils.loadAjaxContent(response, target, function() { + + if ($.isFunction(callback)) { + callback.call(scope); + } + + if ($.isFunction(this._success)) { + this._success.call(this.scope || this, this, response); + } + + $(this).trigger('success', [this, response]); + + this.RENDER_LOCK = true; + $(this).trigger('render', this); + this.RENDER_LOCK = false; + }, this, useReplace); + } + else { + // not finding element considered a failure + if ($.isFunction(this._failure)) { + this._failure.call(this.scope || this, response /* json */, response, undefined /* options */, target); + } + else if (!this.suppressRenderErrors) { + LABKEY.Utils.alert('Rendering Error', 'The element "' + renderEl + '" does not exist in the document. You may need to specify "renderTo".'); + } + } + }, this); + }, + failure: LABKEY.Utils.getCallbackWrapper(function(json, response, options) { + + if (target.length) { + if ($.isFunction(this._failure)) { + this._failure.call(this.scope || this, json, response, options); + } + else if (this.errorType === 'html') { + if (useReplace) { + target.replaceWith('
' + LABKEY.Utils.encodeHtml(json.exception) + '
'); + } + else { + target.html('
' + LABKEY.Utils.encodeHtml(json.exception) + '
'); + } + } + } + else if (!this.suppressRenderErrors) { + LABKEY.Utils.alert('Rendering Error', 'The element "' + renderEl + '" does not exist in the document. You may need to specify "renderTo".'); + } + }, region, true), + scope: region + }); + + if (region.async && !region.complete && region.showPaginationCountAsync && !region.skipTotalRowCount) { + _loadAsyncTotalRowCount(region, params, jsonData); + } + region.skipTotalRowCount = false; + }; + + var totalRowCountRequests = {}; // track the request per region name so that we cancel the correct request when necessary + var _loadAsyncTotalRowCount = function(region, params, jsonData) { + // if there is a previous request pending, abort it before starting a new one + var totalRowCountRequest = totalRowCountRequests[region.name]; + if (totalRowCountRequest !== undefined) { + totalRowCountRequest.abort(); + } + + region.totalRows = undefined; + region.loadingTotalRows = true; + + totalRowCountRequests[region.name] = LABKEY.Query.selectRows({ + ...region.getQueryConfig(), + method: 'POST', + containerPath: region.containerPath, + filterArray: LABKEY.Filter.getFiltersFromParameters({ ...params, ...jsonData.filters }, params.dataRegionName), + sort: undefined, + maxRows: 1, + offset: 0, + includeMetadata: false, + includeDetailsColumn: false, + includeUpdateColumn: false, + includeTotalCount: true, + success: function(json) { + totalRowCountRequests[region.name] = undefined; + region.loadingTotalRows = false; + + if (json !== undefined && json.rowCount !== undefined) { + region.totalRows = json.rowCount; + + // update the pagination button disabled state for 'Show Last' and 'Show All' since they include the totalRows count in their calc + var showLast = _showLastEnabled(region); + if (showLast) { + _getShowLastSelector(region).parent('li').removeClass('disabled'); + _getShowAllSelector(region).parent('li').removeClass('disabled'); + } + } + // note: use _getFormSelector instead of _getBarSelector so that we get the floating header as well + _getFormSelector(region).find('a.labkey-paginationText').html(_getPaginationText(region)); + }, + failure: function(error, request) { + var aborted = request.status === 0; + if (!aborted) { + console.error(error); + totalRowCountRequests[region.name] = undefined; + region.loadingTotalRows = false; + _getFormSelector(region).find('a.labkey-paginationText').html(_getPaginationText(region)); + } + } + }); + }; + + var _getAsyncBody = function(region, params) { + var json = {}; + + if (params.sql) { + json.sql = LABKEY.Utils.wafEncode(params.sql); + } + + _processButtonBar(region, json); + + // Issue 10505: add non-removable sorts and filters to json (not url params). + if (region.sort || region.filters || region.aggregates) { + json.filters = {}; + + if (region.filters) { + LABKEY.Filter.appendFilterParams(json.filters, region.filters, region.name); + } + + if (region.sort) { + json.filters[region.dataRegionName + SORT_PREFIX] = region.sort; + } + + if (region.aggregates) { + LABKEY.Filter.appendAggregateParams(json.filters, region.aggregates, region.name); + } + } + + if (region.metadata) { + json.metadata = { + type: region.metadata.type, + value: LABKEY.Utils.wafEncode(region.metadata.value) + }; + } + + return json; + }; + + var _processButtonBar = function(region, json) { + + var bar = region.buttonBar; + + if (bar && (bar.position || (bar.items && bar.items.length > 0))) { + _processButtonBarItems(region, bar.items); + + // only attach if valid + json.buttonBar = bar; + } + }; + + var _processButtonBarItems = function(region, items) { + if (LABKEY.Utils.isArray(items) && items.length > 0) { + for (var i = 0; i < items.length; i++) { + var item = items[i]; + + if (item && $.isFunction(item.handler)) { + item.id = item.id || LABKEY.Utils.id(); + // TODO: A better way? This exposed _onButtonClick isn't very awesome + item.onClick = "return LABKEY.DataRegions['" + region.name + "']._onButtonClick('" + item.id + "');"; + } + + if (item.items) { + _processButtonBarItems(region, item.items); + } + } + } + }; + + var _isFilter = function(region, parameter) { + return parameter && parameter.indexOf(region.name + '.') === 0 && parameter.indexOf('~') > 0; + }; + + var _getAsyncParams = function(region, newParams, skipPrefixes) { + + var params = {}; + var name = region.name; + + // + // Certain parameters are only included if the region is 'async'. These + // were formerly a part of Query Web Part. + // + if (region.async) { + params[name + '.async'] = true; + + if (LABKEY.Utils.isString(region.frame)) { + params['webpart.frame'] = region.frame; + } + + if (LABKEY.Utils.isString(region.bodyClass)) { + params['webpart.bodyClass'] = region.bodyClass; + } + + if (LABKEY.Utils.isString(region.title)) { + params['webpart.title'] = region.title; + } + + if (LABKEY.Utils.isString(region.titleHref)) { + params['webpart.titleHref'] = region.titleHref; + } + + if (LABKEY.Utils.isString(region.columns)) { + params[region.name + '.columns'] = region.columns; + } + + _applyOptionalParameters(region, params, [ + 'allowChooseQuery', + 'allowChooseView', + 'allowHeaderLock', + 'buttonBarPosition', + 'detailsURL', + 'deleteURL', + 'importURL', + 'insertURL', + 'linkTarget', + 'updateURL', + 'shadeAlternatingRows', + 'showBorders', + 'showDeleteButton', + 'showDetailsColumn', + 'showExportButtons', + 'showRStudioButton', + 'showImportDataButton', + 'showInsertNewButton', + 'showPagination', + 'showPaginationCount', + 'showReports', + 'showSurroundingBorder', + 'showFilterDescription', + 'showUpdateColumn', + 'showViewPanel', + 'timeout', + {name: 'disableAnalytics', prefix: true}, + {name: 'maxRows', prefix: true, check: function(v) { return v > 0; }}, + {name: 'showRows', prefix: true}, + {name: 'offset', prefix: true, check: function(v) { return v !== 0; }}, + {name: 'reportId', prefix: true}, + {name: 'viewName', prefix: true} + ]); + + // Sorts configured by the user when interacting with the grid. We need to pass these as URL parameters. + if (LABKEY.Utils.isString(region._userSort) && region._userSort.length > 0) { + params[name + SORT_PREFIX] = region._userSort; + } + + if (region.userFilters) { + $.each(region.userFilters, function(filterExp, filterValue) { + if (params[filterExp] == undefined) { + params[filterExp] = []; + } + params[filterExp].push(filterValue); + }); + region.userFilters = {}; // they've been applied + } + + // TODO: Get rid of this and incorporate it with the normal containerFilter checks + if (region.userContainerFilter) { + params[name + CONTAINER_FILTER_NAME] = region.userContainerFilter; + } + + if (region.parameters) { + var paramPrefix = name + PARAM_PREFIX; + $.each(region.parameters, function(parameter, value) { + var key = parameter; + if (parameter.indexOf(paramPrefix) !== 0) { + key = paramPrefix + parameter; + } + params[key] = value; + }); + } + } + + // + // apply all parameters + // + + var newParamPrefixes = {}; + + if (newParams) { + newParams.forEach(function(pair) { + // Issue 25337: Filters may repeat themselves + if (_isFilter(region, pair[0])) { + if (params[pair[0]] == undefined) { + params[pair[0]] = []; + } + else if (!LABKEY.Utils.isArray(params[pair[0]])) { + params[pair[0]] = [params[pair[0]]]; + } + + var value = pair[1]; + + // Issue 47735: QWP date filter not being formatted + // This needs to be formatted for the response passed back to the grid for the filter display and + // filter dialog to render correctly + value = _ensureFilterDateFormat(value); + + params[pair[0]].push(value); + } + else { + params[pair[0]] = pair[1]; + } + + newParamPrefixes[pair[0]] = true; + }); + } + + // Issue 40226: Don't include parameters that are being logically excluded + if (skipPrefixes) { + skipPrefixes.forEach(function(skipKey) { + if (params.hasOwnProperty(skipKey) && !newParamPrefixes.hasOwnProperty(skipKey)) { + delete params[skipKey]; + } + }); + } + + // + // Properties that cannot be modified + // + + params.dataRegionName = region.name; + params.schemaName = region.schemaName; + params.viewName = region.viewName; + params.reportId = region.reportId; + params.returnUrl = window.location.href; + params['webpart.name'] = 'Query'; + + if (region.queryName) { + params.queryName = region.queryName; + } + else if (region.sql) { + params.sql = region.sql; + } + + var key = region.name + CONTAINER_FILTER_NAME; + var cf = region.getContainerFilter.call(region); + if (cf && !(key in params)) { + params[key] = cf; + } + + return params; + }; + + var _updateFilter = function(region, filter, skipPrefixes) { + var params = []; + if (filter) { + params.push([filter.getURLParameterName(region.name), filter.getURLParameterValue()]); + } + _setParameters(region, params, [OFFSET_PREFIX].concat(skipPrefixes)); + }; + + var _updateRequiresSelectionButtons = function(region, selectedCount) { + + // update the 'select all on page' checkbox state + _getAllRowSelectors(region).each(function() { + if (region.isPageSelected.call(region)) { + this.checked = true; + this.indeterminate = false; + } + else if (region.selectedCount > 0) { + // There are rows selected, but the are not visible on this page. + this.checked = false; + this.indeterminate = true; + } + else { + this.checked = false; + this.indeterminate = false; + } + }); + + // If not all rows are visible and some rows are selected, show selection message + if (region.totalRows && 0 !== region.selectedCount && !region.complete) { + var msg = (region.selectedCount === region.totalRows) ? + 'All ' + region.totalRows.toLocaleString() + ' rows selected.' : + 'Selected ' + region.selectedCount.toLocaleString() + ' of ' + region.totalRows.toLocaleString() + ' rows.'; + _showSelectMessage(region, msg); + } + + // Issue 10566: for javascript perf on IE stash the requires selection buttons + if (!region._requiresSelectionButtons) { + // escape ', ", and \ + var escaped = region.name.replace(/('|"|\\)/g, "\\$1"); + region._requiresSelectionButtons = $("a[data-labkey-requires-selection='" + escaped + "']"); + } + + region._requiresSelectionButtons.each(function() { + var el = $(this); + + var isDropdown = false; + var dropdownBtn = el.parent(); + if (dropdownBtn && dropdownBtn.hasClass('lk-menu-drop') && dropdownBtn.hasClass('dropdown')) + isDropdown = true; + + // handle min-count + var minCount = el.attr('data-labkey-requires-selection-min-count'); + if (minCount) { + minCount = parseInt(minCount); + } + if (minCount === undefined) { + minCount = 1; + } + + // handle max-count + var maxCount = el.attr('data-labkey-requires-selection-max-count'); + if (maxCount) { + maxCount = parseInt(maxCount); + } + + if (minCount <= selectedCount && (!maxCount || maxCount >= selectedCount)) { + el.removeClass('labkey-disabled-button'); + if (isDropdown) + dropdownBtn.removeClass('labkey-disabled-button'); + } + else { + el.addClass('labkey-disabled-button'); + if (isDropdown) + dropdownBtn.addClass('labkey-disabled-button'); + } + }); + }; + + var HeaderLock = function(region) { + + // init + if (!region.headerLock()) { + region._allowHeaderLock = false; + return; + } + + this.region = region; + + var table = $('#' + region.domId); + var firstRow = table.find('tr.labkey-alternate-row').first().children('td'); + + // If no data rows exist just turn off header locking + if (firstRow.length === 0) { + firstRow = table.find('tr.labkey-row').first().children('td'); + if (firstRow.length === 0) { + region._allowHeaderLock = false; + return; + } + } + + var headerRowId = region.domId + '-column-header-row'; + var headerRow = $('#' + headerRowId); + + if (headerRow.length === 0) { + region._allowHeaderLock = false; + return; + } + + var BOTTOM_OFFSET = 100; + + var me = this, + timeout, + locked = false, + lastLeft = 0, + pos = [ 0, 0, 0, 0 ], + domObserver = null; + + // init + var floatRow = headerRow + .clone() + // TODO: Possibly namespace all the ids underneath + .attr('id', headerRowId + '-float') + .css({ + 'box-shadow': '0 4px 4px #DCDCDC', + display: 'none', + position: 'fixed', + top: 0, + 'z-index': 2 + }); + + floatRow.insertAfter(headerRow); + + // respect showPagination but do not use it directly as it may change + var isPagingFloat = region.showPagination; + var floatPaging, floatPagingWidth = 0; + + if (isPagingFloat) { + var pageWidget = _getBarSelector(region).find('.labkey-pagination'); + if (pageWidget.children().length) { + floatPaging = $('
') + .css({ + 'background-color': 'white', + 'box-shadow': '0 4px 4px #DCDCDC', + display: 'none', + 'min-width': pageWidget.width(), + opacity: 0.7, + position: 'fixed', + top: floatRow.height(), + 'z-index': 1 + }) + .on('mouseover', function() { + $(this).css('opacity', '1.0'); + }) + .on('mouseout', function() { + $(this).css('opacity', '0.7') + }); + + var floatingPageWidget = pageWidget.clone(true).css('padding', '4px 8px'); + + // adjust padding when buttons aren't shown + if (!pageWidget.find('.btn-group').length) { + floatingPageWidget.css('padding-bottom', '8px') + } + + floatPaging.append(floatingPageWidget); + table.parent().append(floatPaging); + floatPagingWidth = floatPaging.width(); + } else { + isPagingFloat = false; + } + } + + var disable = function() { + me.region._allowHeaderLock = false; + + if (timeout) { + clearTimeout(timeout); + } + + $(window) + .unbind('load', domTask) + .unbind('resize', resizeTask) + .unbind('scroll', onScroll); + + if (domObserver) { + domObserver.disconnect(); + domObserver = null; + } + }; + + /** + * Configures the 'pos' array containing the following values: + * [0] - X-coordinate of the top of the object relative to the offset parent. + * [1] - Y-coordinate of the top of the object relative to the offset parent. + * [2] - Y-coordinate of the bottom of the object. + * [3] - width of the object + * This method assumes interaction with the Header of the Data Region. + */ + var loadPosition = function() { + var header = headerRow.offset() || {top: 0}; + var table = $('#' + region.domId); + + var bottom = header.top + table.height() - BOTTOM_OFFSET; + var width = headerRow.width(); + pos = [ header.left, header.top, bottom, width ]; + }; + + loadPosition(); + + var onResize = function() { + loadPosition(); + var sub_h = headerRow.find('th'); + + floatRow.width(headerRow.width()).find('th').each(function(i, el) { + $(el).width($(sub_h[i]).width()); + }); + + isPagingFloat && floatPaging.css({ + left: pos[0] - window.pageXOffset + floatRow.width() - floatPaging.width(), + top: floatRow.height() + }); + }; + + /** + * WARNING: This function is called often. Performance implications for each line. + */ + var onScroll = function() { + if (window.pageYOffset >= pos[1] && window.pageYOffset < pos[2]) { + var newLeft = pos[0] - window.pageXOffset; + var newPagingLeft = isPagingFloat ? newLeft + pos[3] - floatPagingWidth : 0; + + var floatRowCSS = { + top: 0 + }; + var pagingCSS = isPagingFloat && { + top: floatRow.height() + }; + + if (!locked) { + locked = true; + floatRowCSS.display = 'table-row'; + floatRowCSS.left = newLeft; + + pagingCSS.display = 'block'; + pagingCSS.left = newPagingLeft; + } + else if (lastLeft !== newLeft) { + floatRowCSS.left = newLeft; + + pagingCSS.left = newPagingLeft; + } + + floatRow.css(floatRowCSS); + isPagingFloat && floatPaging.css(pagingCSS); + + lastLeft = newLeft; + } + else if (locked && window.pageYOffset >= pos[2]) { + var newTop = pos[2] - window.pageYOffset; + + floatRow.css({ + top: newTop + }); + + isPagingFloat && floatPaging.css({ + top: newTop + floatRow.height() + }); + } + else if (locked) { + locked = false; + floatRow.hide(); + isPagingFloat && floatPaging.hide(); + } + }; + + var resizeTask = function(immediate) { + clearTimeout(timeout); + if (immediate) { + onResize(); + } + else { + timeout = setTimeout(onResize, 110); + } + }; + + var isDOMInit = false; + + var domTask = function() { + if (!isDOMInit) { + isDOMInit = true; + // fire immediate to prevent flicker of components when reloading region + resizeTask(true); + } + else { + resizeTask(); + } + onScroll(); + }; + + $(window) + .one('load', domTask) + .on('resize', resizeTask) + .on('scroll', onScroll); + + domObserver = new MutationObserver(mutationList => + mutationList.filter(m => m.type === 'childList').forEach(m => { + m.addedNodes.forEach(domTask); + })); + domObserver.observe(document,{childList: true, subtree: true}); // Issue 13121, 50939 + + // ensure that resize/scroll fire at the end of initialization + domTask(); + + return { + disable: disable + } + }; + + // + // LOADER + // + LABKEY.DataRegion.create = function(config) { + + var region = LABKEY.DataRegions[config.name]; + + if (region) { + // region already exists, update properties + $.each(config, function(key, value) { + region[key] = value; + }); + if (!config.view) { + // when switching back to 'default' view, needs to clear region.view + region.view = undefined; + } + _init.call(region, config); + } + else { + // instantiate a new region + region = new LABKEY.DataRegion(config); + LABKEY.DataRegions[region.name] = region; + } + + return region; + }; + + LABKEY.DataRegion.loadViewDesigner = function(cb, scope) { + LABKEY.requiresExt4Sandbox(function() { + LABKEY.requiresScript('internal/ViewDesigner', cb, scope); + }); + }; + + LABKEY.DataRegion.getCustomViewEditableErrors = function(customView) { + var errors = []; + if (customView && !customView.editable) { + errors.push("The view is read-only and cannot be edited."); + } + return errors; + }; + + LABKEY.DataRegion.registerPane = function(regionName, callback, scope) { + var region = LABKEY.DataRegions[regionName]; + if (region) { + callback.call(scope || region, region); + return; + } + else if (!_paneCache[regionName]) { + _paneCache[regionName] = []; + } + + _paneCache[regionName].push({cb: callback, scope: scope}); + }; + + LABKEY.DataRegion.selectAll = function(config) { + var params = {}; + if (!config.url) { + // DataRegion doesn't have selectAllURL so generate url and query parameters manually + config.url = LABKEY.ActionURL.buildURL('query', 'selectAll.api', config.containerPath); + + config.dataRegionName = config.dataRegionName || 'query'; + + params = LABKEY.Query.buildQueryParams( + config.schemaName, + config.queryName, + config.filters, + null, + config.dataRegionName + ); + + if (config.viewName) + params[config.dataRegionName + VIEWNAME_PREFIX] = config.viewName; + + if (config.containerFilter) + params.containerFilter = config.containerFilter; + + if (config.selectionKey) + params[config.dataRegionName + '.selectionKey'] = config.selectionKey; + + $.each(config.parameters, function(propName, value) { + params[config.dataRegionName + PARAM_PREFIX + propName] = value; + }); + + if (config.ignoreFilter) { + params[config.dataRegionName + '.ignoreFilter'] = true; + } + + // NOTE: ignore maxRows, showRows, and offset + } + + LABKEY.Ajax.request({ + url: config.url, + method: 'POST', + params: params, + success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), + failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) + }); + }; + + /** + * Static method to add or remove items from the selection for a given {@link #selectionKey}. + * + * @param config A configuration object with the following properties: + * @param {String} config.selectionKey See {@link #selectionKey}. + * @param {Array} config.ids Array of primary key ids for each row to select/unselect. + * @param {Boolean} config.checked If true, the ids will be selected, otherwise unselected. + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'count' to indicate the updated selection count. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion#getSelected + * @see LABKEY.DataRegion#clearSelected + */ + LABKEY.DataRegion.setSelected = function(config) { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'setSelected.api', config.containerPath), + method: 'POST', + jsonData: { + checked: config.checked, + id: config.ids || config.id, + key: config.selectionKey, + }, + scope: config.scope, + success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), + failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) + }); + }; + + /** + * Static method to clear all selected items for a given {@link #selectionKey}. + * + * @param config A configuration object with the following properties: + * @param {String} config.selectionKey See {@link #selectionKey}. + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'count' of 0 to indicate an empty selection. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion#setSelected + * @see LABKEY.DataRegion#getSelected + */ + LABKEY.DataRegion.clearSelected = function(config) { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'clearSelected.api', config.containerPath), + method: 'POST', + jsonData: { key: config.selectionKey }, + success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), + failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) + }); + }; + + /** + * Static method to get all selected items for a given {@link #selectionKey}. + * + * @param config A configuration object with the following properties: + * @param {String} config.selectionKey See {@link #selectionKey}. + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'selected' that is an array of the primary keys for the selected rows. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * @param {boolean} [config.clearSelected] If true, clear the session-based selection for this Data Region after + * retrieving the current selection. Defaults to false. + * + * @see LABKEY.DataRegion#setSelected + * @see LABKEY.DataRegion#clearSelected + */ + LABKEY.DataRegion.getSelected = function(config) { + var jsonData = { key: config.selectionKey }; + + // Issue 41705: Support clearing selection from getSelected() + if (config.clearSelected) { + jsonData.clearSelected = true; + } + + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'getSelected.api', config.containerPath), + method: 'POST', + jsonData: jsonData, + success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), + failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) + }); + }; + + /** + * MessageArea wraps the display of messages in a DataRegion. + * @param dataRegion - The dataregion that the MessageArea will bind itself to. + * @param {String[]} [messages] - An initial messages object containing mappings of 'part' to 'msg' + * @constructor + */ + var MessageArea = function(dataRegion, messages) { + this.bindRegion(dataRegion); + + if (messages) { + this.setMessages(messages); + } + }; + + var MsgProto = MessageArea.prototype; + + MsgProto.bindRegion = function(region) { + this.parentSel = '#' + region.domId + '-msgbox'; + }; + + MsgProto.toJSON = function() { + return this.parts; + }; + + MsgProto.addMessage = function(msg, part, append) { + part = part || 'info'; + + var p = part.toLowerCase(); + if (append && this.parts.hasOwnProperty(p)) + { + this.parts[p] += msg; + this.render(p, msg); + } + else { + this.parts[p] = msg; + this.render(p); + } + }; + + MsgProto.getMessage = function(part) { + return this.parts[part.toLowerCase()]; + }; + + MsgProto.hasMessage = function(part) { + return this.getMessage(part) !== undefined; + }; + + MsgProto.hasContent = function() { + return this.parts && Object.keys(this.parts).length > 0; + }; + + MsgProto.removeAll = function() { + this.parts = {}; + this.render(); + }; + + MsgProto.removeMessage = function(part) { + var p = part.toLowerCase(); + if (this.parts.hasOwnProperty(p)) { + this.parts[p] = undefined; + this.render(); + } + }; + + MsgProto.setMessages = function(messages) { + if (LABKEY.Utils.isObject(messages)) { + this.parts = messages; + } + else { + this.parts = {}; + } + }; + + MsgProto.getParent = function() { + return $(this.parentSel); + }; + + MsgProto.render = function(partToUpdate, appendMsg) { + var hasMsg = false, + me = this, + parent = this.getParent(); + + $.each(this.parts, function(part, msg) { + + if (msg) { + // If this is modified, update the server-side renderer in DataRegion.java renderMessages() + var partEl = parent.find('div[data-msgpart="' + part + '"]'); + if (partEl.length === 0) { + parent.append([ + '
', + msg, + '
' + ].join('')); + } + else if (partToUpdate !== undefined && partToUpdate === part) { + if (appendMsg !== undefined) + partEl.append(appendMsg); + else + partEl.html(msg) + } + + hasMsg = true; + } + else { + parent.find('div[data-msgpart="' + part + '"]').remove(); + delete me.parts[part]; + } + }); + + if (hasMsg) { + this.show(); + $(this).trigger('rendermsg', [this, this.parts]); + } + else { + this.hide(); + parent.html(''); + } + }; + + MsgProto.show = function() { this.getParent().show(); }; + MsgProto.hide = function() { this.getParent().hide(); }; + MsgProto.isVisible = function() { return $(this.parentSel + ':visible').length > 0; }; + MsgProto.find = function(selector) { + return this.getParent().find('.dataregion_msgbox_ct').find(selector); + }; + MsgProto.on = function(evt, callback, scope) { $(this).bind(evt, $.proxy(callback, scope)); }; + + /** + * @description Constructs a LABKEY.QueryWebPart class instance + * @class The LABKEY.QueryWebPart simplifies the task of dynamically adding a query web part to your page. Please use + * this class for adding query web parts to a page instead of {@link LABKEY.WebPart}, + * which can be used for other types of web parts. + *

Additional Documentation: + *

+ *

+ * @constructor + * @param {Object} config A configuration object with the following possible properties: + * @param {String} config.schemaName The name of the schema the web part will query. + * @param {String} config.queryName The name of the query within the schema the web part will select and display. + * @param {String} [config.viewName] the name of a saved view you wish to display for the given schema and query name. + * @param {String} [config.reportId] the report id of a saved report you wish to display for the given schema and query name. + * @param {Mixed} [config.renderTo] The element id, DOM element, or Ext element inside of which the part should be rendered. This is typically a <div>. + * If not supplied in the configuration, you must call the render() method to render the part into the page. + * @param {String} [config.errorType] A parameter to specify how query parse errors are returned. (default 'html'). Valid + * values are either 'html' or 'json'. If 'html' is specified the error will be rendered to an HTML view, if 'json' is specified + * the errors will be returned to the callback handlers as an array of objects named 'parseErrors' with the following properties: + *
    + *
  • msg: The error message.
  • + *
  • line: The line number the error occurred at (optional).
  • + *
  • col: The column number the error occurred at (optional).
  • + *
  • errorStr: The line from the source query that caused the error (optional).
  • + *
+ * @param {String} [config.sql] A SQL query that can be used instead of an existing schema name/query name combination. + * @param {Object} [config.metadata] Metadata that can be applied to the properties of the table fields. Currently, this option is only + * available if the query has been specified through the config.sql option. For full documentation on + * available properties, see LabKey XML Schema Reference. + * This object may contain the following properties: + *
    + *
  • type: The type of metadata being specified. Currently, only 'xml' is supported.
  • + *
  • value: The metadata XML value as a string. For example: '<tables xmlns="http://labkey.org/data/xml"><table tableName="Announcement" tableDbType="NOT_IN_DB"><columns><column columnName="Title"><columnTitle>Custom Title</columnTitle></column></columns></table></tables>'
  • + *
+ * @param {String} [config.title] A title for the web part. If not supplied, the query name will be used as the title. + * @param {String} [config.titleHref] If supplied, the title will be rendered as a hyperlink with this value as the href attribute. + * @param {String} [config.buttonBarPosition] DEPRECATED--see config.buttonBar.position + * @param {boolean} [config.allowChooseQuery] If the button bar is showing, whether or not it should be include a button + * to let the user choose a different query. + * @param {boolean} [config.allowChooseView] If the button bar is showing, whether or not it should be include a button + * to let the user choose a different view. + * @param {String} [config.detailsURL] Specify or override the default details URL for the table with one of the form + * "/controller/action.view?id=${RowId}" or "org.labkey.package.MyController$ActionAction.class?id=${RowId}" + * @param {boolean} [config.showDetailsColumn] If the underlying table has a details URL, show a column that renders a [details] link (default true). If true, the record selectors will be included regardless of the 'showRecordSelectors' config option. + * @param {String} [config.updateURL] Specify or override the default updateURL for the table with one of the form + * "/controller/action.view?id=${RowId}" or "org.labkey.package.MyController$ActionAction.class?id=${RowId}" + * @param {boolean} [config.showUpdateColumn] If the underlying table has an update URL, show a column that renders an [edit] link (default true). + * @param {String} [config.insertURL] Specify or override the default insert URL for the table with one of the form + * "/controller/insertAction.view" or "org.labkey.package.MyController$InsertActionAction.class" + * @param {String} [config.importURL] Specify or override the default bulk import URL for the table with one of the form + * "/controller/importAction.view" or "org.labkey.package.MyController$ImportActionAction.class" + * @param {String} [config.deleteURL] Specify or override the default delete URL for the table with one of the form + * "/controller/action.view" or "org.labkey.package.MyController$ActionAction.class". The keys for the selected rows + * will be included in the POST. + * @param {boolean} [config.showImportDataButton] If the underlying table has an import URL, show an "Import Bulk Data" button in the button bar (default true). + * @param {boolean} [config.showInsertNewButton] If the underlying table has an insert URL, show an "Insert New" button in the button bar (default true). + * @param {boolean} [config.showDeleteButton] Show a "Delete" button in the button bar (default true). + * @param {boolean} [config.showReports] If true, show reports on the Views menu (default true). + * @param {boolean} [config.showExportButtons] Show the export button menu in the button bar (default true). + * @param {boolean} [config.showRStudioButton] Show the export to RStudio button menu in the button bar. Requires export button to work. (default false). + * @param {boolean} [config.showBorders] Render the table with borders (default true). + * @param {boolean} [config.showSurroundingBorder] Render the table with a surrounding border (default true). + * @param {boolean} [config.showFilterDescription] Include filter and parameter values in the grid header, if present (default true). + * @param {boolean} [config.showRecordSelectors] Render the select checkbox column (default undefined, meaning they will be shown if the query is updatable by the current user). + * Both 'showDeleteButton' and 'showExportButtons' must be set to false for the 'showRecordSelectors = false' setting to hide the checkboxes. + * @param {boolean} [config.showPagination] Show the pagination links and count (default true). + * @param {boolean} [config.showPaginationCount] Show the total count of rows in the pagination information text (default true). + * @param {boolean} [config.showPaginationCountAsync] Show the total count of rows in the pagination information text, but query for it asynchronously so that the grid data can load initially without it (default false). + * @param {boolean} [config.shadeAlternatingRows] Shade every other row with a light gray background color (default true). + * @param {boolean} [config.suppressRenderErrors] If true, no alert will appear if there is a problem rendering the QueryWebpart. This is most often encountered if page configuration changes between the time when a request was made and the content loads. Defaults to false. + * @param {Object} [config.buttonBar] Button bar configuration. This object may contain any of the following properties: + *
    + *
  • position: Configures where the button bar will appear with respect to the data grid: legal values are 'top', or 'none'. Default is 'top'.
  • + *
  • includeStandardButtons: If true, all standard buttons not specifically mentioned in the items array will be included at the end of the button bar. Default is false.
  • + *
  • items: An array of button bar items. Each item may be either a reference to a standard button, or a new button configuration. + * to reference standard buttons, use one of the properties on {@link #standardButtons}, or simply include a string + * that matches the button's caption. To include a new button configuration, create an object with the following properties: + *
      + *
    • text: The text you want displayed on the button (aka the caption).
    • + *
    • url: The URL to navigate to when the button is clicked. You may use LABKEY.ActionURL to build URLs to controller actions. + * Specify this or a handler function, but not both.
    • + *
    • handler: A reference to the JavaScript function you want called when the button is clicked.
    • + *
    • permission: Optional. Permission that the current user must possess to see the button. + * Valid options are 'READ', 'INSERT', 'UPDATE', 'DELETE', and 'ADMIN'. + * Default is 'READ' if permissionClass is not specified.
    • + *
    • permissionClass: Optional. If permission (see above) is not specified, the fully qualified Java class + * name of the permission that the user must possess to view the button.
    • + *
    • requiresSelection: A boolean value (true/false) indicating whether the button should only be enabled when + * data rows are checked/selected.
    • + *
    • items: To create a drop-down menu button, set this to an array of menu item configurations. + * Each menu item configuration can specify any of the following properties: + *
        + *
      • text: The text of the menu item.
      • + *
      • handler: A reference to the JavaScript function you want called when the menu item is clicked.
      • + *
      • icon: A url to an image to use as the menu item's icon.
      • + *
      • items: An array of sub-menu item configurations. Used for fly-out menus.
      • + *
      + *
    • + *
    + *
  • + *
+ * @param {String} [config.columns] Comma-separated list of column names to be shown in the grid, overriding + * whatever might be set in a custom view. + * @param {String} [config.sort] A base sort order to use. This is a comma-separated list of column names, each of + * which may have a - prefix to indicate a descending sort. It will be treated as the final sort, after any that the user + * has defined in a custom view or through interacting with the grid column headers. + * @param {String} [config.removeableSort] An additional sort order to use. This is a comma-separated list of column names, each of + * which may have a - prefix to indicate a descending sort. It will be treated as the first sort, before any that the user + * has defined in a custom view or through interacting with the grid column headers. + * @param {Array} [config.filters] A base set of filters to apply. This should be an array of {@link LABKEY.Filter} objects + * each of which is created using the {@link LABKEY.Filter.create} method. These filters cannot be removed by the user + * interacting with the UI. + * For compatibility with the {@link LABKEY.Query} object, you may also specify base filters using config.filterArray. + * @param {Array} [config.removeableFilters] A set of filters to apply. This should be an array of {@link LABKEY.Filter} objects + * each of which is created using the {@link LABKEY.Filter.create} method. These filters can be modified or removed by the user + * interacting with the UI. + * @param {Object} [config.parameters] Map of name (string)/value pairs for the values of parameters if the SQL + * references underlying queries that are parameterized. For example, the following passes two parameters to the query: {'Gender': 'M', 'CD4': '400'}. + * The parameters are written to the request URL as follows: query.param.Gender=M&query.param.CD4=400. For details on parameterized SQL queries, see + * Parameterized SQL Queries. + * @param {Array} [config.aggregates] An array of aggregate definitions. The objects in this array should have the properties: + *
    + *
  • column: The name of the column to be aggregated.
  • + *
  • type: The aggregate type (see {@link LABKEY.AggregateTypes})
  • + *
  • label: Optional label used when rendering the aggregate row. + *
+ * @param {String} [config.showRows] Either 'paginated' (the default) 'selected', 'unselected', 'all', or 'none'. + * When 'paginated', the maxRows and offset parameters can be used to page through the query's result set rows. + * When 'selected' or 'unselected' the set of rows selected or unselected by the user in the grid view will be returned. + * You can programmatically get and set the selection using the {@link LABKEY.DataRegion.setSelected} APIs. + * Setting config.maxRows to -1 is the same as 'all' + * and setting config.maxRows to 0 is the same as 'none'. + * @param {Integer} [config.maxRows] The maximum number of rows to return from the server (defaults to 100). + * If you want to return all possible rows, set this config property to -1. + * @param {Integer} [config.offset] The index of the first row to return from the server (defaults to 0). + * Use this along with the maxRows config property to request pages of data. + * @param {String} [config.dataRegionName] The name to be used for the data region. This should be unique within + * the set of query views on the page. If not supplied, a unique name is generated for you. + * @param {String} [config.linkTarget] The name of a browser window/tab in which to open URLs rendered in the + * QueryWebPart. If not supplied, links will generally be opened in the same browser window/tab where the QueryWebPart. + * @param {String} [config.frame] The frame style to use for the web part. This may be one of the following: + * 'div', 'portal', 'none', 'dialog', 'title', 'left-nav'. + * @param {String} [config.showViewPanel] Open the customize view panel after rendering. The value of this option can be "true" or one of "ColumnsTab", "FilterTab", or "SortTab". + * @param {String} [config.bodyClass] A CSS style class that will be added to the enclosing element for the web part. + * Note, this may not be applied when used in conjunction with some "frame" types (e.g. 'none'). + * @param {Function} [config.success] A function to call after the part has been rendered. It will be passed two arguments: + *
    + *
  • dataRegion: the LABKEY.DataRegion object representing the rendered QueryWebPart
  • + *
  • request: the XMLHTTPRequest that was issued to the server
  • + *
+ * @param {Function} [config.failure] A function to call if the request to retrieve the content fails. It will be passed three arguments: + *
    + *
  • json: JSON object containing the exception.
  • + *
  • response: The XMLHttpRequest object containing the response data.
  • + *
  • options: The parameter to the request call.
  • + *
+ * @param {Object} [config.scope] An object to use as the callback function's scope. Defaults to this. + * @param {int} [config.timeout] A timeout for the AJAX call, in milliseconds. Default is 30000 (30 seconds). + * @param {String} [config.containerPath] The container path in which the schema and query name are defined. If not supplied, the current container path will be used. + * @param {String} [config.containerFilter] One of the values of {@link LABKEY.Query.containerFilter} that sets the scope of this query. If not supplied, the current folder will be used. + * @example + * <div id='queryTestDiv1'/> + * <script type="text/javascript"> + var qwp1 = new LABKEY.QueryWebPart({ + + renderTo: 'queryTestDiv1', + title: 'My Query Web Part', + schemaName: 'lists', + queryName: 'People', + buttonBarPosition: 'none', + aggregates: [ + {column: 'First', type: LABKEY.AggregateTypes.COUNT, label: 'Total People'}, + {column: 'Age', type: LABKEY.AggregateTypes.MEAN} + ], + filters: [ + LABKEY.Filter.create('Last', 'Flintstone') + ], + sort: '-Last' + }); + + //note that you may also register for the 'render' event + //instead of using the success config property. + //registering for events is done using Ext event registration. + //Example: + qwp1.on("render", onRender); + function onRender() + { + //...do something after the part has rendered... + } + + /////////////////////////////////////// + // Custom Button Bar Example + + var qwp1 = new LABKEY.QueryWebPart({ + renderTo: 'queryTestDiv1', + title: 'My Query Web Part', + schemaName: 'lists', + queryName: 'People', + buttonBar: { + includeStandardButtons: true, + items:[ + LABKEY.QueryWebPart.standardButtons.views, + {text: 'Test', url: LABKEY.ActionURL.buildURL('project', 'begin')}, + {text: 'Test Script', onClick: "alert('Hello World!'); return false;"}, + {text: 'Test Handler', handler: onTestHandler}, + {text: 'Test Menu', items: [ + {text: 'Item 1', handler: onItem1Handler}, + {text: 'Fly Out', items: [ + {text: 'Sub Item 1', handler: onItem1Handler} + ]}, + '-', //separator + {text: 'Item 2', handler: onItem2Handler} + ]}, + LABKEY.QueryWebPart.standardButtons.exportRows + ]} + }); + + function onTestHandler(dataRegion) + { + alert("onTestHandler called!"); + return false; + } + + function onItem1Handler(dataRegion) + { + alert("onItem1Handler called!"); + } + + function onItem2Handler(dataRegion) + { + alert("onItem2Handler called!"); + } + + </script> + */ + LABKEY.QueryWebPart = function(config) { + config._useQWPDefaults = true; + return LABKEY.DataRegion.create(config); + }; +})(jQuery); + +/** + * A read-only object that exposes properties representing standard buttons shown in LabKey data grids. + * These are used in conjunction with the buttonBar configuration. The following buttons are currently defined: + *
    + *
  • LABKEY.QueryWebPart.standardButtons.query
  • + *
  • LABKEY.QueryWebPart.standardButtons.views
  • + *
  • LABKEY.QueryWebPart.standardButtons.charts
  • + *
  • LABKEY.QueryWebPart.standardButtons.insertNew
  • + *
  • LABKEY.QueryWebPart.standardButtons.deleteRows
  • + *
  • LABKEY.QueryWebPart.standardButtons.exportRows
  • + *
  • LABKEY.QueryWebPart.standardButtons.print
  • + *
+ * @name standardButtons + * @memberOf LABKEY.QueryWebPart# + */ +LABKEY.QueryWebPart.standardButtons = { + query: 'query', + views: 'grid views', + charts: 'charts', + insertNew: 'insert', + deleteRows: 'delete', + exportRows: 'export', + print: 'print' +}; + +/** + * Requests the query web part content and renders it within the element identified by the renderTo parameter. + * Note that you do not need to call this method explicitly if you specify a renderTo property on the config object + * handed to the class constructor. If you do not specify renderTo in the config, then you must call this method + * passing the id of the element in which you want the part rendered + * @function + * @param renderTo The id of the element in which you want the part rendered. + */ + +LABKEY.QueryWebPart.prototype.render = LABKEY.DataRegion.prototype.render; + +/** + * @returns {LABKEY.DataRegion} + */ +LABKEY.QueryWebPart.prototype.getDataRegion = LABKEY.DataRegion.prototype.getDataRegion; + +LABKEY.AggregateTypes = { + /** + * Displays the sum of the values in the specified column + */ + SUM: 'sum', + /** + * Displays the mean of the values in the specified column + */ + MEAN: 'mean', + /** + * Displays the count of the non-blank values in the specified column + */ + COUNT: 'count', + /** + * Displays the maximum value from the specified column + */ + MIN: 'min', + /** + * Displays the minimum values from the specified column + */ + MAX: 'max', + + /** + * Deprecated + */ + AVG: 'mean' + + // TODO how to allow premium module additions to aggregate types? +}; From ce01af40b0f1ad0f382970a86dea900d53abcbc1 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 7 Oct 2025 11:34:22 -0700 Subject: [PATCH 2/7] Max selection size for module context --- .../labkey/api/data/DataRegionSelection.java | 75 +- .../org/labkey/api/query/QueryService.java | 1 + query/src/org/labkey/query/QueryModule.java | 2 + .../query/controllers/QueryController.java | 17548 ++++++++-------- 4 files changed, 8828 insertions(+), 8798 deletions(-) diff --git a/api/src/org/labkey/api/data/DataRegionSelection.java b/api/src/org/labkey/api/data/DataRegionSelection.java index 987341e3b1c..7495d0af2e6 100644 --- a/api/src/org/labkey/api/data/DataRegionSelection.java +++ b/api/src/org/labkey/api/data/DataRegionSelection.java @@ -46,7 +46,6 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; -import java.util.LinkedList; import java.util.List; import java.util.Set; @@ -61,7 +60,9 @@ public class DataRegionSelection public static final String SELECTED_VALUES = ".selectValues"; public static final String SEPARATOR = "$"; public static final String DATA_REGION_SELECTION_KEY = "dataRegionSelectionKey"; - public static final int MAX_SELECTION_SIZE = 1_000; + + // Issue 53997: Establish a maximum number of selected items allowed for a query. + public static final int MAX_QUERY_SELECTION_SIZE = 100_000; // set/updated using query-setSnapshotSelection // can be used to hold an arbitrary set of selections in session @@ -211,7 +212,6 @@ public static String getSelectionKeyFromRequest(ViewContext context) Set result = new LinkedHashSet<>(parameterSelected); Set sessionSelected = getSet(context, key, false); - //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (sessionSelected) { result.addAll(sessionSelected); @@ -268,22 +268,54 @@ public static int setSelected(ViewContext context, String key, Collection selection, boolean checked, boolean useSnapshot) { - if (checked && selection.size() > MAX_SELECTION_SIZE) - throw new BadRequestException(String.format("Too many selected items: %s. Maximum number of selected items allowed is %s.", Formats.commaf0.format(selection.size()), Formats.commaf0.format(MAX_SELECTION_SIZE))); + if (checked && selection.size() > MAX_QUERY_SELECTION_SIZE) + throw new BadRequestException(selectionTooLargeMessage(selection.size())); Set selectedValues = getSet(context, key, true, useSnapshot); if (checked) { - // TODO: Need to synchronize on this change and not make it if it is too many + // Verify that adding these selections will not result in a set that is too large + if (selectedValues.size() + selection.size() > MAX_QUERY_SELECTION_SIZE) + { + // Do not modify the actual selected values + int current = selectedValues.size(); + int distinctAdds = 0; + + for (String id : selection) + { + if (!selectedValues.contains(id)) + distinctAdds++; + } + + int prospective = current + distinctAdds; + if (prospective > MAX_QUERY_SELECTION_SIZE) + throw new BadRequestException(selectionTooLargeMessage(prospective)); + } + selectedValues.addAll(selection); - if (selectedValues.size() > MAX_SELECTION_SIZE) - throw new BadRequestException(String.format("Too many selected items: %s. Maximum number of selected items allowed is %s.", Formats.commaf0.format(selectedValues.size()), Formats.commaf0.format(MAX_SELECTION_SIZE))); } else selectedValues.removeAll(selection); + return selectedValues.size(); } + public static int setSelectedFromForm(QueryForm form) + { + var view = getQueryView(form); + var viewContext = view.getViewContext(); + var selection = getSet(viewContext, form.getQuerySettings().getSelectionKey(), true); + var items = getSelectedItems(view, selection); + + return setSelected(viewContext, form.getQuerySettings().getSelectionKey(), items, false); + } + + private static String selectionTooLargeMessage(long size) + { + return String.format("Too many selected items: %s. Maximum number of selected items allowed is %s.", + Formats.commaf0.format(size), Formats.commaf0.format(MAX_QUERY_SELECTION_SIZE)); + } + /** * Clear any session attributes that match the given container path, as the prefix, and the selection key, as the suffix */ @@ -347,13 +379,11 @@ public static void clearAll(ViewContext context) * Gets the ids of the selected items for all items in the given query form's view. That is, * not just the items on the current page, but all selected items corresponding to the view's filters. */ - public static List getSelected(QueryForm form, boolean clearSelected) throws IOException + public static Set getSelected(QueryForm form, boolean clearSelected) throws IOException { - List items; var view = getQueryView(form); - var selection = getSet(view.getViewContext(), form.getQuerySettings().getSelectionKey(), true); - items = getSelectedItems(view, selection); + var items = getSelectedItems(view, selection); if (clearSelected && !selection.isEmpty()) { @@ -363,7 +393,8 @@ public static List getSelected(QueryForm form, boolean clearSelected) th items.forEach(selection::remove); } } - return Collections.unmodifiableList(items); + + return items; } private static Pair getDataRegionContext(QueryView view) @@ -407,7 +438,7 @@ private static Pair getDataRegionContext(QueryView vi return schema.createView(form, null); } - public static List getValidatedIds(@NotNull List selection, QueryForm form) throws IOException + public static Set getValidatedIds(@NotNull Collection selection, QueryForm form) { return getSelectedItems(getQueryView(form), selection); } @@ -454,7 +485,7 @@ public static int setSelectionForAll(QueryView view, String key, boolean checked try (Timing ignored = MiniProfiler.step("selectAll"); ResultSet rs = rgn.getResults(rc)) { - var selection = createSelectionList(rc, rgn, rs, null); + var selection = createSelectionSet(rc, rgn, rs, null); return setSelected(view.getViewContext(), key, selection, checked); } catch (SQLException e) @@ -468,13 +499,13 @@ public static int setSelectionForAll(QueryView view, String key, boolean checked * @param view the view from which to retrieve the data region context and session variable * @param selectedValues optionally (nullable) specify a collection of selected values that will be matched * against when selecting items. If null, then all items will be returned. - * @return list of items from the result set that are in the selected session, or an empty list if none. + * @return Set of items from the result set that are in the selected session, or an empty list if none. */ - private static List getSelectedItems(QueryView view, @NotNull Collection selectedValues) throws IOException + private static Set getSelectedItems(QueryView view, @NotNull Collection selectedValues) { // Issue 48657: no need to query the region result set if we have no selectedValues if (selectedValues.isEmpty()) - return new LinkedList<>(); + return new LinkedHashSet<>(); var dataRegionContext = getDataRegionContext(view); var rgn = dataRegionContext.first; @@ -494,7 +525,7 @@ private static List getSelectedItems(QueryView view, @NotNull Collection //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (selectedValues) { - return createSelectionList(ctx, rgn, rs, selectedValues); + return createSelectionSet(ctx, rgn, rs, selectedValues); } } catch (SQLException e) @@ -503,14 +534,14 @@ private static List getSelectedItems(QueryView view, @NotNull Collection } } - private static List createSelectionList( + private static Set createSelectionSet( RenderContext ctx, DataRegion rgn, ResultSet rs, @Nullable Collection selectedValues ) throws SQLException { - List selected = new ArrayList<>(); + Set selected = new LinkedHashSet<>(); if (rs != null) { @@ -526,7 +557,7 @@ private static List createSelectionList( if (selectedValues == null || selectedValues.contains(value)) { selected.add(value); - if (selected.size() == MAX_SELECTION_SIZE) + if (selected.size() == MAX_QUERY_SELECTION_SIZE) break; } } diff --git a/api/src/org/labkey/api/query/QueryService.java b/api/src/org/labkey/api/query/QueryService.java index 844b9cb3db6..bbccad189f6 100644 --- a/api/src/org/labkey/api/query/QueryService.java +++ b/api/src/org/labkey/api/query/QueryService.java @@ -56,6 +56,7 @@ public interface QueryService String EXPERIMENTAL_LAST_MODIFIED = "queryMetadataLastModified"; String EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS = "queryProductAllFolderLookups"; String EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED = "queryProductProjectDataListingScoped"; + String MAX_QUERY_SELECTION = "maxQuerySelection"; String PRODUCT_FOLDERS_ENABLED = "isProductFoldersEnabled"; String PRODUCT_FOLDERS_EXIST = "hasProductFolders"; String USE_ROW_BY_ROW_UPDATE = "useLegacyUpdateRows"; diff --git a/query/src/org/labkey/query/QueryModule.java b/query/src/org/labkey/query/QueryModule.java index 16f6c82957c..7485cfe98ca 100644 --- a/query/src/org/labkey/query/QueryModule.java +++ b/query/src/org/labkey/query/QueryModule.java @@ -25,6 +25,7 @@ import org.labkey.api.data.Aggregate; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DataRegionSelection; import org.labkey.api.data.JdbcType; import org.labkey.api.data.views.DataViewService; import org.labkey.api.exp.property.PropertyService; @@ -416,6 +417,7 @@ public JSONObject getPageContextJson(ContainerUser context) json.put(QueryService.PRODUCT_FOLDERS_EXIST, isProductFoldersEnabled && container.hasProductFolders()); json.put(QueryService.EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS, QueryService.get().isProductFoldersAllFolderScopeEnabled()); json.put(QueryService.EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED, QueryService.get().isProductFoldersDataListingScopedToProject()); + json.put(QueryService.MAX_QUERY_SELECTION, DataRegionSelection.MAX_QUERY_SELECTION_SIZE); return json; } } diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 258eb3d0b91..5dd88b4921e 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -1,8776 +1,8772 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed 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. - */ - -package org.labkey.query.controllers; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import org.antlr.runtime.tree.Tree; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.beanutils.ConvertUtils; -import org.apache.commons.collections4.MultiValuedMap; -import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; -import org.apache.commons.collections4.multimap.HashSetValuedHashMap; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.Strings; -import org.apache.commons.lang3.mutable.MutableInt; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.poi.ss.usermodel.Workbook; -import org.apache.xmlbeans.XmlError; -import org.apache.xmlbeans.XmlException; -import org.apache.xmlbeans.XmlOptions; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONParserConfiguration; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.labkey.api.action.Action; -import org.labkey.api.action.ActionType; -import org.labkey.api.action.ApiJsonForm; -import org.labkey.api.action.ApiJsonWriter; -import org.labkey.api.action.ApiQueryResponse; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiResponseWriter; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ApiUsageException; -import org.labkey.api.action.ApiVersion; -import org.labkey.api.action.ConfirmAction; -import org.labkey.api.action.ExportAction; -import org.labkey.api.action.ExportException; -import org.labkey.api.action.ExtendedApiQueryResponse; -import org.labkey.api.action.FormHandlerAction; -import org.labkey.api.action.FormViewAction; -import org.labkey.api.action.HasBindParameters; -import org.labkey.api.action.JsonInputLimit; -import org.labkey.api.action.LabKeyError; -import org.labkey.api.action.Marshal; -import org.labkey.api.action.Marshaller; -import org.labkey.api.action.MutatingApiAction; -import org.labkey.api.action.NullSafeBindException; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.action.ReportingApiQueryResponse; -import org.labkey.api.action.SimpleApiJsonForm; -import org.labkey.api.action.SimpleErrorView; -import org.labkey.api.action.SimpleRedirectAction; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.AdminUrls; -import org.labkey.api.attachments.SpringAttachmentFile; -import org.labkey.api.audit.AbstractAuditTypeProvider; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.AuditTypeEvent; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.audit.provider.ContainerAuditProvider; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.collections.CaseInsensitiveHashSet; -import org.labkey.api.collections.IntHashMap; -import org.labkey.api.collections.LabKeyCollectors; -import org.labkey.api.collections.RowMapFactory; -import org.labkey.api.collections.Sets; -import org.labkey.api.data.AbstractTableInfo; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.Aggregate; -import org.labkey.api.data.AnalyticsProviderItem; -import org.labkey.api.data.BaseColumnInfo; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.CachedResultSets; -import org.labkey.api.data.ColumnHeaderType; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.ContainerType; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.DbSchema; -import org.labkey.api.data.DbSchemaType; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.ExcelWriter; -import org.labkey.api.data.ForeignKey; -import org.labkey.api.data.JdbcMetaDataSelector; -import org.labkey.api.data.JdbcType; -import org.labkey.api.data.JsonWriter; -import org.labkey.api.data.PHI; -import org.labkey.api.data.PropertyManager; -import org.labkey.api.data.PropertyManager.PropertyMap; -import org.labkey.api.data.PropertyManager.WritablePropertyMap; -import org.labkey.api.data.PropertyStorageSpec; -import org.labkey.api.data.QueryLogging; -import org.labkey.api.data.ResultSetView; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.SchemaTableInfo; -import org.labkey.api.data.ShowRows; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.SqlSelector; -import org.labkey.api.data.TSVWriter; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.data.VirtualTable; -import org.labkey.api.data.dialect.JdbcMetaDataLocator; -import org.labkey.api.data.dialect.SqlDialect; -import org.labkey.api.dataiterator.DataIteratorBuilder; -import org.labkey.api.dataiterator.DataIteratorContext; -import org.labkey.api.dataiterator.DataIteratorUtil; -import org.labkey.api.dataiterator.DetailedAuditLogDataIterator; -import org.labkey.api.dataiterator.ListofMapsDataIterator; -import org.labkey.api.exceptions.OptimisticConflictException; -import org.labkey.api.exp.ExperimentException; -import org.labkey.api.exp.api.ProvenanceRecordingParams; -import org.labkey.api.exp.api.ProvenanceService; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.exp.list.ListService; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainAuditProvider; -import org.labkey.api.exp.property.DomainKind; -import org.labkey.api.exp.property.PropertyService; -import org.labkey.api.files.FileContentService; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; -import org.labkey.api.module.ModuleHtmlView; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.pipeline.RecordedAction; -import org.labkey.api.query.AbstractQueryImportAction; -import org.labkey.api.query.AbstractQueryUpdateService; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.CustomView; -import org.labkey.api.query.DefaultSchema; -import org.labkey.api.query.DetailsURL; -import org.labkey.api.query.DuplicateKeyException; -import org.labkey.api.query.ExportScriptModel; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.FilteredTable; -import org.labkey.api.query.InvalidKeyException; -import org.labkey.api.query.MetadataUnavailableException; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryDefinition; -import org.labkey.api.query.QueryException; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryParam; -import org.labkey.api.query.QueryParseException; -import org.labkey.api.query.QuerySchema; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateForm; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.QueryUpdateServiceException; -import org.labkey.api.query.QueryUrls; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.RuntimeValidationException; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.query.SimpleSchemaTreeVisitor; -import org.labkey.api.query.TempQuerySettings; -import org.labkey.api.query.UserSchema; -import org.labkey.api.query.UserSchemaAction; -import org.labkey.api.query.ValidationException; -import org.labkey.api.reports.report.ReportDescriptor; -import org.labkey.api.security.ActionNames; -import org.labkey.api.security.AdminConsoleAction; -import org.labkey.api.security.CSRF; -import org.labkey.api.security.IgnoresTermsOfUse; -import org.labkey.api.security.MutableSecurityPolicy; -import org.labkey.api.security.RequiresAllOf; -import org.labkey.api.security.RequiresAnyOf; -import org.labkey.api.security.RequiresNoPermission; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.SecurityManager; -import org.labkey.api.security.SecurityPolicyManager; -import org.labkey.api.security.User; -import org.labkey.api.security.UserManager; -import org.labkey.api.security.ValidEmail; -import org.labkey.api.security.permissions.AbstractActionPermissionTest; -import org.labkey.api.security.permissions.AdminOperationsPermission; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.EditSharedViewPermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.MoveEntitiesPermission; -import org.labkey.api.security.permissions.Permission; -import org.labkey.api.security.permissions.PlatformDeveloperPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.security.roles.EditorRole; -import org.labkey.api.settings.AdminConsole; -import org.labkey.api.settings.AppProps; -import org.labkey.api.settings.LookAndFeelProperties; -import org.labkey.api.stats.BaseAggregatesAnalyticsProvider; -import org.labkey.api.stats.ColumnAnalyticsProvider; -import org.labkey.api.util.ButtonBuilder; -import org.labkey.api.util.DOM; -import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.JavaScriptFragment; -import org.labkey.api.util.JsonUtil; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.ResponseHelper; -import org.labkey.api.util.ReturnURLString; -import org.labkey.api.util.StringExpression; -import org.labkey.api.util.TestContext; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.UnexpectedException; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DetailsView; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.InsertView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.UpdateView; -import org.labkey.api.view.VBox; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.ViewServlet; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.PageConfig; -import org.labkey.api.writer.HtmlWriter; -import org.labkey.api.writer.ZipFile; -import org.labkey.data.xml.ColumnType; -import org.labkey.data.xml.ImportTemplateType; -import org.labkey.data.xml.TableType; -import org.labkey.data.xml.TablesDocument; -import org.labkey.data.xml.TablesType; -import org.labkey.data.xml.externalSchema.TemplateSchemaType; -import org.labkey.data.xml.queryCustomView.FilterType; -import org.labkey.query.AutoGeneratedDetailsCustomView; -import org.labkey.query.AutoGeneratedInsertCustomView; -import org.labkey.query.AutoGeneratedUpdateCustomView; -import org.labkey.query.CustomViewImpl; -import org.labkey.query.CustomViewUtil; -import org.labkey.query.EditQueriesPermission; -import org.labkey.query.EditableCustomView; -import org.labkey.query.LinkedTableInfo; -import org.labkey.query.MetadataTableJSON; -import org.labkey.query.ModuleCustomQueryDefinition; -import org.labkey.query.ModuleCustomView; -import org.labkey.query.QueryServiceImpl; -import org.labkey.query.TableXML; -import org.labkey.query.audit.QueryExportAuditProvider; -import org.labkey.query.audit.QueryUpdateAuditProvider; -import org.labkey.query.model.MetadataTableJSONMixin; -import org.labkey.query.persist.AbstractExternalSchemaDef; -import org.labkey.query.persist.CstmView; -import org.labkey.query.persist.ExternalSchemaDef; -import org.labkey.query.persist.ExternalSchemaDefCache; -import org.labkey.query.persist.LinkedSchemaDef; -import org.labkey.query.persist.QueryDef; -import org.labkey.query.persist.QueryManager; -import org.labkey.query.reports.ReportsController; -import org.labkey.query.reports.getdata.DataRequest; -import org.labkey.query.sql.QNode; -import org.labkey.query.sql.Query; -import org.labkey.query.sql.SqlParser; -import org.labkey.query.xml.ApiTestsDocument; -import org.labkey.query.xml.TestCaseType; -import org.labkey.remoteapi.RemoteConnections; -import org.labkey.remoteapi.SelectRowsStreamHack; -import org.labkey.remoteapi.query.SelectRowsCommand; -import org.labkey.vfs.FileLike; -import org.springframework.beans.MutablePropertyValues; -import org.springframework.beans.PropertyValue; -import org.springframework.beans.PropertyValues; -import org.springframework.dao.DataAccessException; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.ModelAndView; - -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.OutputStream; -import java.io.PrintWriter; -import java.nio.file.Path; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.apache.commons.lang3.StringUtils.isBlank; -import static org.apache.commons.lang3.StringUtils.trimToEmpty; -import static org.labkey.api.action.ApiJsonWriter.CONTENT_TYPE_JSON; -import static org.labkey.api.assay.AssayFileWriter.ensureUploadDirectory; -import static org.labkey.api.data.DbScope.NO_OP_TRANSACTION; -import static org.labkey.api.query.AbstractQueryUpdateService.saveFile; -import static org.labkey.api.util.DOM.BR; -import static org.labkey.api.util.DOM.DIV; -import static org.labkey.api.util.DOM.FONT; -import static org.labkey.api.util.DOM.Renderable; -import static org.labkey.api.util.DOM.TABLE; -import static org.labkey.api.util.DOM.TD; -import static org.labkey.api.util.DOM.TR; -import static org.labkey.api.util.DOM.at; -import static org.labkey.api.util.DOM.cl; -import static org.labkey.query.MetadataTableJSON.getTableType; -import static org.labkey.query.MetadataTableJSON.parseDocument; - -@SuppressWarnings("DefaultAnnotationParam") - -public class QueryController extends SpringActionController -{ - private static final Logger LOG = LogManager.getLogger(QueryController.class); - private static final String ROW_ATTACHMENT_INDEX_DELIM = "::"; - - private static final Set RESERVED_VIEW_NAMES = CaseInsensitiveHashSet.of( - "Default", - AutoGeneratedDetailsCustomView.NAME, - AutoGeneratedInsertCustomView.NAME, - AutoGeneratedUpdateCustomView.NAME - ); - - private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(QueryController.class, - ValidateQueryAction.class, - ValidateQueriesAction.class, - GetSchemaQueryTreeAction.class, - GetQueryDetailsAction.class, - ViewQuerySourceAction.class - ); - - public QueryController() - { - setActionResolver(_actionResolver); - } - - public static void registerAdminConsoleLinks() - { - AdminConsole.addLink(AdminConsole.SettingsLinkType.Diagnostics, "data sources", new ActionURL(DataSourceAdminAction.class, ContainerManager.getRoot())); - } - - public static class RemoteQueryConnectionUrls - { - public static ActionURL urlManageRemoteConnection(Container c) - { - return new ActionURL(ManageRemoteConnectionsAction.class, c); - } - - public static ActionURL urlCreateRemoteConnection(Container c) - { - return new ActionURL(EditRemoteConnectionAction.class, c); - } - - public static ActionURL urlEditRemoteConnection(Container c, String connectionName) - { - ActionURL url = new ActionURL(EditRemoteConnectionAction.class, c); - url.addParameter("connectionName", connectionName); - return url; - } - - public static ActionURL urlSaveRemoteConnection(Container c) - { - return new ActionURL(EditRemoteConnectionAction.class, c); - } - - public static ActionURL urlDeleteRemoteConnection(Container c, @Nullable String connectionName) - { - ActionURL url = new ActionURL(DeleteRemoteConnectionAction.class, c); - if (connectionName != null) - url.addParameter("connectionName", connectionName); - return url; - } - - public static ActionURL urlTestRemoteConnection(Container c, String connectionName) - { - ActionURL url = new ActionURL(TestRemoteConnectionAction.class, c); - url.addParameter("connectionName", connectionName); - return url; - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class EditRemoteConnectionAction extends FormViewAction - { - @Override - public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) - { - remoteConnectionForm.setConnectionKind(RemoteConnections.CONNECTION_KIND_QUERY); - if (!errors.hasErrors()) - { - String name = remoteConnectionForm.getConnectionName(); - // package the remote-connection properties into the remoteConnectionForm and pass them along - Map map1 = RemoteConnections.getRemoteConnection(RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY, name, getContainer()); - remoteConnectionForm.setUrl(map1.get("URL")); - remoteConnectionForm.setUserEmail(map1.get("user")); - remoteConnectionForm.setPassword(map1.get("password")); - remoteConnectionForm.setFolderPath(map1.get("container")); - } - setHelpTopic("remoteConnection"); - return new JspView<>("/org/labkey/query/view/createRemoteConnection.jsp", remoteConnectionForm, errors); - } - - @Override - public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) - { - return RemoteConnections.createOrEditRemoteConnection(remoteConnectionForm, getContainer(), errors); - } - - @Override - public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) - { - return RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Create/Edit Remote Connection", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class DeleteRemoteConnectionAction extends FormViewAction - { - @Override - public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/query/view/confirmDeleteConnection.jsp", remoteConnectionForm, errors); - } - - @Override - public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) - { - remoteConnectionForm.setConnectionKind(RemoteConnections.CONNECTION_KIND_QUERY); - return RemoteConnections.deleteRemoteConnection(remoteConnectionForm, getContainer()); - } - - @Override - public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) - { - return RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Confirm Delete Connection", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class TestRemoteConnectionAction extends FormViewAction - { - @Override - public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) - { - String name = remoteConnectionForm.getConnectionName(); - String schemaName = "core"; // test Schema Name - String queryName = "Users"; // test Query Name - - // Extract the username, password, and container from the secure property store - Map singleConnectionMap = RemoteConnections.getRemoteConnection(RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY, name, getContainer()); - if (singleConnectionMap.isEmpty()) - throw new NotFoundException(); - String url = singleConnectionMap.get(RemoteConnections.FIELD_URL); - String user = singleConnectionMap.get(RemoteConnections.FIELD_USER); - String password = singleConnectionMap.get(RemoteConnections.FIELD_PASSWORD); - String container = singleConnectionMap.get(RemoteConnections.FIELD_CONTAINER); - - // connect to the remote server and retrieve an input stream - org.labkey.remoteapi.Connection cn = new org.labkey.remoteapi.Connection(url, user, password); - final SelectRowsCommand cmd = new SelectRowsCommand(schemaName, queryName); - try - { - DataIteratorBuilder source = SelectRowsStreamHack.go(cn, container, cmd, getContainer()); - // immediately close the source after opening it, this is a test. - source.getDataIterator(new DataIteratorContext()).close(); - } - catch (Exception e) - { - errors.addError(new LabKeyError("The listed credentials for this remote connection failed to connect.")); - return new JspView<>("/org/labkey/query/view/testRemoteConnectionsFailure.jsp", remoteConnectionForm); - } - - return new JspView<>("/org/labkey/query/view/testRemoteConnectionsSuccess.jsp", remoteConnectionForm); - } - - @Override - public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) - { - return true; - } - - @Override - public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Manage Remote Connections", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - public static class QueryUrlsImpl implements QueryUrls - { - @Override - public ActionURL urlSchemaBrowser(Container c) - { - return new ActionURL(BeginAction.class, c); - } - - @Override - public ActionURL urlSchemaBrowser(Container c, @Nullable String schemaName) - { - ActionURL ret = urlSchemaBrowser(c); - if (schemaName != null) - { - ret.addParameter(QueryParam.schemaName.toString(), schemaName); - } - return ret; - } - - @Override - public ActionURL urlSchemaBrowser(Container c, @Nullable String schemaName, @Nullable String queryName) - { - if (StringUtils.isEmpty(queryName)) - return urlSchemaBrowser(c, schemaName); - ActionURL ret = urlSchemaBrowser(c); - ret.addParameter(QueryParam.schemaName.toString(), trimToEmpty(schemaName)); - ret.addParameter(QueryParam.queryName.toString(), trimToEmpty(queryName)); - return ret; - } - - public ActionURL urlExternalSchemaAdmin(Container c) - { - return urlExternalSchemaAdmin(c, null); - } - - public ActionURL urlExternalSchemaAdmin(Container c, @Nullable String message) - { - ActionURL url = new ActionURL(AdminAction.class, c); - - if (null != message) - url.addParameter("message", message); - - return url; - } - - public ActionURL urlInsertExternalSchema(Container c) - { - return new ActionURL(InsertExternalSchemaAction.class, c); - } - - public ActionURL urlNewQuery(Container c) - { - return new ActionURL(NewQueryAction.class, c); - } - - public ActionURL urlUpdateExternalSchema(Container c, AbstractExternalSchemaDef def) - { - ActionURL url = new ActionURL(EditExternalSchemaAction.class, c); - url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); - return url; - } - - public ActionURL urlReloadExternalSchema(Container c, AbstractExternalSchemaDef def) - { - ActionURL url = new ActionURL(ReloadExternalSchemaAction.class, c); - url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); - return url; - } - - public ActionURL urlDeleteSchema(Container c, AbstractExternalSchemaDef def) - { - ActionURL url = new ActionURL(DeleteSchemaAction.class, c); - url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); - return url; - } - - @Override - public ActionURL urlStartBackgroundRReport(@NotNull ActionURL baseURL, String reportId) - { - ActionURL result = baseURL.clone(); - result.setAction(ReportsController.StartBackgroundRReportAction.class); - result.replaceParameter(ReportDescriptor.Prop.reportId, reportId); - return result; - } - - @Override - public ActionURL urlExecuteQuery(@NotNull ActionURL baseURL) - { - ActionURL result = baseURL.clone(); - result.setAction(ExecuteQueryAction.class); - return result; - } - - @Override - public ActionURL urlExecuteQuery(Container c, String schemaName, String queryName) - { - return new ActionURL(ExecuteQueryAction.class, c) - .addParameter(QueryParam.schemaName, schemaName) - .addParameter(QueryParam.queryName, queryName); - } - - @Override - public @NotNull ActionURL urlCreateExcelTemplate(Container c, String schemaName, String queryName) - { - return new ActionURL(ExportExcelTemplateAction.class, c) - .addParameter(QueryParam.schemaName, schemaName) - .addParameter("query.queryName", queryName); - } - - @Override - public ActionURL urlMetadataQuery(Container c, String schemaName, String queryName) - { - return new ActionURL(MetadataQueryAction.class, c) - .addParameter(QueryParam.schemaName, schemaName) - .addParameter(QueryParam.queryName, queryName); - } - } - - @Override - public PageConfig defaultPageConfig() - { - // set default help topic for query controller - PageConfig config = super.defaultPageConfig(); - config.setHelpTopic("querySchemaBrowser"); - return config; - } - - @AdminConsoleAction(AdminOperationsPermission.class) - public static class DataSourceAdminAction extends SimpleViewAction - { - public DataSourceAdminAction() - { - } - - public DataSourceAdminAction(ViewContext viewContext) - { - setViewContext(viewContext); - } - - @Override - public ModelAndView getView(Object o, BindException errors) - { - // Site Admin or Troubleshooter? Troubleshooters can see all the information but can't test data sources. - boolean hasAdminOpsPerms = getContainer().hasPermission(getUser(), AdminOperationsPermission.class); - List allDefs = QueryManager.get().getExternalSchemaDefs(null); - - MultiValuedMap byDataSourceName = new ArrayListValuedHashMap<>(); - - for (ExternalSchemaDef def : allDefs) - byDataSourceName.put(def.getDataSource(), def); - - MutableInt row = new MutableInt(); - - Renderable r = DOM.DIV( - DIV("This page lists all the data sources defined in your " + AppProps.getInstance().getWebappConfigurationFilename() + " file that were available when first referenced and the external schemas defined in each."), - BR(), - TABLE(cl("labkey-data-region"), - TR(cl("labkey-show-borders"), - hasAdminOpsPerms ? TD(cl("labkey-column-header"), "Test") : null, - TD(cl("labkey-column-header"), "Data Source"), - TD(cl("labkey-column-header"), "Current Status"), - TD(cl("labkey-column-header"), "URL"), - TD(cl("labkey-column-header"), "Database Name"), - TD(cl("labkey-column-header"), "Product Name"), - TD(cl("labkey-column-header"), "Product Version"), - TD(cl("labkey-column-header"), "Max Connections"), - TD(cl("labkey-column-header"), "Active Connections"), - TD(cl("labkey-column-header"), "Idle Connections"), - TD(cl("labkey-column-header"), "Max Wait (ms)") - ), - DbScope.getDbScopes().stream() - .flatMap(scope -> { - String rowStyle = row.getAndIncrement() % 2 == 0 ? "labkey-alternate-row labkey-show-borders" : "labkey-row labkey-show-borders"; - Object status; - boolean connected = false; - try (Connection ignore = scope.getConnection()) - { - status = "connected"; - connected = true; - } - catch (Exception e) - { - status = FONT(cl("labkey-error"), "disconnected"); - } - - return Stream.of( - TR( - cl(rowStyle), - hasAdminOpsPerms ? TD(connected ? new ButtonBuilder("Test").href(new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", scope.getDataSourceName())) : "") : null, - TD(HtmlString.NBSP, scope.getDisplayName()), - TD(status), - TD(scope.getDatabaseUrl()), - TD(scope.getDatabaseName()), - TD(scope.getDatabaseProductName()), - TD(scope.getDatabaseProductVersion()), - TD(scope.getDataSourceProperties().getMaxTotal()), - TD(scope.getDataSourceProperties().getNumActive()), - TD(scope.getDataSourceProperties().getNumIdle()), - TD(scope.getDataSourceProperties().getMaxWaitMillis()) - ), - TR( - cl(rowStyle), - TD(HtmlString.NBSP), - TD(at(DOM.Attribute.colspan, 10), getDataSourceTable(byDataSourceName.get(scope.getDataSourceName()))) - ) - ); - }) - ) - ); - - return new HtmlView(r); - } - - private Renderable getDataSourceTable(Collection dsDefs) - { - if (dsDefs.isEmpty()) - return TABLE(TR(TD(HtmlString.NBSP))); - - MultiValuedMap byContainerPath = new ArrayListValuedHashMap<>(); - - for (ExternalSchemaDef def : dsDefs) - byContainerPath.put(def.getContainerPath(), def); - - TreeSet paths = new TreeSet<>(byContainerPath.keySet()); - - return TABLE(paths.stream() - .map(path -> TR(TD(at(DOM.Attribute.colspan, 4), getDataSourcePath(path, byContainerPath.get(path))))) - ); - } - - private Renderable getDataSourcePath(String path, Collection unsorted) - { - List defs = new ArrayList<>(unsorted); - defs.sort(Comparator.comparing(AbstractExternalSchemaDef::getUserSchemaName, String.CASE_INSENSITIVE_ORDER)); - Container c = ContainerManager.getForPath(path); - - if (null == c) - return TD(); - - boolean hasRead = c.hasPermission(getUser(), ReadPermission.class); - QueryUrlsImpl urls = new QueryUrlsImpl(); - - return - TD(TABLE( - TR(TD( - at(DOM.Attribute.colspan, 3), - hasRead ? LinkBuilder.simpleLink(path, urls.urlExternalSchemaAdmin(c)) : path - )), - TR(TD(TABLE( - defs.stream() - .map(def -> TR(TD( - at(DOM.Attribute.style, "padding-left:20px"), - hasRead ? LinkBuilder.simpleLink(def.getUserSchemaName() + - (!Strings.CS.equals(def.getSourceSchemaName(), def.getUserSchemaName()) ? " (" + def.getSourceSchemaName() + ")" : ""), urls.urlUpdateExternalSchema(c, def)) - : def.getUserSchemaName() - ))) - ))) - )); - } - - @Override - public void addNavTrail(NavTree root) - { - urlProvider(AdminUrls.class).addAdminNavTrail(root, "Data Source Administration", getClass(), getContainer()); - } - } - - public static class TestDataSourceForm - { - private String _dataSource; - - public String getDataSource() - { - return _dataSource; - } - - @SuppressWarnings("unused") - public void setDataSource(String dataSource) - { - _dataSource = dataSource; - } - } - - public static class TestDataSourceConfirmForm extends TestDataSourceForm - { - private String _excludeSchemas; - private String _excludeTables; - - public String getExcludeSchemas() - { - return _excludeSchemas; - } - - @SuppressWarnings("unused") - public void setExcludeSchemas(String excludeSchemas) - { - _excludeSchemas = excludeSchemas; - } - - public String getExcludeTables() - { - return _excludeTables; - } - - @SuppressWarnings("unused") - public void setExcludeTables(String excludeTables) - { - _excludeTables = excludeTables; - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class TestDataSourceConfirmAction extends FormViewAction - { - private DbScope _scope; - - @Override - public ModelAndView getView(TestDataSourceConfirmForm form, boolean reshow, BindException errors) throws Exception - { - validateCommand(form, errors); - return new JspView<>("/org/labkey/query/view/testDataSourceConfirm.jsp", _scope); - } - - @Override - public void validateCommand(TestDataSourceConfirmForm form, Errors errors) - { - _scope = DbScope.getDbScope(form.getDataSource()); - - if (null == _scope) - throw new NotFoundException("Could not resolve data source " + form.getDataSource()); - } - - @Override - public boolean handlePost(TestDataSourceConfirmForm form, BindException errors) throws Exception - { - saveTestDataSourceProperties(form); - return true; - } - - @Override - public URLHelper getSuccessURL(TestDataSourceConfirmForm form) - { - return new ActionURL(TestDataSourceAction.class, getContainer()).addParameter("dataSource", _scope.getDataSourceName()); - } - - @Override - public void addNavTrail(NavTree root) - { - new DataSourceAdminAction(getViewContext()).addNavTrail(root); - root.addChild("Prepare Test of " + _scope.getDataSourceName()); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class TestDataSourceAction extends SimpleViewAction - { - private DbScope _scope; - - @Override - public ModelAndView getView(TestDataSourceForm form, BindException errors) - { - _scope = DbScope.getDbScope(form.getDataSource()); - - if (null == _scope) - throw new NotFoundException("Could not resolve data source " + form.getDataSource()); - - return new JspView<>("/org/labkey/query/view/testDataSource.jsp", _scope); - } - - @Override - public void addNavTrail(NavTree root) - { - new DataSourceAdminAction(getViewContext()).addNavTrail(root); - root.addChild("Test " + _scope.getDataSourceName()); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class ResetDataSourcePropertiesAction extends FormHandlerAction - { - @Override - public void validateCommand(TestDataSourceForm target, Errors errors) - { - } - - @Override - public boolean handlePost(TestDataSourceForm form, BindException errors) throws Exception - { - WritablePropertyMap map = PropertyManager.getWritableProperties(getCategory(form.getDataSource()), false); - if (map != null) - map.delete(); - return true; - } - - @Override - public URLHelper getSuccessURL(TestDataSourceForm form) - { - return new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", form.getDataSource()) ; - } - } - - private static final String TEST_DATA_SOURCE_CATEGORY = "testDataSourceProperties"; - private static final String TEST_DATA_SOURCE_SCHEMAS_PROPERTY = "excludeSchemas"; - private static final String TEST_DATA_SOURCE_TABLES_PROPERTY = "excludeTables"; - - private static String getCategory(String dataSourceName) - { - return TEST_DATA_SOURCE_CATEGORY + "|" + dataSourceName; - } - - public static void saveTestDataSourceProperties(TestDataSourceConfirmForm form) - { - WritablePropertyMap map = PropertyManager.getWritableProperties(getCategory(form.getDataSource()), true); - // Save empty entries as empty string to distinguish from null (which results in default values) - map.put(TEST_DATA_SOURCE_SCHEMAS_PROPERTY, StringUtils.trimToEmpty(form.getExcludeSchemas())); - map.put(TEST_DATA_SOURCE_TABLES_PROPERTY, StringUtils.trimToEmpty(form.getExcludeTables())); - map.save(); - } - - public static TestDataSourceConfirmForm getTestDataSourceProperties(DbScope scope) - { - TestDataSourceConfirmForm form = new TestDataSourceConfirmForm(); - PropertyMap map = PropertyManager.getProperties(getCategory(scope.getDataSourceName())); - form.setExcludeSchemas(map.getOrDefault(TEST_DATA_SOURCE_SCHEMAS_PROPERTY, scope.getSqlDialect().getDefaultSchemasToExcludeFromTesting())); - form.setExcludeTables(map.getOrDefault(TEST_DATA_SOURCE_TABLES_PROPERTY, scope.getSqlDialect().getDefaultTablesToExcludeFromTesting())); - - return form; - } - - @RequiresPermission(ReadPermission.class) - public static class BrowseAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/query/view/browse.jsp", null); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Schema Browser"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class BeginAction extends QueryViewAction - { - @SuppressWarnings("UnusedDeclaration") - public BeginAction() - { - } - - public BeginAction(ViewContext ctx) - { - setViewContext(ctx); - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - JspView view = new JspView<>("/org/labkey/query/view/browse.jsp", form); - view.setFrame(WebPartView.FrameType.NONE); - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Query Schema Browser", new QueryUrlsImpl().urlSchemaBrowser(getContainer())); - } - } - - @RequiresPermission(ReadPermission.class) - public class SchemaAction extends QueryViewAction - { - public SchemaAction() {} - - SchemaAction(QueryForm form) - { - _form = form; - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - _form = form; - return new JspView<>("/org/labkey/query/view/browse.jsp", form); - } - - @Override - public void addNavTrail(NavTree root) - { - if (_form != null && _form.getSchema() != null) - addSchemaActionNavTrail(root, _form.getSchema().getSchemaPath(), _form.getQueryName()); - } - } - - - void addSchemaActionNavTrail(NavTree root, SchemaKey schemaKey, String queryName) - { - if (getContainer().hasOneOf(getUser(), AdminPermission.class, PlatformDeveloperPermission.class)) - { - // Don't show the full query nav trail to non-admin/non-developer users as they almost certainly don't - // want it - try - { - String schemaName = schemaKey.toDisplayString(); - ActionURL url = new ActionURL(BeginAction.class, getContainer()); - url.addParameter("schemaName", schemaKey.toString()); - url.addParameter("queryName", queryName); - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild(schemaName + " Schema", url); - } - catch (NullPointerException e) - { - LOG.error("NullPointerException in addNavTrail", e); - } - } - } - - - // Trusted analysts who are editors can create and modify queries - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - @Action(ActionType.SelectData.class) - public class NewQueryAction extends FormViewAction - { - private NewQueryForm _form; - private ActionURL _successUrl; - - @Override - public void validateCommand(NewQueryForm target, org.springframework.validation.Errors errors) - { - target.ff_newQueryName = StringUtils.trimToNull(target.ff_newQueryName); - if (null == target.ff_newQueryName) - errors.reject(ERROR_MSG, "QueryName is required"); - } - - @Override - public ModelAndView getView(NewQueryForm form, boolean reshow, BindException errors) - { - form.ensureSchemaExists(); - - if (!form.getSchema().canCreate()) - { - throw new UnauthorizedException(); - } - - getPageConfig().setFocusId("ff_newQueryName"); - _form = form; - setHelpTopic("sqlTutorial"); - return new JspView<>("/org/labkey/query/view/newQuery.jsp", form, errors); - } - - @Override - public boolean handlePost(NewQueryForm form, BindException errors) - { - form.ensureSchemaExists(); - - if (!form.getSchema().canCreate()) - { - throw new UnauthorizedException(); - } - - try - { - if (StringUtils.isEmpty(form.ff_baseTableName)) - { - errors.reject(ERROR_MSG, "You must select a base table or query name."); - return false; - } - - UserSchema schema = form.getSchema(); - String newQueryName = form.ff_newQueryName; - QueryDef existing = QueryManager.get().getQueryDef(getContainer(), form.getSchemaName(), newQueryName, true); - if (existing != null) - { - errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists."); - return false; - } - TableInfo existingTable = form.getSchema().getTable(newQueryName, null); - if (existingTable != null) - { - errors.reject(ERROR_MSG, "A table with the name '" + newQueryName + "' already exists."); - return false; - } - // bug 6095 -- conflicting query and dataset names - if (form.getSchema().getTableNames().contains(newQueryName)) - { - errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists as a table"); - return false; - } - QueryDefinition newDef = QueryService.get().createQueryDef(getUser(), getContainer(), form.getSchemaKey(), form.ff_newQueryName); - Query query = new Query(schema); - query.setRootTable(FieldKey.fromParts(form.ff_baseTableName)); - String sql = query.getQueryText(); - if (null == sql) - sql = "SELECT * FROM \"" + form.ff_baseTableName + "\""; - newDef.setSql(sql); - - try - { - newDef.save(getUser(), getContainer()); - } - catch (SQLException x) - { - if (RuntimeSQLException.isConstraintException(x)) - { - errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists."); - return false; - } - else - { - throw x; - } - } - - _successUrl = newDef.urlFor(form.ff_redirect); - return true; - } - catch (Exception e) - { - ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), e); - errors.reject(ERROR_MSG, Objects.toString(e.getMessage(), e.toString())); - return false; - } - } - - @Override - public ActionURL getSuccessURL(NewQueryForm newQueryForm) - { - return _successUrl; - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - root.addChild("New Query", new QueryUrlsImpl().urlNewQuery(getContainer())); - } - } - - // CONSIDER : deleting this action after the SQL editor UI changes are finalized, keep in mind that built-in views - // use this view as well via the edit metadata page. - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) // Note: This action deals with just meta data; it AJAXes data into place using GetWebPartAction - public class SourceQueryAction extends SimpleViewAction - { - public SourceForm _form; - public UserSchema _schema; - public QueryDefinition _queryDef; - - - @Override - public void validate(SourceForm target, BindException errors) - { - _form = target; - if (StringUtils.isEmpty(target.getSchemaName())) - throw new NotFoundException("schema name not specified"); - if (StringUtils.isEmpty(target.getQueryName())) - throw new NotFoundException("query name not specified"); - - QuerySchema querySchema = DefaultSchema.get(getUser(), getContainer(), _form.getSchemaKey()); - if (null == querySchema) - throw new NotFoundException("schema not found: " + _form.getSchemaKey().toDisplayString()); - if (!(querySchema instanceof UserSchema)) - throw new NotFoundException("Could not find the schema '" + _form.getSchemaName() + "' in the folder '" + getContainer().getPath() + "'"); - _schema = (UserSchema)querySchema; - } - - - @Override - public ModelAndView getView(SourceForm form, BindException errors) - { - _queryDef = _schema.getQueryDef(form.getQueryName()); - if (null == _queryDef) - _queryDef = _schema.getQueryDefForTable(form.getQueryName()); - if (null == _queryDef) - throw new NotFoundException("Could not find the query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); - - try - { - if (form.ff_queryText == null) - { - form.ff_queryText = _queryDef.getSql(); - form.ff_metadataText = _queryDef.getMetadataXml(); - if (null == form.ff_metadataText) - form.ff_metadataText = form.getDefaultMetadataText(); - } - - for (QueryException qpe : _queryDef.getParseErrors(_schema)) - { - errors.reject(ERROR_MSG, Objects.toString(qpe.getMessage(), qpe.toString())); - } - } - catch (Exception e) - { - try - { - ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), e); - } - catch (Throwable t) - { - // - } - errors.reject("ERROR_MSG", e.toString()); - LOG.error("Error", e); - } - - Renderable moduleWarning = null; - if (_queryDef instanceof ModuleCustomQueryDefinition mcqd && _queryDef.canEdit(getUser())) - { - moduleWarning = DIV(cl("labkey-warning-messages"), - "This SQL query is defined in the '" + mcqd.getModuleName() + "' module in directory '" + mcqd.getSqlFile().getParent() + "'.", - BR(), - "Changes to this query will be reflected in all usages across different folders on the server." - ); - } - - var sourceQueryView = new JspView<>("/org/labkey/query/view/sourceQuery.jsp", this, errors); - WebPartView ret = sourceQueryView; - if (null != moduleWarning) - ret = new VBox(new HtmlView(moduleWarning), sourceQueryView); - return ret; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("useSqlEditor"); - - addSchemaActionNavTrail(root, _form.getSchemaKey(), _form.getQueryName()); - - root.addChild("Edit " + _form.getQueryName()); - } - } - - - /** - * Ajax action to save a query. If the save is successful the request will return successfully. A query - * with SQL syntax errors can still be saved successfully. - * - * If the SQL contains parse errors, a parseErrors object will be returned which contains an array of - * JSON serialized error information. - */ - // Trusted analysts who are editors can create and modify queries - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - @Action(ActionType.Configure.class) - public static class SaveSourceQueryAction extends MutatingApiAction - { - private UserSchema _schema; - - @Override - public void validateForm(SourceForm form, Errors errors) - { - if (StringUtils.isEmpty(form.getSchemaName())) - throw new NotFoundException("Query definition not found, schemaName and queryName are required."); - if (StringUtils.isEmpty(form.getQueryName())) - throw new NotFoundException("Query definition not found, schemaName and queryName are required."); - - QuerySchema querySchema = DefaultSchema.get(getUser(), getContainer(), form.getSchemaKey()); - if (null == querySchema) - throw new NotFoundException("schema not found: " + form.getSchemaKey().toDisplayString()); - if (!(querySchema instanceof UserSchema)) - throw new NotFoundException("Could not find the schema '" + form.getSchemaName() + "' in the folder '" + getContainer().getPath() + "'"); - _schema = (UserSchema)querySchema; - - XmlOptions options = XmlBeansUtil.getDefaultParseOptions(); - List xmlErrors = new ArrayList<>(); - options.setErrorListener(xmlErrors); - try - { - // had a couple of real-world failures due to null pointers in this code, so it's time to be paranoid - if (form.ff_metadataText != null) - { - TablesDocument tablesDoc = TablesDocument.Factory.parse(form.ff_metadataText, options); - if (tablesDoc != null) - { - tablesDoc.validate(options); - TablesType tablesType = tablesDoc.getTables(); - if (tablesType != null) - { - for (TableType tableType : tablesType.getTableArray()) - { - if (null != tableType) - { - if (!Objects.equals(tableType.getTableName(), form.getQueryName())) - { - errors.reject(ERROR_MSG, "Table name in the XML metadata must match the table/query name: " + form.getQueryName()); - } - - TableType.Columns tableColumns = tableType.getColumns(); - if (null != tableColumns) - { - ColumnType[] tableColumnArray = tableColumns.getColumnArray(); - for (ColumnType column : tableColumnArray) - { - if (column.isSetPhi() || column.isSetProtected()) - { - throw new IllegalArgumentException("PHI/protected metadata must not be set here."); - } - - ColumnType.Fk fk = column.getFk(); - if (null != fk) - { - try - { - validateForeignKey(fk, column, errors); - validateLookupFilter(AbstractTableInfo.parseXMLLookupFilters(fk.getFilters()), errors); - } - catch (ValidationException e) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - } - } - } - } - } - } - } - } - } - catch (XmlException e) - { - throw new RuntimeValidationException(e); - } - - for (XmlError xmle : xmlErrors) - { - errors.reject(ERROR_MSG, XmlBeansUtil.getErrorMessage(xmle)); - } - } - - private void validateForeignKey(ColumnType.Fk fk, ColumnType column, Errors errors) - { - if (fk.isSetFkMultiValued()) - { - // issue 51695 : don't let users create unsupported MVFK types - String type = fk.getFkMultiValued(); - if (!AbstractTableInfo.MultiValuedFkType.junction.name().equals(type)) - { - errors.reject(ERROR_MSG, String.format("Column : \"%s\" has an invalid fkMultiValued value : \"%s\" is not supported.", column.getColumnName(), type)); - } - } - } - - private void validateLookupFilter(Map> filterMap, Errors errors) - { - filterMap.forEach((operation, filters) -> { - - String displayStr = "Filter for operation : " + operation.name(); - for (FilterType filter : filters) - { - if (isBlank(filter.getColumn())) - errors.reject(ERROR_MSG, displayStr + " requires columnName"); - - if (null == filter.getOperator()) - { - errors.reject(ERROR_MSG, displayStr + " requires operator"); - } - else - { - CompareType compareType = CompareType.getByURLKey(filter.getOperator().toString()); - if (null == compareType) - { - errors.reject(ERROR_MSG, displayStr + " operator is invalid"); - } - else - { - if (compareType.isDataValueRequired() && null == filter.getValue()) - errors.reject(ERROR_MSG, displayStr + " requires a value but none is specified"); - } - } - } - - try - { - // attempt to convert to something we can query against - SimpleFilter.fromXml(filters.toArray(new FilterType[0])); - } - catch (Exception e) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - }); - } - - @Override - public ApiResponse execute(SourceForm form, BindException errors) - { - var queryDef = _schema.getQueryDef(form.getQueryName()); - if (null == queryDef) - queryDef = _schema.getQueryDefForTable(form.getQueryName()); - if (null == queryDef) - throw new NotFoundException("Could not find the query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); - - ApiSimpleResponse response = new ApiSimpleResponse(); - - try - { - if (form.ff_queryText != null) - { - if (!queryDef.isSqlEditable()) - throw new UnauthorizedException("Query SQL is not editable."); - - if (!queryDef.canEdit(getUser())) - throw new UnauthorizedException("Edit permissions are required."); - - queryDef.setSql(form.ff_queryText); - } - - String metadataText = StringUtils.trimToNull(form.ff_metadataText); - if (!Objects.equals(metadataText, queryDef.getMetadataXml())) - { - if (queryDef.isMetadataEditable()) - { - if (!queryDef.canEditMetadata(getUser())) - throw new UnauthorizedException("Edit metadata permissions are required."); - - if (!getUser().isTrustedBrowserDev()) - { - JavaScriptFragment.ensureXMLMetadataNoJavaScript(metadataText); - } - - queryDef.setMetadataXml(metadataText); - } - else - { - if (metadataText != null) - throw new UnsupportedOperationException("Query metadata is not editable."); - } - } - - queryDef.save(getUser(), getContainer()); - - // the query was successfully saved, validate the query but return any errors in the success response - List parseErrors = new ArrayList<>(); - List parseWarnings = new ArrayList<>(); - queryDef.validateQuery(_schema, parseErrors, parseWarnings); - if (!parseErrors.isEmpty()) - { - JSONArray errorArray = new JSONArray(); - - for (QueryException e : parseErrors) - { - errorArray.put(e.toJSON(form.ff_queryText)); - } - response.put("parseErrors", errorArray); - } - else if (!parseWarnings.isEmpty()) - { - JSONArray errorArray = new JSONArray(); - - for (QueryException e : parseWarnings) - { - errorArray.put(e.toJSON(form.ff_queryText)); - } - response.put("parseWarnings", errorArray); - } - } - catch (SQLException e) - { - errors.reject(ERROR_MSG, "An exception occurred: " + e); - LOG.error("Error", e); - } - catch (RuntimeException e) - { - errors.reject(ERROR_MSG, "An exception occurred: " + e.getMessage()); - LOG.error("Error", e); - } - - if (errors.hasErrors()) - return null; - - //if we got here, the query is OK - response.put("success", true); - return response; - } - - } - - - // Trusted analysts who are editors can create and modify queries - @RequiresAllOf({EditQueriesPermission.class, DeletePermission.class}) - @Action(ActionType.Configure.class) - public static class DeleteQueryAction extends ConfirmAction - { - public SourceForm _form; - public QuerySchema _baseSchema; - public QueryDefinition _queryDef; - - - @Override - public void validateCommand(SourceForm target, Errors errors) - { - _form = target; - if (StringUtils.isEmpty(target.getSchemaName())) - throw new NotFoundException("Query definition not found, schemaName and queryName are required."); - if (StringUtils.isEmpty(target.getQueryName())) - throw new NotFoundException("Query definition not found, schemaName and queryName are required."); - - _baseSchema = DefaultSchema.get(getUser(), getContainer(), _form.getSchemaKey()); - if (null == _baseSchema) - throw new NotFoundException("Schema not found: " + _form.getSchemaKey().toDisplayString()); - } - - - @Override - public ModelAndView getConfirmView(SourceForm form, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Delete Query"); - _queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), _baseSchema.getSchemaName(), form.getQueryName()); - - if (null == _queryDef) - throw new NotFoundException("Query not found: " + form.getQueryName()); - - if (!_queryDef.canDelete(getUser())) - { - errors.reject(ERROR_MSG, "Sorry, this query can not be deleted"); - } - - return new JspView<>("/org/labkey/query/view/deleteQuery.jsp", this, errors); - } - - - @Override - public boolean handlePost(SourceForm form, BindException errors) throws Exception - { - _queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), _baseSchema.getSchemaName(), form.getQueryName()); - - if (null == _queryDef) - return false; - try - { - _queryDef.delete(getUser()); - } - catch (OptimisticConflictException x) - { - /* reshow will throw NotFound, so just ignore */ - } - return true; - } - - @Override - @NotNull - public ActionURL getSuccessURL(SourceForm queryForm) - { - return ((UserSchema)_baseSchema).urlFor(QueryAction.schema); - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public class ExecuteQueryAction extends QueryViewAction - { - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - _form = form; - - if (errors.hasErrors()) - return new SimpleErrorView(errors, true); - - QueryView queryView = Objects.requireNonNull(form.getQueryView()); - - var t = queryView.getTable(); - if (null != t && !t.allowRobotsIndex()) - { - getPageConfig().setRobotsNone(); - } - - if (isPrint()) - { - queryView.setPrintView(true); - getPageConfig().setTemplate(PageConfig.Template.Print); - getPageConfig().setShowPrintDialog(true); - } - - queryView.setShadeAlternatingRows(true); - queryView.setShowBorders(true); - setHelpTopic("customSQL"); - _queryView = queryView; - return queryView; - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - TableInfo ti = null; - try - { - if (null != _queryView) - ti = _queryView.getTable(); - } - catch (QueryParseException x) - { - /* */ - } - String display = ti == null ? _form.getQueryName() : ti.getTitle(); - root.addChild(display); - } - } - - - @RequiresPermission(AdminOperationsPermission.class) - public class RawTableMetaDataAction extends QueryViewAction - { - private String _dbSchemaName; - private String _dbTableName; - - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - _form = form; - - QueryView queryView = form.getQueryView(); - String userSchemaName = queryView.getSchema().getName(); - TableInfo ti = queryView.getTable(); - if (null == ti) - throw new NotFoundException(); - - DbScope scope = ti.getSchema().getScope(); - - // Test for provisioned table - if (ti.getDomain() != null) - { - Domain domain = ti.getDomain(); - if (domain.getStorageTableName() != null) - { - // Use the real table and schema names for getting the metadata - _dbTableName = domain.getStorageTableName(); - _dbSchemaName = domain.getDomainKind().getStorageSchemaName(); - } - } - - // No domain or domain with non-provisioned storage (e.g., core.Users) - if (null == _dbSchemaName || null == _dbTableName) - { - DbSchema dbSchema = ti.getSchema(); - _dbSchemaName = dbSchema.getName(); - - // Try to get the underlying schema table and use the meta data name, #12015 - if (ti instanceof FilteredTable fti) - ti = fti.getRealTable(); - - if (ti instanceof SchemaTableInfo) - _dbTableName = ti.getMetaDataIdentifier().getId(); - else if (ti instanceof LinkedTableInfo) - _dbTableName = ti.getName(); - - if (null == _dbTableName) - { - TableInfo tableInfo = dbSchema.getTable(ti.getName()); - if (null != tableInfo) - _dbTableName = tableInfo.getMetaDataIdentifier().getId(); - } - } - - if (null != _dbTableName) - { - VBox result = new VBox(); - - ActionURL url = null; - QuerySchema qs = DefaultSchema.get(getUser(), getContainer()).getSchema(userSchemaName); - if (qs != null) - { - url = new ActionURL(RawSchemaMetaDataAction.class, getContainer()); - url.addParameter("schemaName", userSchemaName); - } - - SqlDialect dialect = scope.getSqlDialect(); - ScopeView scopeInfo = new ScopeView("Scope and Schema Information", scope, _dbSchemaName, url, _dbTableName); - - result.addView(scopeInfo); - - try (JdbcMetaDataLocator locator = dialect.getTableResolver().getSingleTableLocator(scope, _dbSchemaName, _dbTableName)) - { - JdbcMetaDataSelector columnSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getColumns(l.getCatalogName(), l.getSchemaNamePattern(), l.getTableNamePattern(), null)); - result.addView(new ResultSetView(CachedResultSets.create(columnSelector.getResultSet(), true, Table.ALL_ROWS), "Table Meta Data")); - - JdbcMetaDataSelector pkSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getPrimaryKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); - result.addView(new ResultSetView(CachedResultSets.create(pkSelector.getResultSet(), true, Table.ALL_ROWS), "Primary Key Meta Data")); - - if (dialect.canCheckIndices(ti)) - { - JdbcMetaDataSelector indexSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getIndexInfo(l.getCatalogName(), l.getSchemaName(), l.getTableName(), false, false)); - result.addView(new ResultSetView(CachedResultSets.create(indexSelector.getResultSet(), true, Table.ALL_ROWS), "Other Index Meta Data")); - } - - JdbcMetaDataSelector ikSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getImportedKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); - result.addView(new ResultSetView(CachedResultSets.create(ikSelector.getResultSet(), true, Table.ALL_ROWS), "Imported Keys Meta Data")); - - JdbcMetaDataSelector ekSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getExportedKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); - result.addView(new ResultSetView(CachedResultSets.create(ekSelector.getResultSet(), true, Table.ALL_ROWS), "Exported Keys Meta Data")); - } - return result; - } - else - { - errors.reject(ERROR_MSG, "Raw metadata not accessible for table " + ti.getName()); - return new SimpleErrorView(errors); - } - } - - @Override - public void addNavTrail(NavTree root) - { - (new SchemaAction(_form)).addNavTrail(root); - if (null != _dbTableName) - root.addChild("JDBC Meta Data For Table \"" + _dbSchemaName + "." + _dbTableName + "\""); - } - } - - - @RequiresPermission(AdminOperationsPermission.class) - public class RawSchemaMetaDataAction extends SimpleViewAction - { - private String _schemaName; - - @Override - public ModelAndView getView(Object form, BindException errors) throws Exception - { - _schemaName = getViewContext().getActionURL().getParameter("schemaName"); - if (null == _schemaName) - throw new NotFoundException(); - QuerySchema qs = DefaultSchema.get(getUser(), getContainer()).getSchema(_schemaName); - if (null == qs) - throw new NotFoundException(_schemaName); - DbSchema schema = qs.getDbSchema(); - String dbSchemaName = schema.getName(); - DbScope scope = schema.getScope(); - SqlDialect dialect = scope.getSqlDialect(); - - HttpView scopeInfo = new ScopeView("Scope Information", scope); - - ModelAndView tablesView; - - try (JdbcMetaDataLocator locator = dialect.getTableResolver().getAllTablesLocator(scope, dbSchemaName)) - { - JdbcMetaDataSelector selector = new JdbcMetaDataSelector(locator, - (dbmd, locator1) -> dbmd.getTables(locator1.getCatalogName(), locator1.getSchemaNamePattern(), locator1.getTableNamePattern(), null)); - Set tableNames = Sets.newCaseInsensitiveHashSet(qs.getTableNames()); - - ActionURL url = new ActionURL(RawTableMetaDataAction.class, getContainer()) - .addParameter("schemaName", _schemaName) - .addParameter("query.queryName", null); - tablesView = new ResultSetView(CachedResultSets.create(selector.getResultSet(), true, Table.ALL_ROWS), "Tables", "TABLE_NAME", url) - { - @Override - protected boolean shouldLink(ResultSet rs) throws SQLException - { - // Only link to tables and views (not indexes or sequences). And only if they're defined in the query schema. - String name = rs.getString("TABLE_NAME"); - String type = rs.getString("TABLE_TYPE"); - return ("TABLE".equalsIgnoreCase(type) || "VIEW".equalsIgnoreCase(type)) && tableNames.contains(name); - } - }; - } - - return new VBox(scopeInfo, tablesView); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("JDBC Meta Data For Schema \"" + _schemaName + "\""); - } - } - - - public static class ScopeView extends WebPartView - { - private final DbScope _scope; - private final String _schemaName; - private final String _tableName; - private final ActionURL _url; - - private ScopeView(String title, DbScope scope) - { - this(title, scope, null, null, null); - } - - private ScopeView(String title, DbScope scope, String schemaName, ActionURL url, String tableName) - { - super(title); - _scope = scope; - _schemaName = schemaName; - _tableName = tableName; - _url = url; - } - - @Override - protected void renderView(Object model, HtmlWriter out) - { - TABLE( - null != _schemaName ? getLabelAndContents("Schema", _url == null ? _schemaName : LinkBuilder.simpleLink(_schemaName, _url)) : null, - null != _tableName ? getLabelAndContents("Table", _tableName) : null, - getLabelAndContents("Scope", _scope.getDisplayName()), - getLabelAndContents("Dialect", _scope.getSqlDialect().getClass().getSimpleName()), - getLabelAndContents("URL", _scope.getDatabaseUrl()) - ).appendTo(out); - } - - // Return a single row (TR) with styled label and contents in separate TDs - private Renderable getLabelAndContents(String label, Object contents) - { - return TR( - TD( - cl("labkey-form-label"), - label - ), - TD( - contents - ) - ); - } - } - - // for backwards compat same as _executeQuery.view ?_print=1 - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public class PrintRowsAction extends ExecuteQueryAction - { - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - _print = true; - ModelAndView result = super.getView(form, errors); - String title = form.getQueryName(); - if (StringUtils.isEmpty(title)) - title = form.getSchemaName(); - getPageConfig().setTitle(title, true); - return result; - } - } - - - abstract static class _ExportQuery extends SimpleViewAction - { - @Override - public ModelAndView getView(K form, BindException errors) throws Exception - { - QueryView view = form.getQueryView(); - getPageConfig().setTemplate(PageConfig.Template.None); - HttpServletResponse response = getViewContext().getResponse(); - response.setHeader("X-Robots-Tag", "noindex"); - try - { - _export(form, view); - return null; - } - catch (QueryService.NamedParameterNotProvided | QueryParseException x) - { - ExceptionUtil.decorateException(x, ExceptionUtil.ExceptionInfo.SkipMothershipLogging, "true", true); - throw x; - } - } - - abstract void _export(K form, QueryView view) throws Exception; - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportScriptForm extends QueryForm - { - private String _type; - - public String getScriptType() - { - return _type; - } - - public void setScriptType(String type) - { - _type = type; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) // This is called "export" but it doesn't export any data - @CSRF(CSRF.Method.ALL) - public static class ExportScriptAction extends SimpleViewAction - { - @Override - public void validate(ExportScriptForm form, BindException errors) - { - // calling form.getQueryView() as a validation check as it will throw if schema/query missing - form.getQueryView(); - - if (StringUtils.isEmpty(form.getScriptType())) - throw new NotFoundException("Missing required parameter: scriptType."); - } - - @Override - public ModelAndView getView(ExportScriptForm form, BindException errors) - { - return ExportScriptModel.getExportScriptView(QueryView.create(form, errors), form.getScriptType(), getPageConfig(), getViewContext().getResponse()); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportRowsExcelAction extends _ExportQuery - { - @Override - void _export(ExportQueryForm form, QueryView view) throws Exception - { - view.exportToExcel(getViewContext().getResponse(), form.getHeaderType(), ExcelWriter.ExcelDocumentType.xls, form.getRenameColumnMap()); - } - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportRowsXLSXAction extends _ExportQuery - { - @Override - void _export(ExportQueryForm form, QueryView view) throws Exception - { - view.exportToExcel(getViewContext().getResponse(), form.getHeaderType(), ExcelWriter.ExcelDocumentType.xlsx, form.getRenameColumnMap()); - } - } - - public static class ExportQueriesForm extends ExportQueryForm implements ApiJsonForm - { - private String filename; - private List queryForms; - - public void setFilename(String filename) - { - this.filename = filename; - } - - public String getFilename() - { - return filename; - } - - public void setQueryForms(List queryForms) - { - this.queryForms = queryForms; - } - - public List getQueryForms() - { - return queryForms; - } - - /** - * Map JSON to Spring PropertyValue objects. - * @param json the properties - */ - private MutablePropertyValues getPropertyValues(JSONObject json) - { - // Collecting mapped properties as a list because adding them to an existing MutablePropertyValues object replaces existing values - List properties = new ArrayList<>(); - - for (String key : json.keySet()) - { - Object value = json.get(key); - if (value instanceof JSONArray val) - { - // Split arrays into individual pairs to be bound (Issue #45452) - for (int i = 0; i < val.length(); i++) - { - properties.add(new PropertyValue(key, val.get(i).toString())); - } - } - else - { - properties.add(new PropertyValue(key, value)); - } - } - - return new MutablePropertyValues(properties); - } - - @Override - public void bindJson(JSONObject json) - { - setFilename(json.get("filename").toString()); - List forms = new ArrayList<>(); - - JSONArray models = json.optJSONArray("queryForms"); - if (models == null) - { - QueryController.LOG.error("No models to export; Form's `queryForms` property was null"); - throw new RuntimeValidationException("No queries to export; Form's `queryForms` property was null"); - } - - for (JSONObject queryModel : JsonUtil.toJSONObjectList(models)) - { - ExportQueryForm qf = new ExportQueryForm(); - qf.setViewContext(getViewContext()); - - qf.bindParameters(getPropertyValues(queryModel)); - forms.add(qf); - } - - setQueryForms(forms); - } - } - - /** - * Export multiple query forms - */ - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportQueriesXLSXAction extends ReadOnlyApiAction - { - @Override - public Object execute(ExportQueriesForm form, BindException errors) throws Exception - { - getPageConfig().setTemplate(PageConfig.Template.None); - HttpServletResponse response = getViewContext().getResponse(); - response.setHeader("X-Robots-Tag", "noindex"); - ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment); - ViewContext viewContext = getViewContext(); - - Map> nameFormMap = new CaseInsensitiveHashMap<>(); - Map sheetNames = new HashMap<>(); - form.getQueryForms().forEach(qf -> { - String sheetName = qf.getSheetName(); - QueryView qv = qf.getQueryView(); - // use the given sheet name if provided, otherwise try the query definition name - String name = StringUtils.isNotBlank(sheetName) ? sheetName : qv.getQueryDef().getName(); - // if there is no sheet name or queryDefinition name, use a data region name if provided. Otherwise, use "Data" - name = StringUtils.isNotBlank(name) ? name : StringUtils.isNotBlank(qv.getDataRegionName()) ? qv.getDataRegionName() : "Data"; - // clean it to remove undesirable characters and make it of an acceptable length - name = ExcelWriter.cleanSheetName(name); - nameFormMap.computeIfAbsent(name, k -> new ArrayList<>()).add(qf); - }); - // Issue 53722: Need to assure unique names for the sheets in the presence of really long names - for (Map.Entry> entry : nameFormMap.entrySet()) { - String name = entry.getKey(); - if (entry.getValue().size() > 1) - { - List queryForms = entry.getValue(); - int countLength = String.valueOf(queryForms.size()).length() + 2; - if (countLength > name.length()) - throw new IllegalArgumentException("Cannot create sheet names from overlapping query names."); - for (int i = 0; i < queryForms.size(); i++) - { - sheetNames.put(entry.getValue().get(i), name.substring(0, name.length() - countLength) + "(" + i + ")"); - } - } - else - { - sheetNames.put(entry.getValue().get(0), name); - } - } - ExcelWriter writer = new ExcelWriter(ExcelWriter.ExcelDocumentType.xlsx) { - @Override - protected void renderSheets(Workbook workbook) - { - for (ExportQueryForm qf : form.getQueryForms()) - { - qf.setViewContext(viewContext); - qf.getSchema(); - - QueryView qv = qf.getQueryView(); - QueryView.ExcelExportConfig config = new QueryView.ExcelExportConfig(response, qf.getHeaderType()) - .setExcludeColumns(qf.getExcludeColumns()) - .setRenamedColumns(qf.getRenameColumnMap()); - qv.configureExcelWriter(this, config); - setSheetName(sheetNames.get(qf)); - setAutoSize(true); - renderNewSheet(workbook); - qv.logAuditEvent("Exported to Excel", getDataRowCount()); - } - - workbook.setActiveSheet(0); - } - }; - writer.setFilenamePrefix(form.getFilename()); - writer.renderWorkbook(response); - return null; //Returning anything here will cause error as excel writer will close the response stream - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class TemplateForm extends ExportQueryForm - { - boolean insertColumnsOnly = true; - String filenamePrefix; - FieldKey[] includeColumn; - String fileType; - - public TemplateForm() - { - _headerType = ColumnHeaderType.Caption; - } - - // "captionType" field backwards compatibility - public void setCaptionType(ColumnHeaderType headerType) - { - _headerType = headerType; - } - - public ColumnHeaderType getCaptionType() - { - return _headerType; - } - - public List getIncludeColumns() - { - if (includeColumn == null || includeColumn.length == 0) - return Collections.emptyList(); - return Arrays.asList(includeColumn); - } - - public FieldKey[] getIncludeColumn() - { - return includeColumn; - } - - public void setIncludeColumn(FieldKey[] includeColumn) - { - this.includeColumn = includeColumn; - } - - @NotNull - public String getFilenamePrefix() - { - return filenamePrefix == null ? getQueryName() : filenamePrefix; - } - - public void setFilenamePrefix(String prefix) - { - filenamePrefix = prefix; - } - - public String getFileType() - { - return fileType; - } - - public void setFileType(String fileType) - { - this.fileType = fileType; - } - } - - - /** - * Can be used to generate an Excel template for import into a table. Supported URL params include: - *
- *
filenamePrefix
- *
the prefix of the excel file that is generated, defaults to '_data'
- * - *
query.viewName
- *
if provided, the resulting excel file will use the fields present in this view. - * Non-usereditable columns will be skipped. - * Non-existent columns (like a lookup) unless includeMissingColumns is true. - * Any required columns missing from this view will be appended to the end of the query. - *
- * - *
includeColumn
- *
List of column names to include, even if the column doesn't exist or is non-userEditable. - * For example, this can be used to add a fake column that is only supported during the import process. - *
- * - *
excludeColumn
- *
List of column names to exclude. - *
- * - *
exportAlias.columns
- *
Use alternative column name in excel: exportAlias.originalColumnName=aliasColumnName - *
- * - *
captionType
- *
determines which column property is used in the header, either Label or Name
- *
- */ - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportExcelTemplateAction extends _ExportQuery - { - public ExportExcelTemplateAction() - { - setCommandClass(TemplateForm.class); - } - - @Override - void _export(TemplateForm form, QueryView view) throws Exception - { - boolean respectView = form.getViewName() != null; - ExcelWriter.ExcelDocumentType fileType = ExcelWriter.ExcelDocumentType.xlsx; - if (form.getFileType() != null) - { - try - { - fileType = ExcelWriter.ExcelDocumentType.valueOf(form.getFileType().toLowerCase()); - } - catch (IllegalArgumentException ignored) {} - } - view.exportToExcel( new QueryView.ExcelExportConfig(getViewContext().getResponse(), form.getHeaderType()) - .setTemplateOnly(true) - .setInsertColumnsOnly(form.insertColumnsOnly) - .setDocType(fileType) - .setRespectView(respectView) - .setIncludeColumns(form.getIncludeColumns()) - .setExcludeColumns(form.getExcludeColumns()) - .setRenamedColumns(form.getRenameColumnMap()) - .setPrefix((StringUtils.isEmpty(form.getFilenamePrefix()) ? "Import" : form.getFilenamePrefix()) + "_Template") // Issue 48028: Change template file names - ); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportQueryForm extends QueryForm - { - protected ColumnHeaderType _headerType = null; // QueryView will provide a default header type if the user doesn't select one - FieldKey[] excludeColumn; - Map renameColumns = null; - private String sheetName; - - public void setSheetName(String sheetName) - { - this.sheetName = sheetName; - } - - public String getSheetName() - { - return sheetName; - } - - public ColumnHeaderType getHeaderType() - { - return _headerType; - } - - public void setHeaderType(ColumnHeaderType headerType) - { - _headerType = headerType; - } - - public List getExcludeColumns() - { - if (excludeColumn == null || excludeColumn.length == 0) - return Collections.emptyList(); - return Arrays.asList(excludeColumn); - } - - public void setExcludeColumn(FieldKey[] excludeColumn) - { - this.excludeColumn = excludeColumn; - } - - public Map getRenameColumnMap() - { - if (renameColumns != null) - return renameColumns; - - renameColumns = new CaseInsensitiveHashMap<>(); - final String renameParamPrefix = "exportAlias."; - PropertyValue[] pvs = getInitParameters().getPropertyValues(); - for (PropertyValue pv : pvs) - { - String paramName = pv.getName(); - if (!paramName.startsWith(renameParamPrefix) || pv.getValue() == null) - continue; - - renameColumns.put(paramName.substring(renameParamPrefix.length()), (String) pv.getValue()); - } - - return renameColumns; - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportRowsTsvForm extends ExportQueryForm - { - private TSVWriter.DELIM _delim = TSVWriter.DELIM.TAB; - private TSVWriter.QUOTE _quote = TSVWriter.QUOTE.DOUBLE; - - public TSVWriter.DELIM getDelim() - { - return _delim; - } - - public void setDelim(TSVWriter.DELIM delim) - { - _delim = delim; - } - - public TSVWriter.QUOTE getQuote() - { - return _quote; - } - - public void setQuote(TSVWriter.QUOTE quote) - { - _quote = quote; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportRowsTsvAction extends _ExportQuery - { - public ExportRowsTsvAction() - { - setCommandClass(ExportRowsTsvForm.class); - } - - @Override - void _export(ExportRowsTsvForm form, QueryView view) throws Exception - { - view.exportToTsv(getViewContext().getResponse(), form.getDelim(), form.getQuote(), form.getHeaderType(), form.getRenameColumnMap()); - } - } - - - @RequiresNoPermission - @IgnoresTermsOfUse - @Action(ActionType.Export.class) - public static class ExcelWebQueryAction extends ExportRowsTsvAction - { - @Override - public ModelAndView getView(ExportRowsTsvForm form, BindException errors) throws Exception - { - if (!getContainer().hasPermission(getUser(), ReadPermission.class)) - { - if (!getUser().isGuest()) - { - throw new UnauthorizedException(); - } - getViewContext().getResponse().setHeader("WWW-Authenticate", "Basic realm=\"" + LookAndFeelProperties.getInstance(ContainerManager.getRoot()).getDescription() + "\""); - getViewContext().getResponse().setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return null; - } - - // Bug 5610. Excel web queries don't work over SSL if caching is disabled, - // so we need to allow caching so that Excel can read from IE on Windows. - HttpServletResponse response = getViewContext().getResponse(); - // Set the headers to allow the client to cache, but not proxies - ResponseHelper.setPrivate(response); - - QueryView view = form.getQueryView(); - getPageConfig().setTemplate(PageConfig.Template.None); - view.exportToExcelWebQuery(getViewContext().getResponse()); - return null; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExcelWebQueryDefinitionAction extends SimpleViewAction - { - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - getPageConfig().setTemplate(PageConfig.Template.None); - form.getQueryView(); - String queryViewActionURL = form.getQueryViewActionURL(); - ActionURL url; - if (queryViewActionURL != null) - { - url = new ActionURL(queryViewActionURL); - } - else - { - url = getViewContext().cloneActionURL(); - url.setAction(ExcelWebQueryAction.class); - } - getViewContext().getResponse().setContentType("text/x-ms-iqy"); - String filename = FileUtil.makeFileNameWithTimestamp(form.getQueryName(), "iqy"); - ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, filename); - PrintWriter writer = getViewContext().getResponse().getWriter(); - writer.println("WEB"); - writer.println("1"); - writer.println(url.getURIString()); - - QueryService.get().addAuditEvent(getUser(), getContainer(), form.getSchemaName(), form.getQueryName(), url, "Exported to Excel Web Query definition", null); - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - // Trusted analysts who are editors can create and modify queries - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - @Action(ActionType.SelectMetaData.class) - public class MetadataQueryAction extends SimpleViewAction - { - QueryForm _form = null; - - @Override - public ModelAndView getView(QueryForm queryForm, BindException errors) throws Exception - { - String schemaName = queryForm.getSchemaName(); - String queryName = queryForm.getQueryName(); - - _form = queryForm; - - if (schemaName.isEmpty() && (null == queryName || queryName.isEmpty())) - { - throw new NotFoundException("Must provide schemaName and queryName."); - } - - if (schemaName.isEmpty()) - { - throw new NotFoundException("Must provide schemaName."); - } - - if (null == queryName || queryName.isEmpty()) - { - throw new NotFoundException("Must provide queryName."); - } - - if (!queryForm.getQueryDef().isMetadataEditable()) - throw new UnauthorizedException("Query metadata is not editable"); - - if (!queryForm.canEditMetadata()) - throw new UnauthorizedException("You do not have permission to edit the query metadata"); - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("queryMetadataEditor")); - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - var metadataQuery = _form.getQueryDef().getName(); - if (null != metadataQuery) - root.addChild("Edit Metadata: " + _form.getQueryName(), metadataQuery); - else - root.addChild("Edit Metadata: " + _form.getQueryName()); - } - } - - // Uck. Supports the old and new view designer. - protected JSONObject saveCustomView(Container container, QueryDefinition queryDef, - String regionName, String viewName, boolean replaceExisting, - boolean share, boolean inherit, - boolean session, boolean saveFilter, - boolean hidden, JSONObject jsonView, - ActionURL returnUrl, - BindException errors) - { - User owner = getUser(); - boolean canSaveForAllUsers = container.hasPermission(getUser(), EditSharedViewPermission.class); - if (share && canSaveForAllUsers && !session) - { - owner = null; - } - String name = StringUtils.trimToNull(viewName); - - if (name != null && RESERVED_VIEW_NAMES.contains(name.toLowerCase())) - errors.reject(ERROR_MSG, "The grid view name '" + name + "' is not allowed."); - - boolean isHidden = hidden; - CustomView view; - if (owner == null) - view = queryDef.getSharedCustomView(name); - else - view = queryDef.getCustomView(owner, getViewContext().getRequest(), name); - - if (view != null && !replaceExisting && !StringUtils.isEmpty(name)) - errors.reject(ERROR_MSG, "A saved view by the name \"" + viewName + "\" already exists. "); - - // 11179: Allow editing the view if we're saving to session. - // NOTE: Check for session flag first otherwise the call to canEdit() will add errors to the errors collection. - boolean canEdit = view == null || session || view.canEdit(container, errors); - if (errors.hasErrors()) - return null; - - if (canEdit) - { - // Issue 13594: Disallow setting of the customview inherit bit for query views - // that have no available container filter types. Unfortunately, the only way - // to get the container filters is from the QueryView. Ideally, the query def - // would know if it was container filterable or not instead of using the QueryView. - if (inherit && canSaveForAllUsers && !session) - { - UserSchema schema = queryDef.getSchema(); - QueryView queryView = schema.createView(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, queryDef.getName(), errors); - if (queryView != null) - { - Set allowableContainerFilterTypes = queryView.getAllowableContainerFilterTypes(); - if (allowableContainerFilterTypes.size() <= 1) - { - errors.reject(ERROR_MSG, "QueryView doesn't support inherited custom views"); - return null; - } - } - } - - // Create a new view if none exists or the current view is a shared view - // and the user wants to override the shared view with a personal view. - if (view == null || (owner != null && view.isShared())) - { - if (owner == null) - view = queryDef.createSharedCustomView(name); - else - view = queryDef.createCustomView(owner, name); - - if (owner != null && session) - ((CustomViewImpl) view).isSession(true); - view.setIsHidden(hidden); - } - else if (session != view.isSession()) - { - if (session) - { - assert !view.isSession(); - if (owner == null) - { - errors.reject(ERROR_MSG, "Session views can't be saved for all users"); - return null; - } - - // The form is saving to session but the view is in the database. - // Make a copy in case it's a read-only version from an XML file - view = queryDef.createCustomView(owner, name); - ((CustomViewImpl) view).isSession(true); - } - else - { - // Remove the session view and call saveCustomView again to either create a new view or update an existing view. - assert view.isSession(); - boolean success = false; - try - { - view.delete(getUser(), getViewContext().getRequest()); - JSONObject ret = saveCustomView(container, queryDef, regionName, viewName, replaceExisting, share, inherit, session, saveFilter, hidden, jsonView, returnUrl, errors); - success = !errors.hasErrors() && ret != null; - return success ? ret : null; - } - finally - { - if (!success) - { - // dirty the view then save the deleted session view back in session state - view.setName(view.getName()); - view.save(getUser(), getViewContext().getRequest()); - } - } - } - } - - // NOTE: Updating, saving, and deleting the view may throw an exception - CustomViewImpl cview = null; - if (view instanceof EditableCustomView && view.isOverridable()) - { - cview = ((EditableCustomView)view).getEditableViewInfo(owner, session); - } - if (null == cview) - { - throw new IllegalArgumentException("View cannot be edited"); - } - - cview.update(jsonView, saveFilter); - if (canSaveForAllUsers && !session) - { - cview.setCanInherit(inherit); - } - isHidden = view.isHidden(); - cview.setContainer(container); - cview.save(getUser(), getViewContext().getRequest()); - if (owner == null) - { - // New view is shared so delete any previous custom view owned by the user with the same name. - CustomView personalView = queryDef.getCustomView(getUser(), getViewContext().getRequest(), name); - if (personalView != null && !personalView.isShared()) - { - personalView.delete(getUser(), getViewContext().getRequest()); - } - } - } - - if (null == returnUrl) - { - returnUrl = getViewContext().cloneActionURL().setAction(ExecuteQueryAction.class); - } - else - { - returnUrl = returnUrl.clone(); - if (name == null || !canEdit) - { - returnUrl.deleteParameter(regionName + "." + QueryParam.viewName); - } - else if (!isHidden) - { - returnUrl.replaceParameter(regionName + "." + QueryParam.viewName, name); - } - returnUrl.deleteParameter(regionName + "." + QueryParam.ignoreFilter); - if (saveFilter) - { - for (String key : returnUrl.getKeysByPrefix(regionName + ".")) - { - if (isFilterOrSort(regionName, key)) - returnUrl.deleteFilterParameters(key); - } - } - } - - JSONObject ret = new JSONObject(); - ret.put("redirect", returnUrl); - Map viewAsMap = CustomViewUtil.toMap(view, getUser(), true); - try - { - ret.put("view", new JSONObject(viewAsMap, new JSONParserConfiguration().withMaxNestingDepth(10))); - } - catch (JSONException e) - { - LOG.error("Failed to save view: {}", jsonView, e); - } - return ret; - } - - private boolean isFilterOrSort(String dataRegionName, String param) - { - assert param.startsWith(dataRegionName + "."); - String check = param.substring(dataRegionName.length() + 1); - if (check.contains("~")) - return true; - if ("sort".equals(check)) - return true; - if (check.equals("containerFilterName")) - return true; - return false; - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Configure.class) - @JsonInputLimit(100_000) - public class SaveQueryViewsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(SimpleApiJsonForm form, BindException errors) - { - JSONObject json = form.getJsonObject(); - if (json == null) - throw new NotFoundException("Empty request"); - - String schemaName = json.optString(QueryParam.schemaName.toString(), null); - String queryName = json.optString(QueryParam.queryName.toString(), null); - if (schemaName == null || queryName == null) - throw new NotFoundException("schemaName and queryName are required"); - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); - if (schema == null) - throw new NotFoundException("schema not found"); - - QueryDefinition queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), schemaName, queryName); - if (queryDef == null) - queryDef = schema.getQueryDefForTable(queryName); - if (queryDef == null) - throw new NotFoundException("query not found"); - - JSONObject response = new JSONObject(); - response.put(QueryParam.schemaName.toString(), schemaName); - response.put(QueryParam.queryName.toString(), queryName); - JSONArray views = new JSONArray(); - response.put("views", views); - - ActionURL redirect = null; - JSONArray jsonViews = json.getJSONArray("views"); - for (int i = 0; i < jsonViews.length(); i++) - { - final JSONObject jsonView = jsonViews.getJSONObject(i); - String viewName = jsonView.optString("name", null); - if (viewName == null) - throw new NotFoundException("'name' is required all views'"); - - boolean shared = jsonView.optBoolean("shared", false); - boolean replace = jsonView.optBoolean("replace", true); // "replace" was the default before the flag is introduced - boolean inherit = jsonView.optBoolean("inherit", false); - boolean session = jsonView.optBoolean("session", false); - boolean hidden = jsonView.optBoolean("hidden", false); - // Users may save views to a location other than the current container - String containerPath = jsonView.optString("containerPath", getContainer().getPath()); - Container container; - if (inherit) - { - // Only respect this request if it's a view that is inheritable in subfolders - container = ContainerManager.getForPath(containerPath); - } - else - { - // Otherwise, save it in the current container - container = getContainer().getContainerFor(ContainerType.DataType.customQueryViews); - } - - if (container == null) - { - throw new NotFoundException("No such container: " + containerPath); - } - - JSONObject savedView = saveCustomView( - container, queryDef, QueryView.DATAREGIONNAME_DEFAULT, viewName, replace, - shared, inherit, session, true, hidden, jsonView, null, errors); - - if (savedView != null) - { - if (redirect == null) - redirect = (ActionURL)savedView.get("redirect"); - views.put(savedView.getJSONObject("view")); - } - } - - if (redirect != null) - response.put("redirect", redirect); - - if (errors.hasErrors()) - return null; - else - return new ApiSimpleResponse(response); - } - } - - public static class RenameQueryViewForm extends QueryForm - { - private String newName; - - public String getNewName() - { - return newName; - } - - public void setNewName(String newName) - { - this.newName = newName; - } - } - - @RequiresPermission(ReadPermission.class) - public class RenameQueryViewAction extends MutatingApiAction - { - @Override - public ApiResponse execute(RenameQueryViewForm form, BindException errors) - { - CustomView view = form.getCustomView(); - if (view == null) - { - throw new NotFoundException(); - } - - Container container = getContainer(); - User user = getUser(); - - String schemaName = form.getSchemaName(); - String queryName = form.getQueryName(); - if (schemaName == null || queryName == null) - throw new NotFoundException("schemaName and queryName are required"); - - UserSchema schema = QueryService.get().getUserSchema(user, container, schemaName); - if (schema == null) - throw new NotFoundException("schema not found"); - - QueryDefinition queryDef = QueryService.get().getQueryDef(user, container, schemaName, queryName); - if (queryDef == null) - queryDef = schema.getQueryDefForTable(queryName); - if (queryDef == null) - throw new NotFoundException("query not found"); - - renameCustomView(container, queryDef, view, form.getNewName(), errors); - - if (errors.hasErrors()) - return null; - else - return new ApiSimpleResponse("success", true); - } - } - - protected void renameCustomView(Container container, QueryDefinition queryDef, CustomView fromView, String newViewName, BindException errors) - { - if (newViewName != null && RESERVED_VIEW_NAMES.contains(newViewName.toLowerCase())) - errors.reject(ERROR_MSG, "The grid view name '" + newViewName + "' is not allowed."); - - String newName = StringUtils.trimToNull(newViewName); - if (StringUtils.isEmpty(newName)) - errors.reject(ERROR_MSG, "View name cannot be blank."); - - if (errors.hasErrors()) - return; - - User owner = getUser(); - boolean canSaveForAllUsers = container.hasPermission(getUser(), EditSharedViewPermission.class); - - if (!fromView.canEdit(container, errors)) - return; - - if (fromView.isSession()) - { - errors.reject(ERROR_MSG, "Cannot rename a session view."); - return; - } - - CustomView duplicateView = queryDef.getCustomView(owner, getViewContext().getRequest(), newName); - if (duplicateView == null && canSaveForAllUsers) - duplicateView = queryDef.getSharedCustomView(newName); - if (duplicateView != null) - { - // only allow duplicate view name if creating a new private view to shadow an existing shared view - if (!(!fromView.isShared() && duplicateView.isShared())) - { - errors.reject(ERROR_MSG, "Another saved view by the name \"" + newName + "\" already exists. "); - return; - } - } - - fromView.setName(newViewName); - fromView.save(getUser(), getViewContext().getRequest()); - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Configure.class) - public class PropertiesQueryAction extends FormViewAction - { - PropertiesForm _form = null; - private String _queryName; - - @Override - public void validateCommand(PropertiesForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(PropertiesForm form, boolean reshow, BindException errors) - { - // assertQueryExists requires that it be well-formed - // assertQueryExists(form); - QueryDefinition queryDef = form.getQueryDef(); - _form = form; - _form.setDescription(queryDef.getDescription()); - _form.setInheritable(queryDef.canInherit()); - _form.setHidden(queryDef.isHidden()); - setHelpTopic("editQueryProperties"); - _queryName = form.getQueryName(); - - return new JspView<>("/org/labkey/query/view/propertiesQuery.jsp", form, errors); - } - - @Override - public boolean handlePost(PropertiesForm form, BindException errors) throws Exception - { - // assertQueryExists requires that it be well-formed - // assertQueryExists(form); - if (!form.canEdit()) - { - throw new UnauthorizedException(); - } - QueryDefinition queryDef = form.getQueryDef(); - _queryName = form.getQueryName(); - if (!queryDef.getDefinitionContainer().getId().equals(getContainer().getId())) - throw new NotFoundException("Query not found"); - - _form = form; - - if (!StringUtils.isEmpty(form.rename) && !form.rename.equalsIgnoreCase(queryDef.getName())) - { - // issue 17766: check if query or table exist with this name - if (null != QueryManager.get().getQueryDef(getContainer(), form.getSchemaName(), form.rename, true) - || null != form.getSchema().getTable(form.rename,null)) - { - errors.reject(ERROR_MSG, "A query or table with the name \"" + form.rename + "\" already exists."); - return false; - } - - // Issue 40895: update queryName in xml metadata - updateXmlMetadata(queryDef); - queryDef.setName(form.rename); - // update form so getSuccessURL() works - _form = new PropertiesForm(form.getSchemaName(), form.rename); - _form.setViewContext(form.getViewContext()); - _queryName = form.rename; - } - - queryDef.setDescription(form.description); - queryDef.setCanInherit(form.inheritable); - queryDef.setIsHidden(form.hidden); - queryDef.save(getUser(), getContainer()); - return true; - } - - private void updateXmlMetadata(QueryDefinition queryDef) throws XmlException - { - if (null != queryDef.getMetadataXml()) - { - TablesDocument doc = TablesDocument.Factory.parse(queryDef.getMetadataXml()); - if (null != doc) - { - for (TableType tableType : doc.getTables().getTableArray()) - { - if (tableType.getTableName().equalsIgnoreCase(queryDef.getName())) - { - // update tableName in xml - tableType.setTableName(_form.rename); - } - } - XmlOptions xmlOptions = new XmlOptions(); - xmlOptions.setSavePrettyPrint(); - // Don't use an explicit namespace, making the XML much more readable - xmlOptions.setUseDefaultNamespace(); - queryDef.setMetadataXml(doc.xmlText(xmlOptions)); - } - } - } - - @Override - public ActionURL getSuccessURL(PropertiesForm propertiesForm) - { - ActionURL url = new ActionURL(BeginAction.class, propertiesForm.getViewContext().getContainer()); - url.addParameter("schemaName", propertiesForm.getSchemaName()); - if (null != _queryName) - url.addParameter("queryName", _queryName); - return url; - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - root.addChild("Edit query properties"); - } - } - - @ActionNames("truncateTable") - @RequiresPermission(AdminPermission.class) - public static class TruncateTableAction extends MutatingApiAction - { - UserSchema schema; - TableInfo table; - - @Override - public void validateForm(QueryForm form, Errors errors) - { - String schemaName = form.getSchemaName(); - String queryName = form.getQueryName(); - - if (isBlank(schemaName) || isBlank(queryName)) - throw new NotFoundException("schemaName and queryName are required"); - - schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); - if (null == schema) - throw new NotFoundException("The schema '" + schemaName + "' does not exist."); - - table = schema.getTable(queryName, null); - if (null == table) - throw new NotFoundException("The query '" + queryName + "' in the schema '" + schemaName + "' does not exist."); - } - - @Override - public ApiResponse execute(QueryForm form, BindException errors) throws Exception - { - int deletedRows; - QueryUpdateService qus = table.getUpdateService(); - - if (null == qus) - throw new IllegalArgumentException("The query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "' is not truncatable."); - - try (DbScope.Transaction transaction = table.getSchema().getScope().ensureTransaction()) - { - deletedRows = qus.truncateRows(getUser(), getContainer(), null, null); - transaction.commit(); - } - - ApiSimpleResponse response = new ApiSimpleResponse(); - - response.put("success", true); - response.put(BaseSaveRowsAction.PROP_SCHEMA_NAME, form.getSchemaName()); - response.put(BaseSaveRowsAction.PROP_QUERY_NAME, form.getQueryName()); - response.put("deletedRows", deletedRows); - - return response; - } - } - - - @RequiresPermission(DeletePermission.class) - public static class DeleteQueryRowsAction extends FormHandlerAction - { - @Override - public void validateCommand(QueryForm target, Errors errors) - { - } - - @Override - public boolean handlePost(QueryForm form, BindException errors) - { - TableInfo table = form.getQueryView().getTable(); - - if (!table.hasPermission(getUser(), DeletePermission.class)) - { - throw new UnauthorizedException(); - } - - QueryUpdateService updateService = table.getUpdateService(); - if (updateService == null) - throw new UnsupportedOperationException("Unable to delete - no QueryUpdateService registered for " + form.getSchemaName() + "." + form.getQueryName()); - - Set ids = DataRegionSelection.getSelected(form.getViewContext(), null, true); - List pks = table.getPkColumns(); - int numPks = pks.size(); - - //normalize the pks to arrays of correctly-typed objects - List> keyValues = new ArrayList<>(ids.size()); - for (String id : ids) - { - String[] stringValues; - if (numPks > 1) - { - stringValues = id.split(","); - if (stringValues.length != numPks) - throw new IllegalStateException("This table has " + numPks + " primary-key columns, but " + stringValues.length + " primary-key values were provided!"); - } - else - stringValues = new String[]{id}; - - Map rowKeyValues = new CaseInsensitiveHashMap<>(); - for (int idx = 0; idx < numPks; ++idx) - { - ColumnInfo keyColumn = pks.get(idx); - Object keyValue = keyColumn.getJavaClass() == String.class ? stringValues[idx] : ConvertUtils.convert(stringValues[idx], keyColumn.getJavaClass()); - rowKeyValues.put(keyColumn.getName(), keyValue); - } - keyValues.add(rowKeyValues); - } - - DbSchema dbSchema = table.getSchema(); - try - { - dbSchema.getScope().executeWithRetry(tx -> - { - try - { - updateService.deleteRows(getUser(), getContainer(), keyValues, null, null); - } - catch (SQLException x) - { - if (!RuntimeSQLException.isConstraintException(x)) - throw new RuntimeSQLException(x); - errors.reject(ERROR_MSG, getMessage(table.getSchema().getSqlDialect(), x)); - } - catch (DataIntegrityViolationException | OptimisticConflictException e) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - catch (BatchValidationException x) - { - x.addToErrors(errors); - } - catch (Exception x) - { - errors.reject(ERROR_MSG, null == x.getMessage() ? x.toString() : x.getMessage()); - ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), x); - } - // need to throw here to avoid committing tx - if (errors.hasErrors()) - throw new DbScope.RetryPassthroughException(errors); - return true; - }); - } - catch (DbScope.RetryPassthroughException x) - { - if (x.getCause() != errors) - x.throwRuntimeException(); - } - return !errors.hasErrors(); - } - - @Override - public ActionURL getSuccessURL(QueryForm form) - { - return form.getReturnActionURL(); - } - } - - @RequiresPermission(ReadPermission.class) - public static class DetailsQueryRowAction extends UserSchemaAction - { - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - - if (_schema != null && _table != null) - { - if (_table.hasPermission(getUser(), UpdatePermission.class)) - { - StringExpression updateExpr = _form.getQueryDef().urlExpr(QueryAction.updateQueryRow, _schema.getContainer()); - if (updateExpr != null) - { - String url = updateExpr.eval(tableForm.getTypedValues()); - if (url != null) - { - ActionURL updateUrl = new ActionURL(url); - ActionButton editButton = new ActionButton("Edit", updateUrl); - bb.add(editButton); - } - } - } - - - ActionURL gridUrl; - if (_form.getReturnActionURL() != null) - { - // If we have a specific return URL requested, use that - gridUrl = _form.getReturnActionURL(); - } - else - { - // Otherwise go back to the default grid view - gridUrl = _schema.urlFor(QueryAction.executeQuery, _form.getQueryDef()); - } - if (gridUrl != null) - { - ActionButton gridButton = new ActionButton("Show Grid", gridUrl); - bb.add(gridButton); - } - } - - DetailsView detailsView = new DetailsView(tableForm); - detailsView.setFrame(WebPartView.FrameType.PORTAL); - detailsView.getDataRegion().setButtonBar(bb); - - VBox view = new VBox(detailsView); - - DetailsURL detailsURL = QueryService.get().getAuditDetailsURL(getUser(), getContainer(), _table); - - if (detailsURL != null) - { - String url = detailsURL.eval(tableForm.getTypedValues()); - if (url != null) - { - ActionURL auditURL = new ActionURL(url); - - QueryView historyView = QueryUpdateAuditProvider.createDetailsQueryView(getViewContext(), - auditURL.getParameter(QueryParam.schemaName), - auditURL.getParameter(QueryParam.queryName), - auditURL.getParameter("keyValue"), errors); - - if (null != historyView) - { - historyView.setFrame(WebPartView.FrameType.PORTAL); - historyView.setTitle("History"); - - view.addView(historyView); - } - } - } - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) - { - return false; - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Details"); - } - } - - @RequiresPermission(InsertPermission.class) - public static class InsertQueryRowAction extends UserSchemaAction - { - @Override - public BindException bindParameters(PropertyValues m) throws Exception - { - BindException bind = super.bindParameters(m); - - // what is going on with UserSchemaAction and form binding? Why doesn't successUrl bind? - QueryUpdateForm form = (QueryUpdateForm)bind.getTarget(); - if (null == form.getSuccessUrl() && null != m.getPropertyValue(ActionURL.Param.successUrl.name())) - form.setSuccessUrl(new ReturnURLString(m.getPropertyValue(ActionURL.Param.successUrl.name()).getValue().toString())); - return bind; - } - - Map insertedRow = null; - - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Insert Row"); - - InsertView view = new InsertView(tableForm, errors); - view.getDataRegion().setButtonBar(createSubmitCancelButtonBar(tableForm)); - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) - { - List> list = doInsertUpdate(tableForm, errors, true); - if (null != list && list.size() == 1) - insertedRow = list.get(0); - return 0 == errors.getErrorCount(); - } - - /** - * NOTE: UserSchemaAction.addNavTrail() uses this method getSuccessURL() for the nav trail link (form==null). - * It is used for where to go on success, and also as a "back" link in the nav trail - * If there is a setSuccessUrl specified, we will use that for successful submit - */ - @Override - public ActionURL getSuccessURL(QueryUpdateForm form) - { - if (null == form) - return super.getSuccessURL(null); - - String str = null; - if (form.getSuccessUrl() != null) - str = form.getSuccessUrl().toString(); - if (isBlank(str)) - str = form.getReturnUrl(); - - if ("details.view".equals(str)) - { - if (null == insertedRow) - return super.getSuccessURL(form); - StringExpression se = form.getTable().getDetailsURL(null, getContainer()); - if (null == se) - return super.getSuccessURL(form); - str = se.eval(insertedRow); - } - try - { - if (!isBlank(str)) - return new ActionURL(str); - } - catch (IllegalArgumentException x) - { - // pass - } - return super.getSuccessURL(form); - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Insert " + _table.getName()); - } - } - - @RequiresPermission(UpdatePermission.class) - public static class UpdateQueryRowAction extends UserSchemaAction - { - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - ButtonBar bb = createSubmitCancelButtonBar(tableForm); - UpdateView view = new UpdateView(tableForm, errors); - view.getDataRegion().setButtonBar(bb); - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) throws Exception - { - doInsertUpdate(tableForm, errors, false); - return 0 == errors.getErrorCount(); - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Edit " + _table.getName()); - } - } - - @RequiresPermission(UpdatePermission.class) - public static class UpdateQueryRowsAction extends UpdateQueryRowAction - { - @Override - public ModelAndView handleRequest(QueryUpdateForm tableForm, BindException errors) throws Exception - { - tableForm.setBulkUpdate(true); - return super.handleRequest(tableForm, errors); - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) throws Exception - { - boolean ret; - - if (tableForm.isDataSubmit()) - { - ret = super.handlePost(tableForm, errors); - if (ret) - DataRegionSelection.clearAll(getViewContext(), null); // in case we altered primary keys, see issue #35055 - return ret; - } - - return false; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Edit Multiple " + _table.getName()); - } - } - - // alias - public static class DeleteAction extends DeleteQueryRowsAction - { - } - - public abstract static class QueryViewAction extends SimpleViewAction - { - QueryForm _form; - QueryView _queryView; - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class APIQueryForm extends ContainerFilterQueryForm - { - private Integer _start; - private Integer _limit; - private boolean _includeDetailsColumn = false; - private boolean _includeUpdateColumn = false; - private boolean _includeTotalCount = true; - private boolean _includeStyle = false; - private boolean _includeDisplayValues = false; - private boolean _minimalColumns = true; - private boolean _includeMetadata = true; - - public Integer getStart() - { - return _start; - } - - public void setStart(Integer start) - { - _start = start; - } - - public Integer getLimit() - { - return _limit; - } - - public void setLimit(Integer limit) - { - _limit = limit; - } - - public boolean isIncludeTotalCount() - { - return _includeTotalCount; - } - - public void setIncludeTotalCount(boolean includeTotalCount) - { - _includeTotalCount = includeTotalCount; - } - - public boolean isIncludeStyle() - { - return _includeStyle; - } - - public void setIncludeStyle(boolean includeStyle) - { - _includeStyle = includeStyle; - } - - public boolean isIncludeDetailsColumn() - { - return _includeDetailsColumn; - } - - public void setIncludeDetailsColumn(boolean includeDetailsColumn) - { - _includeDetailsColumn = includeDetailsColumn; - } - - public boolean isIncludeUpdateColumn() - { - return _includeUpdateColumn; - } - - public void setIncludeUpdateColumn(boolean includeUpdateColumn) - { - _includeUpdateColumn = includeUpdateColumn; - } - - public boolean isIncludeDisplayValues() - { - return _includeDisplayValues; - } - - public void setIncludeDisplayValues(boolean includeDisplayValues) - { - _includeDisplayValues = includeDisplayValues; - } - - public boolean isMinimalColumns() - { - return _minimalColumns; - } - - public void setMinimalColumns(boolean minimalColumns) - { - _minimalColumns = minimalColumns; - } - - public boolean isIncludeMetadata() - { - return _includeMetadata; - } - - public void setIncludeMetadata(boolean includeMetadata) - { - _includeMetadata = includeMetadata; - } - - @Override - protected QuerySettings createQuerySettings(UserSchema schema) - { - QuerySettings results = super.createQuerySettings(schema); - - // See dataintegration/202: The java client api / remote ETL calls selectRows with showRows=all. We need to test _initParameters to properly read this - boolean missingShowRows = null == getViewContext().getRequest().getParameter(getDataRegionName() + "." + QueryParam.showRows) && null == _initParameters.getPropertyValue(getDataRegionName() + "." + QueryParam.showRows); - if (null == getLimit() && !results.isMaxRowsSet() && missingShowRows) - { - results.setShowRows(ShowRows.PAGINATED); - results.setMaxRows(DEFAULT_API_MAX_ROWS); - } - - if (getLimit() != null) - { - results.setShowRows(ShowRows.PAGINATED); - results.setMaxRows(getLimit()); - } - if (getStart() != null) - results.setOffset(getStart()); - - return results; - } - } - - public static final int DEFAULT_API_MAX_ROWS = 100000; - - @CSRF(CSRF.Method.NONE) // No need for CSRF token --- this is a non-mutating action that supports POST to allow for large payloads, see #36056 - @ActionNames("selectRows, getQuery") - @RequiresPermission(ReadPermission.class) - @ApiVersion(9.1) - @Action(ActionType.SelectData.class) - public class SelectRowsAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(APIQueryForm form, BindException errors) - { - // Issue 12233: add implicit maxRows=100k when using client API - QueryView view = form.getQueryView(); - - view.setShowPagination(form.isIncludeTotalCount()); - - //if viewName was specified, ensure that it was actually found and used - //QueryView.create() will happily ignore an invalid view name and just return the default view - if (null != StringUtils.trimToNull(form.getViewName()) && - null == view.getQueryDef().getCustomView(getUser(), getViewContext().getRequest(), form.getViewName())) - { - throw new NotFoundException("The requested view '" + form.getViewName() + "' does not exist for this user."); - } - - TableInfo t = view.getTable(); - if (null == t) - { - List qpes = view.getParseErrors(); - if (!qpes.isEmpty()) - throw qpes.get(0); - throw new NotFoundException(form.getQueryName()); - } - - boolean isEditable = isQueryEditable(view.getTable()); - boolean metaDataOnly = form.getQuerySettings().getMaxRows() == 0; - boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; - boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; - - ApiQueryResponse response; - - // 13.2 introduced the getData API action, a condensed response wire format, and a js wrapper to consume the wire format. Support this as an option for legacy APIs. - if (getRequestedApiVersion() >= 13.2) - { - ReportingApiQueryResponse fancyResponse = new ReportingApiQueryResponse(view, isEditable, true, view.getQueryDef().getName(), form.getQuerySettings().getOffset(), null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); - fancyResponse.arrayMultiValueColumns(arrayMultiValueColumns); - fancyResponse.includeFormattedValue(includeFormattedValue); - response = fancyResponse; - } - //if requested version is >= 9.1, use the extended api query response - else if (getRequestedApiVersion() >= 9.1) - { - response = new ExtendedApiQueryResponse(view, isEditable, true, - form.getSchemaName(), form.getQueryName(), form.getQuerySettings().getOffset(), null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); - } - else - { - response = new ApiQueryResponse(view, isEditable, true, - form.getSchemaName(), form.getQueryName(), form.getQuerySettings().getOffset(), null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), - form.isIncludeDisplayValues(), form.isIncludeMetadata()); - } - response.includeStyle(form.isIncludeStyle()); - - // Issues 29515 and 32269 - force key and other non-requested columns to be sent back, but only if the client has - // requested minimal columns, as we now do for ExtJS stores - if (form.isMinimalColumns()) - { - // Be sure to use the settings from the view, as it may have swapped it out with a customized version. - // See issue 38747. - response.setColumnFilter(view.getSettings().getFieldKeys()); - } - - return response; - } - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public static class GetDataAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws Exception - { - ObjectMapper mapper = JsonUtil.createDefaultMapper(); - mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - JSONObject object = form.getJsonObject(); - if (object == null) - { - object = new JSONObject(); - } - DataRequest builder = mapper.readValue(object.toString(), DataRequest.class); - - return builder.render(getViewContext(), errors); - } - } - - protected boolean isQueryEditable(TableInfo table) - { - if (!getContainer().hasPermission("isQueryEditable", getUser(), DeletePermission.class)) - return false; - QueryUpdateService updateService = null; - try - { - updateService = table.getUpdateService(); - } - catch(Exception ignore) {} - return null != table && null != updateService; - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExecuteSqlForm extends APIQueryForm - { - private String _sql; - private Integer _maxRows; - private Integer _offset; - private boolean _saveInSession; - - public String getSql() - { - return _sql; - } - - public void setSql(String sql) - { - _sql = PageFlowUtil.wafDecode(StringUtils.trim(sql)); - } - - public Integer getMaxRows() - { - return _maxRows; - } - - public void setMaxRows(Integer maxRows) - { - _maxRows = maxRows; - } - - public Integer getOffset() - { - return _offset; - } - - public void setOffset(Integer offset) - { - _offset = offset; - } - - @Override - public void setLimit(Integer limit) - { - _maxRows = limit; - } - - @Override - public void setStart(Integer start) - { - _offset = start; - } - - public boolean isSaveInSession() - { - return _saveInSession; - } - - public void setSaveInSession(boolean saveInSession) - { - _saveInSession = saveInSession; - } - - @Override - public String getQueryName() - { - // ExecuteSqlAction doesn't allow setting query name parameter. - return null; - } - - @Override - public void setQueryName(String name) - { - // ExecuteSqlAction doesn't allow setting query name parameter. - } - } - - @CSRF(CSRF.Method.NONE) // No need for CSRF token --- this is a non-mutating action that supports POST to allow for large payloads, see #36056 - @RequiresPermission(ReadPermission.class) - @ApiVersion(9.1) - @Action(ActionType.SelectData.class) - public class ExecuteSqlAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(ExecuteSqlForm form, BindException errors) - { - form.ensureSchemaExists(); - - String schemaName = StringUtils.trimToNull(form.getQuerySettings().getSchemaName()); - if (null == schemaName) - throw new IllegalArgumentException("No value was supplied for the required parameter 'schemaName'."); - String sql = form.getSql(); - if (StringUtils.isBlank(sql)) - throw new IllegalArgumentException("No value was supplied for the required parameter 'sql'."); - - //create a temp query settings object initialized with the posted LabKey SQL - //this will provide a temporary QueryDefinition to Query - QuerySettings settings = form.getQuerySettings(); - if (form.isSaveInSession()) - { - HttpSession session = getViewContext().getSession(); - if (session == null) - throw new IllegalStateException("Session required"); - - QueryDefinition def = QueryService.get().saveSessionQuery(getViewContext(), getContainer(), schemaName, sql); - settings.setDataRegionName("executeSql"); - settings.setQueryName(def.getName()); - } - else - { - settings = new TempQuerySettings(getViewContext(), sql, settings); - } - - //need to explicitly turn off various UI options that will try to refer to the - //current URL and query string - settings.setAllowChooseView(false); - settings.setAllowCustomizeView(false); - - // Issue 12233: add implicit maxRows=100k when using client API - settings.setShowRows(ShowRows.PAGINATED); - settings.setMaxRows(DEFAULT_API_MAX_ROWS); - - // 16961: ExecuteSql API without maxRows parameter defaults to returning 100 rows - //apply optional settings (maxRows, offset) - boolean metaDataOnly = false; - if (null != form.getMaxRows() && (form.getMaxRows() >= 0 || form.getMaxRows() == Table.ALL_ROWS)) - { - settings.setMaxRows(form.getMaxRows()); - metaDataOnly = Table.NO_ROWS == form.getMaxRows(); - } - - int offset = 0; - if (null != form.getOffset()) - { - settings.setOffset(form.getOffset().longValue()); - offset = form.getOffset(); - } - - //build a query view using the schema and settings - QueryView view = new QueryView(form.getSchema(), settings, errors); - view.setShowRecordSelectors(false); - view.setShowExportButtons(false); - view.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - view.setShowPagination(form.isIncludeTotalCount()); - - TableInfo t = view.getTable(); - boolean isEditable = null != t && isQueryEditable(view.getTable()); - boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; - boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; - - ApiQueryResponse response; - - // 13.2 introduced the getData API action, a condensed response wire format, and a js wrapper to consume the wire format. Support this as an option for legacy APIs. - if (getRequestedApiVersion() >= 13.2) - { - ReportingApiQueryResponse fancyResponse = new ReportingApiQueryResponse(view, isEditable, false, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); - fancyResponse.arrayMultiValueColumns(arrayMultiValueColumns); - fancyResponse.includeFormattedValue(includeFormattedValue); - response = fancyResponse; - } - else if (getRequestedApiVersion() >= 9.1) - { - response = new ExtendedApiQueryResponse(view, isEditable, - false, schemaName, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); - } - else - { - response = new ApiQueryResponse(view, isEditable, - false, schemaName, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), - form.isIncludeDisplayValues()); - } - response.includeStyle(form.isIncludeStyle()); - - return response; - } - } - - public static class ContainerFilterQueryForm extends QueryForm - { - private String _containerFilter; - - public String getContainerFilter() - { - return _containerFilter; - } - - public void setContainerFilter(String containerFilter) - { - _containerFilter = containerFilter; - } - - @Override - protected QuerySettings createQuerySettings(UserSchema schema) - { - var result = super.createQuerySettings(schema); - if (getContainerFilter() != null) - { - // If the user specified an incorrect filter, throw an IllegalArgumentException - try - { - ContainerFilter.Type containerFilterType = ContainerFilter.Type.valueOf(getContainerFilter()); - result.setContainerFilterName(containerFilterType.name()); - } - catch (IllegalArgumentException e) - { - // Remove bogus value from error message, Issue 45567 - throw new IllegalArgumentException("'containerFilter' parameter is not valid"); - } - } - return result; - } - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public class SelectDistinctAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(ContainerFilterQueryForm form, BindException errors) throws Exception - { - TableInfo table = form.getQueryView().getTable(); - if (null == table) - throw new NotFoundException(); - SqlSelector sqlSelector = getDistinctSql(table, form, errors); - - if (errors.hasErrors() || null == sqlSelector) - return null; - - ApiResponseWriter writer = new ApiJsonWriter(getViewContext().getResponse()); - - try (ResultSet rs = sqlSelector.getResultSet()) - { - writer.startResponse(); - writer.writeProperty("schemaName", form.getSchemaName()); - writer.writeProperty("queryName", form.getQueryName()); - writer.startList("values"); - - while (rs.next()) - { - writer.writeListEntry(rs.getObject(1)); - } - } - catch (SQLException x) - { - throw new RuntimeSQLException(x); - } - catch (DataAccessException x) // Spring error translator can return various subclasses of this - { - throw new RuntimeException(x); - } - writer.endList(); - writer.endResponse(); - - return null; - } - - @Nullable - private SqlSelector getDistinctSql(TableInfo table, ContainerFilterQueryForm form, BindException errors) - { - QuerySettings settings = form.getQuerySettings(); - QueryService service = QueryService.get(); - - if (null == getViewContext().getRequest().getParameter(QueryParam.maxRows.toString())) - { - settings.setMaxRows(DEFAULT_API_MAX_ROWS); - } - else - { - try - { - int maxRows = Integer.parseInt(getViewContext().getRequest().getParameter(QueryParam.maxRows.toString())); - settings.setMaxRows(maxRows); - } - catch (NumberFormatException e) - { - // Standard exception message, Issue 45567 - QuerySettings.throwParameterParseException(QueryParam.maxRows); - } - } - - List fieldKeys = settings.getFieldKeys(); - if (null == fieldKeys || fieldKeys.size() != 1) - { - errors.reject(ERROR_MSG, "Select Distinct requires that only one column be requested."); - return null; - } - Map columns = service.getColumns(table, fieldKeys); - if (columns.size() != 1) - { - errors.reject(ERROR_MSG, "Select Distinct requires that only one column be requested."); - return null; - } - - ColumnInfo col = columns.get(settings.getFieldKeys().get(0)); - if (col == null) - { - errors.reject(ERROR_MSG, "\"" + settings.getFieldKeys().get(0).getName() + "\" is not a valid column."); - return null; - } - - try - { - SimpleFilter filter = getFilterFromQueryForm(form); - - // Strip out filters on columns that don't exist - issue 21669 - service.ensureRequiredColumns(table, columns.values(), filter, null, new HashSet<>()); - QueryLogging queryLogging = new QueryLogging(); - QueryService.SelectBuilder builder = service.getSelectBuilder(table) - .columns(columns.values()) - .filter(filter) - .queryLogging(queryLogging) - .distinct(true); - SQLFragment selectSql = builder.buildSqlFragment(); - - // TODO: queryLogging.isShouldAudit() is always false at this point. - // The only place that seems to set this is ComplianceQueryLoggingProfileListener.queryInvoked() - if (queryLogging.isShouldAudit() && null != queryLogging.getExceptionToThrowIfLoggingIsEnabled()) - { - // this is probably a more helpful message - errors.reject(ERROR_MSG, "Cannot choose values from a column that requires logging."); - return null; - } - - // Regenerate the column since the alias may have changed after call to getSelectSQL() - columns = service.getColumns(table, settings.getFieldKeys()); - var colGetAgain = columns.get(settings.getFieldKeys().get(0)); - // I don't believe the above comment, so here's an assert - assert(colGetAgain.getAlias().equals(col.getAlias())); - - SQLFragment sql = new SQLFragment("SELECT ").appendIdentifier(col.getAlias()).append(" AS value FROM ("); - sql.append(selectSql); - sql.append(") S ORDER BY value"); - - sql = table.getSqlDialect().limitRows(sql, settings.getMaxRows()); - - // 18875: Support Parameterized queries in Select Distinct - Map _namedParameters = settings.getQueryParameters(); - - service.bindNamedParameters(sql, _namedParameters); - service.validateNamedParameters(sql); - - return new SqlSelector(table.getSchema().getScope(), sql, queryLogging); - } - catch (ConversionException | QueryService.NamedParameterNotProvided e) - { - errors.reject(ERROR_MSG, e.getMessage()); - return null; - } - } - } - - private SimpleFilter getFilterFromQueryForm(QueryForm form) - { - QuerySettings settings = form.getQuerySettings(); - SimpleFilter filter = null; - - // 21032: Respect 'ignoreFilter' - if (settings != null && !settings.getIgnoreUserFilter()) - { - // Attach any URL-based filters. This would apply to 'filterArray' from the JavaScript API. - filter = new SimpleFilter(settings.getBaseFilter()); - - String dataRegionName = form.getDataRegionName(); - if (StringUtils.trimToNull(dataRegionName) == null) - dataRegionName = QueryView.DATAREGIONNAME_DEFAULT; - - // Support for 'viewName' - CustomView view = settings.getCustomView(getViewContext(), form.getQueryDef()); - if (null != view && view.hasFilterOrSort() && !settings.getIgnoreViewFilter()) - { - ActionURL url = new ActionURL(SelectDistinctAction.class, getContainer()); - view.applyFilterAndSortToURL(url, dataRegionName); - filter.addAllClauses(new SimpleFilter(url, dataRegionName)); - } - - filter.addUrlFilters(settings.getSortFilterURL(), dataRegionName, Collections.emptyList(), getUser(), getContainer()); - } - - return filter; - } - - @RequiresPermission(ReadPermission.class) - public class GetColumnSummaryStatsAction extends ReadOnlyApiAction - { - private FieldKey _colFieldKey; - - @Override - public void validateForm(QueryForm form, Errors errors) - { - QuerySettings settings = form.getQuerySettings(); - List fieldKeys = settings != null ? settings.getFieldKeys() : null; - if (null == fieldKeys || fieldKeys.size() != 1) - errors.reject(ERROR_MSG, "GetColumnSummaryStats requires that only one column be requested."); - else - _colFieldKey = fieldKeys.get(0); - } - - @Override - public ApiResponse execute(QueryForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - QueryView view = form.getQueryView(); - DisplayColumn displayColumn = null; - - for (DisplayColumn dc : view.getDisplayColumns()) - { - if (dc.getColumnInfo() != null && _colFieldKey.equals(dc.getColumnInfo().getFieldKey())) - { - displayColumn = dc; - break; - } - } - - if (displayColumn != null && displayColumn.getColumnInfo() != null) - { - // get the map of the analytics providers to their relevant aggregates and add the information to the response - Map> analyticsProviders = new LinkedHashMap<>(); - Set colAggregates = new HashSet<>(); - for (ColumnAnalyticsProvider analyticsProvider : displayColumn.getAnalyticsProviders()) - { - if (analyticsProvider instanceof BaseAggregatesAnalyticsProvider baseAggProvider) - { - Map props = new HashMap<>(); - props.put("label", baseAggProvider.getLabel()); - - List aggregateNames = new ArrayList<>(); - for (Aggregate aggregate : AnalyticsProviderItem.createAggregates(baseAggProvider, _colFieldKey, null)) - { - aggregateNames.add(aggregate.getType().getName()); - colAggregates.add(aggregate); - } - props.put("aggregates", aggregateNames); - - analyticsProviders.put(baseAggProvider.getName(), props); - } - } - - // get the filter set from the queryform and verify that they resolve - SimpleFilter filter = getFilterFromQueryForm(form); - if (filter != null) - { - Map resolvedCols = QueryService.get().getColumns(view.getTable(), filter.getAllFieldKeys()); - for (FieldKey filterFieldKey : filter.getAllFieldKeys()) - { - if (!resolvedCols.containsKey(filterFieldKey)) - filter.deleteConditions(filterFieldKey); - } - } - - // query the table/view for the aggregate results - Collection columns = Collections.singleton(displayColumn.getColumnInfo()); - TableSelector selector = new TableSelector(view.getTable(), columns, filter, null).setNamedParameters(form.getQuerySettings().getQueryParameters()); - Map> aggResults = selector.getAggregates(new ArrayList<>(colAggregates)); - - // create a response object mapping the analytics providers to their relevant aggregate results - Map> aggregateResults = new HashMap<>(); - if (aggResults.containsKey(_colFieldKey.toString())) - { - for (Aggregate.Result r : aggResults.get(_colFieldKey.toString())) - { - Map props = new HashMap<>(); - Aggregate.Type type = r.getAggregate().getType(); - props.put("label", type.getFullLabel()); - props.put("description", type.getDescription()); - props.put("value", r.getFormattedValue(displayColumn, getContainer()).value()); - aggregateResults.put(type.getName(), props); - } - - response.put("success", true); - response.put("analyticsProviders", analyticsProviders); - response.put("aggregateResults", aggregateResults); - } - else - { - response.put("success", false); - response.put("message", "Unable to get aggregate results for " + _colFieldKey); - } - } - else - { - response.put("success", false); - response.put("message", "Unable to find ColumnInfo for " + _colFieldKey); - } - - return response; - } - } - - @RequiresPermission(ReadPermission.class) - public class ImportAction extends AbstractQueryImportAction - { - private QueryForm _form; - - @Override - protected void initRequest(QueryForm form) throws ServletException - { - _form = form; - - _insertOption = form.getInsertOption(); - QueryDefinition query = form.getQueryDef(); - List qpe = new ArrayList<>(); - TableInfo t = query.getTable(form.getSchema(), qpe, true); - if (!qpe.isEmpty()) - throw qpe.get(0); - if (null != t) - setTarget(t); - _auditBehaviorType = form.getAuditBehavior(); - _auditUserComment = form.getAuditUserComment(); - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - initRequest(form); - return super.getDefaultImportView(form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - var executeQuery = _form.urlFor(QueryAction.executeQuery); - if (null == executeQuery) - root.addChild(_form.getQueryName()); - else - root.addChild(_form.getQueryName(), executeQuery); - root.addChild("Import Data"); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportSqlForm - { - private String _sql; - private String _schemaName; - private String _containerFilter; - private String _format = "excel"; - - public String getSql() - { - return _sql; - } - - public void setSql(String sql) - { - _sql = PageFlowUtil.wafDecode(sql); - } - - public String getSchemaName() - { - return _schemaName; - } - - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public String getContainerFilter() - { - return _containerFilter; - } - - public void setContainerFilter(String containerFilter) - { - _containerFilter = containerFilter; - } - - public String getFormat() - { - return _format; - } - - public void setFormat(String format) - { - _format = format; - } - } - - @RequiresPermission(ReadPermission.class) - @ApiVersion(9.2) - @Action(ActionType.Export.class) - public static class ExportSqlAction extends ExportAction - { - @Override - public void export(ExportSqlForm form, HttpServletResponse response, BindException errors) throws IOException, ExportException - { - String schemaName = StringUtils.trimToNull(form.getSchemaName()); - if (null == schemaName) - throw new NotFoundException("No value was supplied for the required parameter 'schemaName'"); - String sql = StringUtils.trimToNull(form.getSql()); - if (null == sql) - throw new NotFoundException("No value was supplied for the required parameter 'sql'"); - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); - - if (null == schema) - throw new NotFoundException("Schema '" + schemaName + "' not found in this folder"); - - //create a temp query settings object initialized with the posted LabKey SQL - //this will provide a temporary QueryDefinition to Query - TempQuerySettings settings = new TempQuerySettings(getViewContext(), sql); - - //need to explicitly turn off various UI options that will try to refer to the - //current URL and query string - settings.setAllowChooseView(false); - settings.setAllowCustomizeView(false); - - //return all rows - settings.setShowRows(ShowRows.ALL); - - //add container filter if supplied - if (form.getContainerFilter() != null && !form.getContainerFilter().isEmpty()) - { - ContainerFilter.Type containerFilterType = - ContainerFilter.Type.valueOf(form.getContainerFilter()); - settings.setContainerFilterName(containerFilterType.name()); - } - - //build a query view using the schema and settings - QueryView view = new QueryView(schema, settings, errors); - view.setShowRecordSelectors(false); - view.setShowExportButtons(false); - view.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - - //export it - ResponseHelper.setPrivate(response); - response.setHeader("X-Robots-Tag", "noindex"); - - if ("excel".equalsIgnoreCase(form.getFormat())) - view.exportToExcel(response); - else if ("tsv".equalsIgnoreCase(form.getFormat())) - view.exportToTsv(response); - else - errors.reject(null, "Invalid format specified; must be 'excel' or 'tsv'"); - - for (QueryException qe : view.getParseErrors()) - errors.reject(null, qe.getMessage()); - - if (errors.hasErrors()) - throw new ExportException(new SimpleErrorView(errors, false)); - } - } - - public static class ApiSaveRowsForm extends SimpleApiJsonForm - { - } - - private enum CommandType - { - insert(InsertPermission.class, QueryService.AuditAction.INSERT) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException - { - BatchValidationException errors = new BatchValidationException(); - List> insertedRows = qus.insertRows(user, container, rows, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - // Issue 42519: Submitter role not able to insert - // as per the definition of submitter, should allow insert without read - if (qus.hasPermission(user, ReadPermission.class) && shouldReselect(configParameters)) - { - return qus.getRows(user, container, insertedRows); - } - else - { - return insertedRows; - } - } - }, - insertWithKeys(InsertPermission.class, QueryService.AuditAction.INSERT) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException - { - List> newRows = new ArrayList<>(); - List> oldKeys = new ArrayList<>(); - for (Map row : rows) - { - //issue 13719: use CaseInsensitiveHashMaps. Also allow either values or oldKeys to be null - CaseInsensitiveHashMap newMap = row.get(SaveRowsAction.PROP_VALUES) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_VALUES)).toMap()) : new CaseInsensitiveHashMap<>(); - newRows.add(newMap); - - CaseInsensitiveHashMap oldMap = row.get(SaveRowsAction.PROP_OLD_KEYS) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_OLD_KEYS)).toMap()) : new CaseInsensitiveHashMap<>(); - oldKeys.add(oldMap); - } - BatchValidationException errors = new BatchValidationException(); - List> updatedRows = qus.insertRows(user, container, newRows, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - // Issue 42519: Submitter role not able to insert - // as per the definition of submitter, should allow insert without read - if (qus.hasPermission(user, ReadPermission.class) && shouldReselect(configParameters)) - { - updatedRows = qus.getRows(user, container, updatedRows); - } - List> results = new ArrayList<>(); - for (int i = 0; i < updatedRows.size(); i++) - { - Map result = new HashMap<>(); - result.put(SaveRowsAction.PROP_VALUES, updatedRows.get(i)); - result.put(SaveRowsAction.PROP_OLD_KEYS, oldKeys.get(i)); - results.add(result); - } - return results; - } - }, - importRows(InsertPermission.class, QueryService.AuditAction.INSERT) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, BatchValidationException - { - BatchValidationException errors = new BatchValidationException(); - DataIteratorBuilder it = new ListofMapsDataIterator.Builder(rows.get(0).keySet(), rows); - qus.importRows(user, container, it, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - return Collections.emptyList(); - } - }, - moveRows(MoveEntitiesPermission.class, QueryService.AuditAction.UPDATE) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException - { - BatchValidationException errors = new BatchValidationException(); - - Container targetContainer = (Container) configParameters.get(QueryUpdateService.ConfigParameters.TargetContainer); - Map updatedCounts = qus.moveRows(user, container, targetContainer, rows, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - return Collections.singletonList(updatedCounts); - } - }, - update(UpdatePermission.class, QueryService.AuditAction.UPDATE) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException - { - BatchValidationException errors = new BatchValidationException(); - List> updatedRows = qus.updateRows(user, container, rows, null, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - return shouldReselect(configParameters) ? qus.getRows(user, container, updatedRows) : updatedRows; - } - }, - updateChangingKeys(UpdatePermission.class, QueryService.AuditAction.UPDATE) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException - { - List> newRows = new ArrayList<>(); - List> oldKeys = new ArrayList<>(); - for (Map row : rows) - { - // issue 13719: use CaseInsensitiveHashMaps. Also allow either values or oldKeys to be null. - // this should never happen on an update, but we will let it fail later with a better error message instead of the NPE here - CaseInsensitiveHashMap newMap = row.get(SaveRowsAction.PROP_VALUES) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_VALUES)).toMap()) : new CaseInsensitiveHashMap<>(); - newRows.add(newMap); - - CaseInsensitiveHashMap oldMap = row.get(SaveRowsAction.PROP_OLD_KEYS) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_OLD_KEYS)).toMap()) : new CaseInsensitiveHashMap<>(); - oldKeys.add(oldMap); - } - BatchValidationException errors = new BatchValidationException(); - List> updatedRows = qus.updateRows(user, container, newRows, oldKeys, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - if (shouldReselect(configParameters)) - updatedRows = qus.getRows(user, container, updatedRows); - List> results = new ArrayList<>(); - for (int i = 0; i < updatedRows.size(); i++) - { - Map result = new HashMap<>(); - result.put(SaveRowsAction.PROP_VALUES, updatedRows.get(i)); - result.put(SaveRowsAction.PROP_OLD_KEYS, oldKeys.get(i)); - results.add(result); - } - return results; - } - }, - delete(DeletePermission.class, QueryService.AuditAction.DELETE) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException - { - return qus.deleteRows(user, container, rows, configParameters, extraContext); - } - }; - - private final Class _permission; - private final QueryService.AuditAction _auditAction; - - CommandType(Class permission, QueryService.AuditAction auditAction) - { - _permission = permission; - _auditAction = auditAction; - } - - public Class getPermission() - { - return _permission; - } - - public QueryService.AuditAction getAuditAction() - { - return _auditAction; - } - - public static boolean shouldReselect(Map configParameters) - { - if (configParameters == null || !configParameters.containsKey(QueryUpdateService.ConfigParameters.SkipReselectRows)) - return true; - - return Boolean.TRUE != configParameters.get(QueryUpdateService.ConfigParameters.SkipReselectRows); - } - - public abstract List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException; - } - - /** - * Base action class for insert/update/delete actions - */ - protected abstract static class BaseSaveRowsAction
extends MutatingApiAction - { - public static final String PROP_SCHEMA_NAME = "schemaName"; - public static final String PROP_QUERY_NAME = "queryName"; - public static final String PROP_CONTAINER_PATH = "containerPath"; - public static final String PROP_TARGET_CONTAINER_PATH = "targetContainerPath"; - public static final String PROP_COMMAND = "command"; - public static final String PROP_ROWS = "rows"; - - private JSONObject _json; - - @Override - public void validateForm(FORM apiSaveRowsForm, Errors errors) - { - _json = apiSaveRowsForm.getJsonObject(); - - // if the POST was done using FormData, the apiSaveRowsForm would not have bound the json data, so - // we'll instead look for that data in the request param directly - if (_json == null && getViewContext().getRequest() != null && getViewContext().getRequest().getParameter("json") != null) - _json = new JSONObject(getViewContext().getRequest().getParameter("json")); - } - - protected JSONObject getJsonObject() - { - return _json; - } - - protected Container getContainerForCommand(JSONObject json) - { - return getContainerForCommand(json, PROP_CONTAINER_PATH, getContainer()); - } - - protected Container getContainerForCommand(JSONObject json, String containerPathProp, @Nullable Container defaultContainer) - { - Container container; - String containerPath = StringUtils.trimToNull(json.optString(containerPathProp)); - if (containerPath == null) - { - if (defaultContainer != null) - container = defaultContainer; - else - throw new IllegalArgumentException(containerPathProp + " is required but was not provided."); - } - else - { - container = ContainerManager.getForPath(containerPath); - if (container == null) - { - throw new IllegalArgumentException("Unknown container: " + containerPath); - } - } - - // Issue 21850: Verify that the user has at least some sort of basic access to the container. We'll check for more downstream - if (!container.hasPermission(getUser(), ReadPermission.class) && - !container.hasPermission(getUser(), DeletePermission.class) && - !container.hasPermission(getUser(), InsertPermission.class) && - !container.hasPermission(getUser(), UpdatePermission.class)) - { - throw new UnauthorizedException(); - } - - return container; - } - - protected String getTargetContainerProp() - { - JSONObject json = getJsonObject(); - return json.optString(PROP_TARGET_CONTAINER_PATH, null); - } - - protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors) throws Exception - { - return executeJson(json, commandType, allowTransaction, errors, false); - } - - protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors, boolean isNestedTransaction) throws Exception - { - return executeJson(json, commandType, allowTransaction, errors, isNestedTransaction, null); - } - - protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors, boolean isNestedTransaction, @Nullable Integer commandIndex) throws Exception - { - JSONObject response = new JSONObject(); - Container container = getContainerForCommand(json); - User user = getUser(); - - if (json == null) - throw new ValidationException("Empty request"); - - JSONArray rows; - try - { - rows = json.getJSONArray(PROP_ROWS); - if (rows.isEmpty()) - throw new ValidationException("No '" + PROP_ROWS + "' array supplied."); - } - catch (JSONException x) - { - throw new ValidationException("No '" + PROP_ROWS + "' array supplied."); - } - - String schemaName = json.getString(PROP_SCHEMA_NAME); - String queryName = json.getString(PROP_QUERY_NAME); - TableInfo table = getTableInfo(container, user, schemaName, queryName); - - if (!table.hasPermission(user, commandType.getPermission())) - throw new UnauthorizedException(); - - if (commandType != CommandType.insert && table.getPkColumns().isEmpty()) - throw new IllegalArgumentException("The table '" + table.getPublicSchemaName() + "." + - table.getPublicName() + "' cannot be updated because it has no primary key defined!"); - - QueryUpdateService qus = table.getUpdateService(); - if (null == qus) - throw new IllegalArgumentException("The query '" + queryName + "' in the schema '" + schemaName + - "' is not updatable via the HTTP-based APIs."); - - int rowsAffected = 0; - - List> rowsToProcess = new ArrayList<>(); - - // NOTE RowMapFactory is faster, but for update it's important to preserve missing v explicit NULL values - // Do we need to support some sort of UNDEFINED and NULL instance of MvFieldWrapper? - RowMapFactory f = null; - if (commandType == CommandType.insert || commandType == CommandType.insertWithKeys || commandType == CommandType.delete) - f = new RowMapFactory<>(); - CaseInsensitiveHashMap referenceCasing = new CaseInsensitiveHashMap<>(); - - for (int idx = 0; idx < rows.length(); ++idx) - { - JSONObject jsonObj; - try - { - jsonObj = rows.getJSONObject(idx); - } - catch (JSONException x) - { - throw new IllegalArgumentException("rows[" + idx + "] is not an object."); - } - if (null != jsonObj) - { - Map rowMap = null == f ? new CaseInsensitiveHashMap<>(new HashMap<>(), referenceCasing) : f.getRowMap(); - // Use shallow copy since jsonObj.toMap() will translate contained JSONObjects into Maps, which we don't want - boolean conflictingCasing = JsonUtil.fillMapShallow(jsonObj, rowMap); - if (conflictingCasing) - { - // Issue 52616 - LOG.error("Row contained conflicting casing for key names in the incoming row: {}", jsonObj); - } - if (allowRowAttachments()) - addRowAttachments(table, rowMap, idx, commandIndex); - - rowsToProcess.add(rowMap); - rowsAffected++; - } - } - - Map extraContext = json.has("extraContext") ? json.getJSONObject("extraContext").toMap() : new CaseInsensitiveHashMap<>(); - - Map configParameters = new HashMap<>(); - - // Check first if the audit behavior has been defined for the table either in code or through XML. - // If not defined there, check for the audit behavior defined in the action form (json). - AuditBehaviorType behaviorType = table.getEffectiveAuditBehavior(json.optString("auditBehavior", null)); - if (behaviorType != null) - { - configParameters.put(DetailedAuditLogDataIterator.AuditConfigs.AuditBehavior, behaviorType); - String auditComment = json.optString("auditUserComment", null); - if (!StringUtils.isEmpty(auditComment)) - configParameters.put(DetailedAuditLogDataIterator.AuditConfigs.AuditUserComment, auditComment); - } - - boolean skipReselectRows = json.optBoolean("skipReselectRows", false); - if (skipReselectRows) - configParameters.put(QueryUpdateService.ConfigParameters.SkipReselectRows, true); - - if (getTargetContainerProp() != null) - { - Container targetContainer = getContainerForCommand(json, PROP_TARGET_CONTAINER_PATH, null); - configParameters.put(QueryUpdateService.ConfigParameters.TargetContainer, targetContainer); - } - - //set up the response, providing the schema name, query name, and operation - //so that the client can sort out which request this response belongs to - //(clients often submit these async) - response.put(PROP_SCHEMA_NAME, schemaName); - response.put(PROP_QUERY_NAME, queryName); - response.put("command", commandType.name()); - response.put("containerPath", container.getPath()); - - //we will transact operations by default, but the user may - //override this by sending a "transacted" property set to false - // 11741: A transaction may already be active if we're trying to - // insert/update/delete from within a transformation/validation script. - boolean transacted = allowTransaction && json.optBoolean("transacted", true); - TransactionAuditProvider.TransactionAuditEvent auditEvent = null; - try (DbScope.Transaction transaction = transacted ? table.getSchema().getScope().ensureTransaction() : NO_OP_TRANSACTION) - { - if (behaviorType != null && behaviorType != AuditBehaviorType.NONE) - { - DbScope.Transaction auditTransaction = !transacted && isNestedTransaction ? table.getSchema().getScope().getCurrentTransaction() : transaction; - if (auditTransaction == null) - auditTransaction = NO_OP_TRANSACTION; - - if (auditTransaction.getAuditEvent() != null) - auditEvent = auditTransaction.getAuditEvent(); - else - { - auditEvent = AbstractQueryUpdateService.createTransactionAuditEvent(container, commandType.getAuditAction()); - AbstractQueryUpdateService.addTransactionAuditEvent(auditTransaction, getUser(), auditEvent); - } - } - - QueryService.get().setEnvironment(QueryService.Environment.CONTAINER, container); - List> responseRows = - commandType.saveRows(qus, rowsToProcess, getUser(), container, configParameters, extraContext); - if (auditEvent != null) - auditEvent.addComment(commandType.getAuditAction(), responseRows.size()); - - if (commandType == CommandType.moveRows) - { - // moveRows returns a single map of updateCounts - response.put("updateCounts", responseRows.get(0)); - } - else if (commandType != CommandType.importRows) - { - response.put("rows", responseRows.stream() - .map(JsonUtil::toMapPreserveNonFinite) - .map(JsonUtil::toJsonPreserveNulls) - .collect(LabKeyCollectors.toJSONArray())); - } - - // if there is any provenance information, save it here - ProvenanceService svc = ProvenanceService.get(); - if (json.has("provenance")) - { - JSONObject provenanceJSON = json.getJSONObject("provenance"); - ProvenanceRecordingParams params = svc.createRecordingParams(getViewContext(), provenanceJSON, ProvenanceService.ADD_RECORDING); - RecordedAction action = svc.createRecordedAction(getViewContext(), params); - if (action != null && params.getRecordingId() != null) - { - // check for any row level provenance information - if (json.has("rows")) - { - Object rowObject = json.get("rows"); - if (rowObject instanceof JSONArray jsonArray) - { - // we need to match any provenance object inputs to the object outputs from the response rows, this typically would - // be the row lsid but it configurable in the provenance recording params - // - List> provenanceMap = svc.createProvenanceMapFromRows(getViewContext(), params, jsonArray, responseRows); - if (!provenanceMap.isEmpty()) - { - action.getProvenanceMap().addAll(provenanceMap); - } - svc.addRecordingStep(getViewContext().getRequest(), params.getRecordingId(), action); - } - else - { - errors.reject(SpringActionController.ERROR_MSG, "Unable to process provenance information, the rows object was not an array"); - } - } - } - } - transaction.commit(); - } - catch (OptimisticConflictException e) - { - //issue 13967: provide better message for OptimisticConflictException - errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); - } - catch (QueryUpdateServiceException | ConversionException | DuplicateKeyException | DataIntegrityViolationException e) - { - //Issue 14294: improve handling of ConversionException (and DuplicateKeyException (Issue 28037), and DataIntegrity (uniqueness) (Issue 22779) - errors.reject(SpringActionController.ERROR_MSG, e.getMessage() == null ? e.toString() : e.getMessage()); - } - catch (BatchValidationException e) - { - if (isSuccessOnValidationError()) - { - response.put("errors", createResponseWriter().toJSON(e)); - } - else - { - ExceptionUtil.decorateException(e, ExceptionUtil.ExceptionInfo.SkipMothershipLogging, "true", true); - throw e; - } - } - if (auditEvent != null) - { - response.put("transactionAuditId", auditEvent.getRowId()); - response.put("reselectRowCount", auditEvent.hasMultiActions()); - } - - response.put("rowsAffected", rowsAffected); - - return response; - } - - protected boolean allowRowAttachments() - { - return false; - } - - private void addRowAttachments(TableInfo tableInfo, Map rowMap, int rowIndex, @Nullable Integer commandIndex) - { - if (getFileMap() != null) - { - for (Map.Entry fileEntry : getFileMap().entrySet()) - { - // Allow for the fileMap key to include the row index, and optionally command index, for defining - // which row to attach this file to - String fullKey = fileEntry.getKey(); - String fieldKey = fullKey; - // Issue 52827: Cannot attach a file if the field name contains :: - // use lastIndexOf instead of split to get the proper parts - int lastDelimIndex = fullKey.lastIndexOf(ROW_ATTACHMENT_INDEX_DELIM); - if (lastDelimIndex > -1) - { - String fieldKeyExcludeIndex = fullKey.substring(0, lastDelimIndex); - String fieldRowIndex = fullKey.substring(lastDelimIndex + ROW_ATTACHMENT_INDEX_DELIM.length()); - if (!fieldRowIndex.equals(rowIndex+"")) continue; - - if (commandIndex == null) - { - // Single command, so we're parsing file names in the format of: FileField::0 - fieldKey = fieldKeyExcludeIndex; - } - else - { - // Multi-command, so we're parsing file names in the format of: FileField::0::1 - int subDelimIndex = fieldKeyExcludeIndex.lastIndexOf(ROW_ATTACHMENT_INDEX_DELIM); - if (subDelimIndex > -1) - { - fieldKey = fieldKeyExcludeIndex.substring(0, subDelimIndex); - String fieldCommandIndex = fieldKeyExcludeIndex.substring(subDelimIndex + ROW_ATTACHMENT_INDEX_DELIM.length()); - if (!fieldCommandIndex.equals(commandIndex+"")) - continue; - } - else - continue; - } - } - - SpringAttachmentFile file = new SpringAttachmentFile(fileEntry.getValue()); - rowMap.put(fieldKey, file.isEmpty() ? null : file); - } - } - - for (ColumnInfo col : tableInfo.getColumns()) - DataIteratorUtil.MatchType.multiPartFormData.updateRowMap(col, rowMap); - } - - protected boolean isSuccessOnValidationError() - { - return getRequestedApiVersion() >= 13.2; - } - - @NotNull - protected TableInfo getTableInfo(Container container, User user, String schemaName, String queryName) - { - if (null == schemaName || null == queryName) - throw new IllegalArgumentException("You must supply a schemaName and queryName!"); - - UserSchema schema = QueryService.get().getUserSchema(user, container, schemaName); - if (null == schema) - throw new IllegalArgumentException("The schema '" + schemaName + "' does not exist."); - - TableInfo table = schema.getTableForInsert(queryName); - if (table == null) - throw new IllegalArgumentException("The query '" + queryName + "' in the schema '" + schemaName + "' does not exist."); - return table; - } - } - - // Issue: 20522 - require read access to the action but executeJson will check for update privileges from the table - // - @RequiresPermission(ReadPermission.class) //will check below - @ApiVersion(8.3) - public static class UpdateRowsAction extends BaseSaveRowsAction - { - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - JSONObject response = executeJson(getJsonObject(), CommandType.update, true, errors); - if (response == null || errors.hasErrors()) - return null; - return new ApiSimpleResponse(response); - } - - @Override - protected boolean allowRowAttachments() - { - return true; - } - } - - @RequiresAnyOf({ReadPermission.class, InsertPermission.class}) //will check below - @ApiVersion(8.3) - public static class InsertRowsAction extends BaseSaveRowsAction - { - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - JSONObject response = executeJson(getJsonObject(), CommandType.insert, true, errors); - if (response == null || errors.hasErrors()) - return null; - - return new ApiSimpleResponse(response); - } - - @Override - protected boolean allowRowAttachments() - { - return true; - } - } - - @RequiresPermission(ReadPermission.class) //will check below - @ApiVersion(8.3) - public static class ImportRowsAction extends BaseSaveRowsAction - { - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - JSONObject response = executeJson(getJsonObject(), CommandType.importRows, true, errors); - if (response == null || errors.hasErrors()) - return null; - return new ApiSimpleResponse(response); - } - } - - @ActionNames("deleteRows, delRows") - @RequiresPermission(ReadPermission.class) //will check below - @ApiVersion(8.3) - public static class DeleteRowsAction extends BaseSaveRowsAction - { - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - JSONObject response = executeJson(getJsonObject(), CommandType.delete, true, errors); - if (response == null || errors.hasErrors()) - return null; - return new ApiSimpleResponse(response); - } - } - - @RequiresPermission(ReadPermission.class) //will check below - public static class MoveRowsAction extends BaseSaveRowsAction - { - private Container _targetContainer; - - @Override - public void validateForm(MoveRowsForm form, Errors errors) - { - super.validateForm(form, errors); - - JSONObject json = getJsonObject(); - if (json == null) - { - errors.reject(ERROR_GENERIC, "Empty request"); - } - else - { - // Since we are moving between containers, we know we have product folders enabled - if (getContainer().getProject().getAuditCommentsRequired() && StringUtils.isBlank(json.optString("auditUserComment"))) - errors.reject(ERROR_GENERIC, "A reason for the move of data is required."); - else - { - String queryName = json.optString(PROP_QUERY_NAME, null); - _targetContainer = ContainerManager.getMoveTargetContainer(queryName, getContainer(), getUser(), getTargetContainerProp(), errors); - } - } - } - - @Override - public ApiResponse execute(MoveRowsForm form, BindException errors) throws Exception - { - // if JSON does not have rows array, see if they were provided via selectionKey - if (!getJsonObject().has(PROP_ROWS)) - setRowsFromSelectionKey(form); - - JSONObject response = executeJson(getJsonObject(), CommandType.moveRows, true, errors); - if (response == null || errors.hasErrors()) - return null; - - updateSelections(form); - - response.put("success", true); - response.put("containerPath", _targetContainer.getPath()); - return new ApiSimpleResponse(response); - } - - private void updateSelections(MoveRowsForm form) - { - String selectionKey = form.getDataRegionSelectionKey(); - if (selectionKey != null) - { - Set rowIds = form.getIds(getViewContext(), false) - .stream().map(Object::toString).collect(Collectors.toSet()); - DataRegionSelection.setSelected(getViewContext(), selectionKey, rowIds, false); - - // if moving entities from a type, the selections from other selectionKeys in that container will - // possibly be holding onto invalid keys after the move, so clear them based on the containerPath and selectionKey suffix - String[] keyParts = selectionKey.split("|"); - if (keyParts.length > 1) - DataRegionSelection.clearRelatedByContainerPath(getViewContext(), keyParts[keyParts.length - 1]); - } - } - - private void setRowsFromSelectionKey(MoveRowsForm form) - { - Set rowIds = form.getIds(getViewContext(), false); // handle clear of selectionKey after move complete - - // convert rowIds to a JSONArray of JSONObjects with a single property "RowId" - JSONArray rows = new JSONArray(); - for (Long rowId : rowIds) - { - JSONObject row = new JSONObject(); - row.put("RowId", rowId); - rows.put(row); - } - getJsonObject().put(PROP_ROWS, rows); - } - } - - public static class MoveRowsForm extends ApiSaveRowsForm - { - private String _dataRegionSelectionKey; - private boolean _useSnapshotSelection; - - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - public void setDataRegionSelectionKey(String dataRegionSelectionKey) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - } - - public boolean isUseSnapshotSelection() - { - return _useSnapshotSelection; - } - - public void setUseSnapshotSelection(boolean useSnapshotSelection) - { - _useSnapshotSelection = useSnapshotSelection; - } - - @Override - public void bindJson(JSONObject json) - { - super.bindJson(json); - _dataRegionSelectionKey = json.optString("dataRegionSelectionKey", null); - _useSnapshotSelection = json.optBoolean("useSnapshotSelection", false); - } - - public Set getIds(ViewContext context, boolean clear) - { - if (_useSnapshotSelection) - return new HashSet<>(DataRegionSelection.getSnapshotSelectedIntegers(context, getDataRegionSelectionKey())); - else - return DataRegionSelection.getSelectedIntegers(context, getDataRegionSelectionKey(), clear); - } - } - - @RequiresNoPermission //will check below - public static class SaveRowsAction extends BaseSaveRowsAction - { - public static final String PROP_VALUES = "values"; - public static final String PROP_OLD_KEYS = "oldKeys"; - - @Override - protected boolean isFailure(BindException errors) - { - return !isSuccessOnValidationError() && super.isFailure(errors); - } - - @Override - protected boolean allowRowAttachments() - { - return true; - } - - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - // Issue 21850: Verify that the user has at least some sort of basic access to the container. We'll check for more - // specific permissions later once we've figured out exactly what they're trying to do. This helps us - // give a better HTTP response code when they're trying to access a resource that's not available to guests - if (!getContainer().hasPermission(getUser(), ReadPermission.class) && - !getContainer().hasPermission(getUser(), DeletePermission.class) && - !getContainer().hasPermission(getUser(), InsertPermission.class) && - !getContainer().hasPermission(getUser(), UpdatePermission.class)) - { - throw new UnauthorizedException(); - } - - JSONObject json = getJsonObject(); - if (json == null) - throw new IllegalArgumentException("Empty request"); - - JSONArray commands = json.optJSONArray("commands"); - if (commands == null || commands.isEmpty()) - { - throw new NotFoundException("Empty request"); - } - - boolean validateOnly = json.optBoolean("validateOnly", false); - // If we are going to validate and not commit, we need to be sure we're transacted as well. Otherwise, - // respect the client's request. - boolean transacted = validateOnly || json.optBoolean("transacted", true); - - // Keep track of whether we end up committing or not - boolean committed = false; - - DbScope scope = null; - if (transacted) - { - for (int i = 0; i < commands.length(); i++) - { - JSONObject commandJSON = commands.getJSONObject(i); - String schemaName = commandJSON.getString(PROP_SCHEMA_NAME); - String queryName = commandJSON.getString(PROP_QUERY_NAME); - Container container = getContainerForCommand(commandJSON); - TableInfo tableInfo = getTableInfo(container, getUser(), schemaName, queryName); - if (scope == null) - { - scope = tableInfo.getSchema().getScope(); - } - else if (scope != tableInfo.getSchema().getScope()) - { - throw new IllegalArgumentException("All queries must be from the same source database"); - } - } - assert scope != null; - } - - JSONArray resultArray = new JSONArray(); - JSONObject extraContext = json.optJSONObject("extraContext"); - - int startingErrorIndex = 0; - int errorCount = 0; - // 11741: A transaction may already be active if we're trying to - // insert/update/delete from within a transformation/validation script. - - try (DbScope.Transaction transaction = transacted ? scope.ensureTransaction() : NO_OP_TRANSACTION) - { - for (int i = 0; i < commands.length(); i++) - { - JSONObject commandObject = commands.getJSONObject(i); - String commandName = commandObject.getString(PROP_COMMAND); - if (commandName == null) - { - throw new ApiUsageException(PROP_COMMAND + " is required but was missing"); - } - CommandType command = CommandType.valueOf(commandName); - - // Copy the top-level 'extraContext' and merge in the command-level extraContext. - Map commandExtraContext = new HashMap<>(); - if (extraContext != null) - commandExtraContext.putAll(extraContext.toMap()); - if (commandObject.has("extraContext")) - { - commandExtraContext.putAll(commandObject.getJSONObject("extraContext").toMap()); - } - commandObject.put("extraContext", commandExtraContext); - - JSONObject commandResponse = executeJson(commandObject, command, !transacted, errors, transacted, i); - // Bail out immediately if we're going to return a failure-type response message - if (commandResponse == null || (errors.hasErrors() && !isSuccessOnValidationError())) - return null; - - //this would be populated in executeJson when a BatchValidationException is thrown - if (commandResponse.has("errors")) - { - errorCount += commandResponse.getJSONObject("errors").getInt("errorCount"); - } - - // If we encountered errors with this particular command and the client requested that don't treat - // the whole request as a failure (non-200 HTTP status code), stash the errors for this particular - // command in its response section. - // NOTE: executeJson should handle and serialize BatchValidationException - // these errors upstream - if (errors.getErrorCount() > startingErrorIndex && isSuccessOnValidationError()) - { - commandResponse.put("errors", ApiResponseWriter.convertToJSON(errors, startingErrorIndex).getValue()); - startingErrorIndex = errors.getErrorCount(); - } - - resultArray.put(commandResponse); - } - - // Don't commit if we had errors or if the client requested that we only validate (and not commit) - if (!errors.hasErrors() && !validateOnly && errorCount == 0) - { - transaction.commit(); - committed = true; - } - } - - errorCount += errors.getErrorCount(); - JSONObject result = new JSONObject(); - result.put("result", resultArray); - result.put("committed", committed); - result.put("errorCount", errorCount); - - return new ApiSimpleResponse(result); - } - } - - @RequiresPermission(ReadPermission.class) - public static class ApiTestAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/query/view/apitest.jsp"); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("API Test"); - } - } - - - @RequiresPermission(AdminPermission.class) - public static class AdminAction extends SimpleViewAction - { - @SuppressWarnings("UnusedDeclaration") - public AdminAction() - { - } - - public AdminAction(ViewContext ctx) - { - setViewContext(ctx); - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - setHelpTopic("externalSchemas"); - return new JspView<>("/org/labkey/query/view/admin.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Schema Administration", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ResetRemoteConnectionsForm - { - private boolean _reset; - - public boolean isReset() - { - return _reset; - } - - public void setReset(boolean reset) - { - _reset = reset; - } - } - - - @RequiresPermission(AdminPermission.class) - public static class ManageRemoteConnectionsAction extends FormViewAction - { - @Override - public void validateCommand(ResetRemoteConnectionsForm target, Errors errors) {} - - @Override - public boolean handlePost(ResetRemoteConnectionsForm form, BindException errors) - { - if (form.isReset()) - { - PropertyManager.getEncryptedStore().deletePropertySet(getContainer(), RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY); - } - return true; - } - - @Override - public URLHelper getSuccessURL(ResetRemoteConnectionsForm queryForm) - { - return new ActionURL(ManageRemoteConnectionsAction.class, getContainer()); - } - - @Override - public ModelAndView getView(ResetRemoteConnectionsForm queryForm, boolean reshow, BindException errors) - { - Map connectionMap; - try - { - // if the encrypted property store is configured but no values have yet been set, and empty map is returned - connectionMap = PropertyManager.getEncryptedStore().getProperties(getContainer(), RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY); - } - catch (Exception e) - { - connectionMap = null; // render the failure page - } - setHelpTopic("remoteConnection"); - return new JspView<>("/org/labkey/query/view/manageRemoteConnections.jsp", connectionMap, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Manage Remote Connections", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - private abstract static class BaseInsertExternalSchemaAction, T extends AbstractExternalSchemaDef> extends FormViewAction - { - protected BaseInsertExternalSchemaAction(Class commandClass) - { - super(commandClass); - } - - @Override - public void validateCommand(F form, Errors errors) - { - form.validate(errors); - } - - @Override - public boolean handlePost(F form, BindException errors) throws Exception - { - try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) - { - form.doInsert(); - auditSchemaAdminActivity(form.getBean(), "created", getContainer(), getUser()); - QueryManager.get().updateExternalSchemas(getContainer()); - - t.commit(); - } - catch (RuntimeSQLException e) - { - if (e.isConstraintException()) - { - errors.reject(ERROR_MSG, "A schema by that name is already defined in this folder"); - return false; - } - - throw e; - } - - return true; - } - - @Override - public ActionURL getSuccessURL(F form) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new AdminAction(getViewContext()).addNavTrail(root); - root.addChild("Define Schema", new ActionURL(getClass(), getContainer())); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class InsertLinkedSchemaAction extends BaseInsertExternalSchemaAction - { - public InsertLinkedSchemaAction() - { - super(LinkedSchemaForm.class); - } - - @Override - public ModelAndView getView(LinkedSchemaForm form, boolean reshow, BindException errors) - { - setHelpTopic("filterSchema"); - return new JspView<>("/org/labkey/query/view/linkedSchema.jsp", new LinkedSchemaBean(getContainer(), form.getBean(), true), errors); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class InsertExternalSchemaAction extends BaseInsertExternalSchemaAction - { - public InsertExternalSchemaAction() - { - super(ExternalSchemaForm.class); - } - - @Override - public ModelAndView getView(ExternalSchemaForm form, boolean reshow, BindException errors) - { - setHelpTopic("externalSchemas"); - return new JspView<>("/org/labkey/query/view/externalSchema.jsp", new ExternalSchemaBean(getContainer(), form.getBean(), true), errors); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class DeleteSchemaAction extends ConfirmAction - { - @Override - public String getConfirmText() - { - return "Delete"; - } - - @Override - public ModelAndView getConfirmView(SchemaForm form, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Delete Schema"); - - AbstractExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), AbstractExternalSchemaDef.class); - if (def == null) - throw new NotFoundException(); - - String schemaName = isBlank(def.getUserSchemaName()) ? "this schema" : "the schema '" + def.getUserSchemaName() + "'"; - return new HtmlView(HtmlString.of("Are you sure you want to delete " + schemaName + "? The tables and queries defined in this schema will no longer be accessible.")); - } - - @Override - public boolean handlePost(SchemaForm form, BindException errors) - { - AbstractExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), AbstractExternalSchemaDef.class); - if (def == null) - throw new NotFoundException(); - - try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) - { - auditSchemaAdminActivity(def, "deleted", getContainer(), getUser()); - QueryManager.get().delete(def); - t.commit(); - } - return true; - } - - @Override - public void validateCommand(SchemaForm form, Errors errors) - { - } - - @Override - @NotNull - public ActionURL getSuccessURL(SchemaForm form) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); - } - } - - private static void auditSchemaAdminActivity(AbstractExternalSchemaDef def, String action, Container container, User user) - { - String comment = StringUtils.capitalize(def.getSchemaType().toString()) + " schema '" + def.getUserSchemaName() + "' " + action; - AuditTypeEvent event = new AuditTypeEvent(ContainerAuditProvider.CONTAINER_AUDIT_EVENT, container, comment); - AuditLogService.get().addEvent(user, event); - } - - - private abstract static class BaseEditSchemaAction, T extends AbstractExternalSchemaDef> extends FormViewAction - { - protected BaseEditSchemaAction(Class commandClass) - { - super(commandClass); - } - - @Override - public void validateCommand(F form, Errors errors) - { - form.validate(errors); - } - - @Nullable - protected abstract T getCurrent(int externalSchemaId); - - @NotNull - protected T getDef(F form, boolean reshow) - { - T def; - Container defContainer; - - if (reshow) - { - def = form.getBean(); - T current = getCurrent(def.getExternalSchemaId()); - if (current == null) - throw new NotFoundException(); - - defContainer = current.lookupContainer(); - } - else - { - form.refreshFromDb(); - if (!form.isDataLoaded()) - throw new NotFoundException(); - - def = form.getBean(); - if (def == null) - throw new NotFoundException(); - - defContainer = def.lookupContainer(); - } - - if (!getContainer().equals(defContainer)) - throw new UnauthorizedException(); - - return def; - } - - @Override - public boolean handlePost(F form, BindException errors) throws Exception - { - T def = form.getBean(); - T fromDb = getCurrent(def.getExternalSchemaId()); - - // Unauthorized if def in the database reports a different container - if (!getContainer().equals(fromDb.lookupContainer())) - throw new UnauthorizedException(); - - try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) - { - form.doUpdate(); - auditSchemaAdminActivity(def, "updated", getContainer(), getUser()); - QueryManager.get().updateExternalSchemas(getContainer()); - t.commit(); - } - catch (RuntimeSQLException e) - { - if (e.isConstraintException()) - { - errors.reject(ERROR_MSG, "A schema by that name is already defined in this folder"); - return false; - } - - throw e; - } - return true; - } - - @Override - public ActionURL getSuccessURL(F externalSchemaForm) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new AdminAction(getViewContext()).addNavTrail(root); - root.addChild("Edit Schema", new ActionURL(getClass(), getContainer())); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class EditLinkedSchemaAction extends BaseEditSchemaAction - { - public EditLinkedSchemaAction() - { - super(LinkedSchemaForm.class); - } - - @Nullable - @Override - protected LinkedSchemaDef getCurrent(int externalId) - { - return QueryManager.get().getLinkedSchemaDef(getContainer(), externalId); - } - - @Override - public ModelAndView getView(LinkedSchemaForm form, boolean reshow, BindException errors) - { - LinkedSchemaDef def = getDef(form, reshow); - - setHelpTopic("filterSchema"); - return new JspView<>("/org/labkey/query/view/linkedSchema.jsp", new LinkedSchemaBean(getContainer(), def, false), errors); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class EditExternalSchemaAction extends BaseEditSchemaAction - { - public EditExternalSchemaAction() - { - super(ExternalSchemaForm.class); - } - - @Nullable - @Override - protected ExternalSchemaDef getCurrent(int externalId) - { - return QueryManager.get().getExternalSchemaDef(getContainer(), externalId); - } - - @Override - public ModelAndView getView(ExternalSchemaForm form, boolean reshow, BindException errors) - { - ExternalSchemaDef def = getDef(form, reshow); - - setHelpTopic("externalSchemas"); - return new JspView<>("/org/labkey/query/view/externalSchema.jsp", new ExternalSchemaBean(getContainer(), def, false), errors); - } - } - - - public static class DataSourceInfo - { - public final String sourceName; - public final String displayName; - public final boolean editable; - - public DataSourceInfo(DbScope scope) - { - this(scope.getDataSourceName(), scope.getDisplayName(), scope.getSqlDialect().isEditable()); - } - - public DataSourceInfo(Container c) - { - this(c.getId(), c.getName(), false); - } - - public DataSourceInfo(String sourceName, String displayName, boolean editable) - { - this.sourceName = sourceName; - this.displayName = displayName; - this.editable = editable; - } - - @Override - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - DataSourceInfo that = (DataSourceInfo) o; - return sourceName != null ? sourceName.equals(that.sourceName) : that.sourceName == null; - } - - @Override - public int hashCode() - { - return sourceName != null ? sourceName.hashCode() : 0; - } - } - - public static abstract class BaseExternalSchemaBean - { - protected final Container _c; - protected final T _def; - protected final boolean _insert; - protected final Map _help = new HashMap<>(); - - public BaseExternalSchemaBean(Container c, T def, boolean insert) - { - _c = c; - _def = def; - _insert = insert; - - TableInfo ti = QueryManager.get().getTableInfoExternalSchema(); - - ti.getColumns() - .stream() - .filter(ci -> null != ci.getDescription()) - .forEach(ci -> _help.put(ci.getName(), ci.getDescription())); - } - - public abstract DataSourceInfo getInitialSource(); - - public T getSchemaDef() - { - return _def; - } - - public boolean isInsert() - { - return _insert; - } - - public ActionURL getReturnURL() - { - return new ActionURL(AdminAction.class, _c); - } - - public ActionURL getDeleteURL() - { - return new QueryUrlsImpl().urlDeleteSchema(_c, _def); - } - - public String getHelpHTML(String fieldName) - { - return _help.get(fieldName); - } - } - - public static class LinkedSchemaBean extends BaseExternalSchemaBean - { - public LinkedSchemaBean(Container c, LinkedSchemaDef def, boolean insert) - { - super(c, def, insert); - } - - @Override - public DataSourceInfo getInitialSource() - { - Container sourceContainer = getInitialContainer(); - return new DataSourceInfo(sourceContainer); - } - - private @NotNull Container getInitialContainer() - { - LinkedSchemaDef def = getSchemaDef(); - Container sourceContainer = def.lookupSourceContainer(); - if (sourceContainer == null) - sourceContainer = def.lookupContainer(); - if (sourceContainer == null) - sourceContainer = _c; - return sourceContainer; - } - } - - public static class ExternalSchemaBean extends BaseExternalSchemaBean - { - protected final Map> _sourcesAndSchemas = new LinkedHashMap<>(); - protected final Map> _sourcesAndSchemasIncludingSystem = new LinkedHashMap<>(); - - public ExternalSchemaBean(Container c, ExternalSchemaDef def, boolean insert) - { - super(c, def, insert); - initSources(); - } - - public Collection getSources() - { - return _sourcesAndSchemas.keySet(); - } - - public Collection getSchemaNames(DataSourceInfo source, boolean includeSystem) - { - if (includeSystem) - return _sourcesAndSchemasIncludingSystem.get(source); - else - return _sourcesAndSchemas.get(source); - } - - @Override - public DataSourceInfo getInitialSource() - { - ExternalSchemaDef def = getSchemaDef(); - DbScope scope = def.lookupDbScope(); - if (scope == null) - scope = DbScope.getLabKeyScope(); - return new DataSourceInfo(scope); - } - - protected void initSources() - { - ModuleLoader moduleLoader = ModuleLoader.getInstance(); - - for (DbScope scope : DbScope.getDbScopes()) - { - SqlDialect dialect = scope.getSqlDialect(); - - Collection schemaNames = new LinkedList<>(); - Collection schemaNamesIncludingSystem = new LinkedList<>(); - - for (String schemaName : scope.getSchemaNames()) - { - schemaNamesIncludingSystem.add(schemaName); - - if (dialect.isSystemSchema(schemaName)) - continue; - - if (null != moduleLoader.getModule(scope, schemaName)) - continue; - - schemaNames.add(schemaName); - } - - DataSourceInfo source = new DataSourceInfo(scope); - _sourcesAndSchemas.put(source, schemaNames); - _sourcesAndSchemasIncludingSystem.put(source, schemaNamesIncludingSystem); - } - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class GetTablesForm - { - private String _dataSource; - private String _schemaName; - private boolean _sorted; - - public String getDataSource() - { - return _dataSource; - } - - public void setDataSource(String dataSource) - { - _dataSource = dataSource; - } - - public String getSchemaName() - { - return _schemaName; - } - - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public boolean isSorted() - { - return _sorted; - } - - public void setSorted(boolean sorted) - { - _sorted = sorted; - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class GetTablesAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(GetTablesForm form, BindException errors) - { - List> rows = new LinkedList<>(); - List tableNames = new ArrayList<>(); - - if (null != form.getSchemaName()) - { - DbScope scope = DbScope.getDbScope(form.getDataSource()); - if (null != scope) - { - DbSchema schema = scope.getSchema(form.getSchemaName(), DbSchemaType.Bare); - tableNames.addAll(schema.getTableNames()); - } - else - { - Container c = ContainerManager.getForId(form.getDataSource()); - if (null != c) - { - UserSchema schema = QueryService.get().getUserSchema(getUser(), c, form.getSchemaName()); - if (null != schema) - { - if (form.isSorted()) - for (TableInfo table : schema.getSortedTables()) - tableNames.add(table.getName()); - else - tableNames.addAll(schema.getTableAndQueryNames(true)); - } - } - } - } - - Collections.sort(tableNames); - - for (String tableName : tableNames) - { - Map row = new LinkedHashMap<>(); - row.put("table", tableName); - rows.add(row); - } - - Map properties = new HashMap<>(); - properties.put("rows", rows); - - return new ApiSimpleResponse(properties); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class SchemaTemplateForm - { - private String _name; - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - } - - - @RequiresPermission(AdminOperationsPermission.class) - public static class SchemaTemplateAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(SchemaTemplateForm form, BindException errors) - { - String name = form.getName(); - if (name == null) - throw new IllegalArgumentException("name required"); - - Container c = getContainer(); - TemplateSchemaType template = QueryServiceImpl.get().getSchemaTemplate(c, name); - if (template == null) - throw new NotFoundException("template not found"); - - JSONObject templateJson = QueryServiceImpl.get().schemaTemplateJson(name, template); - - return new ApiSimpleResponse("template", templateJson); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class SchemaTemplatesAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(Object form, BindException errors) - { - Container c = getContainer(); - QueryServiceImpl svc = QueryServiceImpl.get(); - Map templates = svc.getSchemaTemplates(c); - - JSONArray ret = new JSONArray(); - for (String key : templates.keySet()) - { - TemplateSchemaType template = templates.get(key); - JSONObject templateJson = svc.schemaTemplateJson(key, template); - ret.put(templateJson); - } - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("templates", ret); - resp.put("success", true); - return resp; - } - } - - @RequiresPermission(AdminPermission.class) - public static class ReloadExternalSchemaAction extends FormHandlerAction - { - private String _userSchemaName; - - @Override - public void validateCommand(SchemaForm form, Errors errors) - { - } - - @Override - public boolean handlePost(SchemaForm form, BindException errors) - { - ExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), ExternalSchemaDef.class); - if (def == null) - throw new NotFoundException(); - - QueryManager.get().reloadExternalSchema(def); - _userSchemaName = def.getUserSchemaName(); - - return true; - } - - @Override - public ActionURL getSuccessURL(SchemaForm form) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "Schema " + _userSchemaName + " was reloaded successfully."); - } - } - - - @RequiresPermission(AdminPermission.class) - public static class ReloadAllUserSchemas extends FormHandlerAction - { - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - QueryManager.get().reloadAllExternalSchemas(getContainer()); - return true; - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "All schemas in this folder were reloaded successfully."); - } - } - - @RequiresPermission(AdminPermission.class) - public static class ReloadFailedConnectionsAction extends FormHandlerAction - { - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - DbScope.clearFailedDbScopes(); - return true; - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "Reconnection was attempted on all data sources that failed previous connection attempts."); - } - } - - @RequiresPermission(ReadPermission.class) - public static class TableInfoAction extends SimpleViewAction - { - @Override - public ModelAndView getView(TableInfoForm form, BindException errors) throws Exception - { - TablesDocument ret = TablesDocument.Factory.newInstance(); - TablesType tables = ret.addNewTables(); - - FieldKey[] fields = form.getFieldKeys(); - if (fields.length != 0) - { - TableInfo tinfo = QueryView.create(form, errors).getTable(); - Map columnMap = CustomViewImpl.getColumnInfos(tinfo, Arrays.asList(fields)); - TableXML.initTable(tables.addNewTable(), tinfo, null, columnMap.values()); - } - - for (FieldKey tableKey : form.getTableKeys()) - { - TableInfo tableInfo = form.getTableInfo(tableKey); - TableType xbTable = tables.addNewTable(); - TableXML.initTable(xbTable, tableInfo, tableKey); - } - getViewContext().getResponse().setContentType("text/xml"); - getViewContext().getResponse().getWriter().write(ret.toString()); - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - // Issue 18870: Guest user can't revert unsaved custom view changes - // Permission will be checked inline (guests are allowed to delete their session custom views) - @RequiresNoPermission - @Action(ActionType.Configure.class) - public static class DeleteViewAction extends MutatingApiAction - { - @Override - public ApiResponse execute(DeleteViewForm form, BindException errors) - { - CustomView view = form.getCustomView(); - if (view == null) - { - throw new NotFoundException(); - } - - if (getUser().isGuest()) - { - // Guests can only delete session custom views. - if (!view.isSession()) - throw new UnauthorizedException(); - } - else - { - // Logged in users must have read permission - if (!getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new UnauthorizedException(); - } - - if (view.isShared()) - { - if (!getContainer().hasPermission(getUser(), EditSharedViewPermission.class)) - throw new UnauthorizedException(); - } - - view.delete(getUser(), getViewContext().getRequest()); - - // Delete the first shadowed custom view, if available. - if (form.isComplete()) - { - form.reset(); - CustomView shadowed = form.getCustomView(); - if (shadowed != null && shadowed.isEditable() && !(shadowed instanceof ModuleCustomView)) - { - if (!shadowed.isShared() || getContainer().hasPermission(getUser(), EditSharedViewPermission.class)) - shadowed.delete(getUser(), getViewContext().getRequest()); - } - } - - // Try to get a custom view of the same name as the view we just deleted. - // The deleted view may have been a session view or a personal view masking shared view with the same name. - form.reset(); - view = form.getCustomView(); - String nextViewName = null; - if (view != null) - nextViewName = view.getName(); - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("viewName", nextViewName); - return response; - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class SaveSessionViewForm extends QueryForm - { - private String newName; - private boolean inherit; - private boolean shared; - private boolean hidden; - private boolean replace; - private String containerPath; - - public String getNewName() - { - return newName; - } - - public void setNewName(String newName) - { - this.newName = newName; - } - - public boolean isInherit() - { - return inherit; - } - - public void setInherit(boolean inherit) - { - this.inherit = inherit; - } - - public boolean isShared() - { - return shared; - } - - public void setShared(boolean shared) - { - this.shared = shared; - } - - public String getContainerPath() - { - return containerPath; - } - - public void setContainerPath(String containerPath) - { - this.containerPath = containerPath; - } - - public boolean isHidden() - { - return hidden; - } - - public void setHidden(boolean hidden) - { - this.hidden = hidden; - } - - public boolean isReplace() - { - return replace; - } - - public void setReplace(boolean replace) - { - this.replace = replace; - } - } - - // Moves a session view into the database. - @RequiresPermission(ReadPermission.class) - public static class SaveSessionViewAction extends MutatingApiAction - { - @Override - public ApiResponse execute(SaveSessionViewForm form, BindException errors) - { - CustomView view = form.getCustomView(); - if (view == null) - { - throw new NotFoundException(); - } - if (!view.isSession()) - throw new IllegalArgumentException("This action only supports saving session views."); - - //if (!getContainer().getId().equals(view.getContainer().getId())) - // throw new IllegalArgumentException("View may only be saved from container it was created in."); - - assert !view.canInherit() && !view.isShared() && view.isEditable(): "Session view should never be inheritable or shared and always be editable"; - - // Users may save views to a location other than the current container - String containerPath = form.getContainerPath(); - Container container; - if (form.isInherit() && containerPath != null) - { - // Only respect this request if it's a view that is inheritable in subfolders - container = ContainerManager.getForPath(containerPath); - } - else - { - // Otherwise, save it in the current container - container = getContainer(); - } - - if (container == null) - throw new NotFoundException("No such container: " + containerPath); - - if (form.isShared() || form.isInherit()) - { - if (!container.hasPermission(getUser(), EditSharedViewPermission.class)) - throw new UnauthorizedException(); - } - - DbScope scope = QueryManager.get().getDbSchema().getScope(); - try (DbScope.Transaction tx = scope.ensureTransaction()) - { - // Delete the session view. The view will be restored if an exception is thrown. - view.delete(getUser(), getViewContext().getRequest()); - - // Get any previously existing non-session view. - // The session custom view and the view-to-be-saved may have different names. - // If they do have different names, we may need to delete an existing session view with that name. - // UNDONE: If the view has a different name, we will clobber it without asking. - CustomView existingView = form.getQueryDef().getCustomView(getUser(), null, form.getNewName()); - if (existingView != null && existingView.isSession()) - { - // Delete any session view we are overwriting. - existingView.delete(getUser(), getViewContext().getRequest()); - existingView = form.getQueryDef().getCustomView(getUser(), null, form.getNewName()); - } - - // save a new private view if shared is false but existing view is shared - if (existingView != null && !form.isShared() && existingView.getOwner() == null) - { - existingView = null; - } - - if (existingView != null && !form.isReplace() && !StringUtils.isEmpty(form.getNewName())) - throw new IllegalArgumentException("A saved view by the name \"" + form.getNewName() + "\" already exists. "); - - if (existingView == null || (existingView instanceof ModuleCustomView && existingView.isEditable())) - { - User owner = form.isShared() ? null : getUser(); - - CustomViewImpl viewCopy = new CustomViewImpl(form.getQueryDef(), owner, form.getNewName()); - viewCopy.setColumns(view.getColumns()); - viewCopy.setCanInherit(form.isInherit()); - viewCopy.setFilterAndSort(view.getFilterAndSort()); - viewCopy.setColumnProperties(view.getColumnProperties()); - viewCopy.setIsHidden(form.isHidden()); - if (form.isInherit()) - viewCopy.setContainer(container); - - viewCopy.save(getUser(), getViewContext().getRequest()); - } - else if (!existingView.isEditable()) - { - throw new IllegalArgumentException("Existing view '" + form.getNewName() + "' is not editable. You may save this view with a different name."); - } - else - { - // UNDONE: changing shared property of an existing view is unimplemented. Not sure if it makes sense from a usability point of view. - existingView.setColumns(view.getColumns()); - existingView.setFilterAndSort(view.getFilterAndSort()); - existingView.setColumnProperties(view.getColumnProperties()); - existingView.setCanInherit(form.isInherit()); - if (form.isInherit()) - ((CustomViewImpl)existingView).setContainer(container); - existingView.setIsHidden(form.isHidden()); - - existingView.save(getUser(), getViewContext().getRequest()); - } - - tx.commit(); - return new ApiSimpleResponse("success", true); - } - catch (Exception e) - { - // dirty the view then save the deleted session view back in session state - view.setName(view.getName()); - view.save(getUser(), getViewContext().getRequest()); - - throw e; - } - } - } - - /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ - @RequiresPermission(AdminPermission.class) - public class ManageViewsAction extends SimpleViewAction - { - @SuppressWarnings("UnusedDeclaration") - public ManageViewsAction() - { - } - - public ManageViewsAction(ViewContext ctx) - { - setViewContext(ctx); - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - return new JspView<>("/org/labkey/query/view/manageViews.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Manage Views", QueryController.this.getViewContext().getActionURL()); - } - } - - - /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ - @RequiresPermission(AdminPermission.class) - public class InternalDeleteView extends ConfirmAction - { - @Override - public ModelAndView getConfirmView(InternalViewForm form, BindException errors) - { - return new JspView<>("/org/labkey/query/view/internalDeleteView.jsp", form, errors); - } - - @Override - public boolean handlePost(InternalViewForm form, BindException errors) - { - CstmView view = form.getViewAndCheckPermission(); - QueryManager.get().delete(view); - return true; - } - - @Override - public void validateCommand(InternalViewForm internalViewForm, Errors errors) - { - } - - @Override - @NotNull - public ActionURL getSuccessURL(InternalViewForm internalViewForm) - { - return new ActionURL(ManageViewsAction.class, getContainer()); - } - } - - /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ - @RequiresPermission(AdminPermission.class) - public class InternalSourceViewAction extends FormViewAction - { - @Override - public void validateCommand(InternalSourceViewForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(InternalSourceViewForm form, boolean reshow, BindException errors) - { - CstmView view = form.getViewAndCheckPermission(); - form.ff_inherit = QueryManager.get().canInherit(view.getFlags()); - form.ff_hidden = QueryManager.get().isHidden(view.getFlags()); - form.ff_columnList = view.getColumns(); - form.ff_filter = view.getFilter(); - return new JspView<>("/org/labkey/query/view/internalSourceView.jsp", form, errors); - } - - @Override - public boolean handlePost(InternalSourceViewForm form, BindException errors) - { - CstmView view = form.getViewAndCheckPermission(); - int flags = view.getFlags(); - flags = QueryManager.get().setCanInherit(flags, form.ff_inherit); - flags = QueryManager.get().setIsHidden(flags, form.ff_hidden); - view.setFlags(flags); - view.setColumns(form.ff_columnList); - view.setFilter(form.ff_filter); - QueryManager.get().update(getUser(), view); - return true; - } - - @Override - public ActionURL getSuccessURL(InternalSourceViewForm form) - { - return new ActionURL(ManageViewsAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new ManageViewsAction(getViewContext()).addNavTrail(root); - root.addChild("Edit source of Grid View"); - } - } - - /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ - @RequiresPermission(AdminPermission.class) - public class InternalNewViewAction extends FormViewAction - { - int _customViewId = 0; - - @Override - public void validateCommand(InternalNewViewForm form, Errors errors) - { - if (StringUtils.trimToNull(form.ff_schemaName) == null) - { - errors.reject(ERROR_MSG, "Schema name cannot be blank."); - } - if (StringUtils.trimToNull(form.ff_queryName) == null) - { - errors.reject(ERROR_MSG, "Query name cannot be blank"); - } - } - - @Override - public ModelAndView getView(InternalNewViewForm form, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/query/view/internalNewView.jsp", form, errors); - } - - @Override - public boolean handlePost(InternalNewViewForm form, BindException errors) - { - if (form.ff_share) - { - if (!getContainer().hasPermission(getUser(), AdminPermission.class)) - throw new UnauthorizedException(); - } - List existing = QueryManager.get().getCstmViews(getContainer(), form.ff_schemaName, form.ff_queryName, form.ff_viewName, form.ff_share ? null : getUser(), false, false); - CstmView view; - if (!existing.isEmpty()) - { - } - else - { - view = new CstmView(); - view.setSchema(form.ff_schemaName); - view.setQueryName(form.ff_queryName); - view.setName(form.ff_viewName); - view.setContainerId(getContainer().getId()); - if (form.ff_share) - { - view.setCustomViewOwner(null); - } - else - { - view.setCustomViewOwner(getUser().getUserId()); - } - if (form.ff_inherit) - { - view.setFlags(QueryManager.get().setCanInherit(view.getFlags(), form.ff_inherit)); - } - InternalViewForm.checkEdit(getViewContext(), view); - try - { - view = QueryManager.get().insert(getUser(), view); - } - catch (Exception e) - { - LogManager.getLogger(QueryController.class).error("Error", e); - errors.reject(ERROR_MSG, "An exception occurred: " + e); - return false; - } - _customViewId = view.getCustomViewId(); - } - return true; - } - - @Override - public ActionURL getSuccessURL(InternalNewViewForm form) - { - ActionURL forward = new ActionURL(InternalSourceViewAction.class, getContainer()); - forward.addParameter("customViewId", Integer.toString(_customViewId)); - return forward; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Create New Grid View"); - } - } - - - @ActionNames("clearSelected, selectNone") - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public static class SelectNoneAction extends MutatingApiAction - { - @Override - public void validateForm(SelectForm form, Errors errors) - { - if (form.getSchemaName().isEmpty() != (form.getQueryName() == null)) - { - errors.reject(ERROR_MSG, "Both schemaName and queryName are required"); - } - } - - @Override - public ApiResponse execute(final SelectForm form, BindException errors) throws Exception - { - if (form.getQueryName() == null) - { - DataRegionSelection.clearAll(getViewContext(), form.getKey()); - return new DataRegionSelection.SelectionResponse(0); - } - else - { - // Note: DataRegionSelection.setSelectionForAll(form, false) will query for all rows in the region to remove those in context. - // Instead, get the selected values in context and then "uncheck" them directly - List currentCtxSelection = DataRegionSelection.getSelected(form, false); - int count = DataRegionSelection.setSelected(getViewContext(), form.getQuerySettings().getSelectionKey(), currentCtxSelection, false); - return new DataRegionSelection.SelectionResponse(count); - } - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class SelectForm extends QueryForm - { - protected boolean clearSelected; - protected String key; - - public boolean isClearSelected() - { - return clearSelected; - } - - public void setClearSelected(boolean clearSelected) - { - this.clearSelected = clearSelected; - } - - public String getKey() - { - return key; - } - - public void setKey(String key) - { - this.key = key; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public static class SelectAllAction extends MutatingApiAction - { - @Override - public void validateForm(QueryForm form, Errors errors) - { - if (form.getSchemaName().isEmpty() || form.getQueryName() == null) - { - errors.reject(ERROR_MSG, "schemaName and queryName required"); - } - } - - @Override - public ApiResponse execute(final QueryForm form, BindException errors) throws Exception - { - int count = DataRegionSelection.setSelectionForAll(form, true); - return new DataRegionSelection.SelectionResponse(count); - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetSelectedAction extends ReadOnlyApiAction - { - @Override - public void validateForm(SelectForm form, Errors errors) - { - if (form.getSchemaName().isEmpty() != (form.getQueryName() == null)) - { - errors.reject(ERROR_MSG, "Both schemaName and queryName are required"); - } - } - - @Override - public ApiResponse execute(final SelectForm form, BindException errors) throws Exception - { - getViewContext().getResponse().setHeader("Content-Type", CONTENT_TYPE_JSON); - if (form.getQueryName() == null) - { - Set selected = DataRegionSelection.getSelected(getViewContext(), form.getKey(), form.isClearSelected()); - return new ApiSimpleResponse("selected", selected); - } - else - { - List selected = DataRegionSelection.getSelected(form, form.isClearSelected()); - return new ApiSimpleResponse("selected", selected); - } - } - } - - @ActionNames("setSelected, setCheck") - @RequiresPermission(ReadPermission.class) - public static class SetCheckAction extends MutatingApiAction - { - @Override - public ApiResponse execute(final SetCheckForm form, BindException errors) throws Exception - { - String[] ids = form.getId(getViewContext().getRequest()); - List selection = new ArrayList<>(); - if (ids != null) - { - for (String id : ids) - { - if (StringUtils.isNotBlank(id)) - selection.add(id); - } - } - - int count; - if (form.getQueryName() != null && form.isValidateIds() && form.isChecked()) - { - selection = DataRegionSelection.getValidatedIds(selection, form); - } - - count = DataRegionSelection.setSelected( - getViewContext(), form.getKey(), - selection, form.isChecked()); - - return new DataRegionSelection.SelectionResponse(count); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class SetCheckForm extends SelectForm - { - protected String[] ids; - protected boolean checked; - protected boolean validateIds; - - public String[] getId(HttpServletRequest request) - { - // 5025 : DataRegion checkbox names may contain comma - // Beehive parses a single parameter value with commas into an array - // which is not what we want. - String[] paramIds = request.getParameterValues("id"); - return paramIds == null ? ids: paramIds; - } - - public void setId(String[] ids) - { - this.ids = ids; - } - - public boolean isChecked() - { - return checked; - } - - public void setChecked(boolean checked) - { - this.checked = checked; - } - - public boolean isValidateIds() - { - return validateIds; - } - - public void setValidateIds(boolean validateIds) - { - this.validateIds = validateIds; - } - } - - @RequiresPermission(ReadPermission.class) - public static class ReplaceSelectedAction extends MutatingApiAction - { - @Override - public ApiResponse execute(final SetCheckForm form, BindException errors) - { - String[] ids = form.getId(getViewContext().getRequest()); - List selection = new ArrayList<>(); - if (ids != null) - { - for (String id : ids) - { - if (StringUtils.isNotBlank(id)) - selection.add(id); - } - } - - - DataRegionSelection.clearAll(getViewContext(), form.getKey()); - int count = DataRegionSelection.setSelected( - getViewContext(), form.getKey(), - selection, true); - return new DataRegionSelection.SelectionResponse(count); - } - } - - @RequiresPermission(ReadPermission.class) - public static class SetSnapshotSelectionAction extends MutatingApiAction - { - @Override - public ApiResponse execute(final SetCheckForm form, BindException errors) - { - String[] ids = form.getId(getViewContext().getRequest()); - List selection = new ArrayList<>(); - if (ids != null) - { - for (String id : ids) - { - if (StringUtils.isNotBlank(id)) - selection.add(id); - } - } - - DataRegionSelection.clearAll(getViewContext(), form.getKey(), true); - int count = DataRegionSelection.setSelected( - getViewContext(), form.getKey(), - selection, true, true); - return new DataRegionSelection.SelectionResponse(count); - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetSnapshotSelectionAction extends ReadOnlyApiAction - { - @Override - public void validateForm(SelectForm form, Errors errors) - { - if (StringUtils.isEmpty(form.getKey())) - { - errors.reject(ERROR_MSG, "Selection key is required"); - } - } - - @Override - public ApiResponse execute(final SelectForm form, BindException errors) throws Exception - { - List selected = DataRegionSelection.getSnapshotSelected(getViewContext(), form.getKey()); - return new ApiSimpleResponse("selected", selected); - } - } - - public static String getMessage(SqlDialect d, SQLException x) - { - return x.getMessage(); - } - - - public static class GetSchemasForm - { - private boolean _includeHidden = true; - private SchemaKey _schemaName; - - public SchemaKey getSchemaName() - { - return _schemaName; - } - - @SuppressWarnings("unused") - public void setSchemaName(SchemaKey schemaName) - { - _schemaName = schemaName; - } - - public boolean isIncludeHidden() - { - return _includeHidden; - } - - @SuppressWarnings("unused") - public void setIncludeHidden(boolean includeHidden) - { - _includeHidden = includeHidden; - } - } - - - @RequiresPermission(ReadPermission.class) - @ApiVersion(12.3) - public static class GetSchemasAction extends ReadOnlyApiAction - { - @Override - protected long getLastModified(GetSchemasForm form) - { - return QueryService.get().metadataLastModified(); - } - - @Override - public ApiResponse execute(GetSchemasForm form, BindException errors) - { - final Container container = getContainer(); - final User user = getUser(); - - final boolean includeHidden = form.isIncludeHidden(); - if (getRequestedApiVersion() >= 9.3) - { - SimpleSchemaTreeVisitor visitor = new SimpleSchemaTreeVisitor<>(includeHidden) - { - @Override - public Void visitUserSchema(UserSchema schema, Path path, JSONObject json) - { - JSONObject schemaProps = new JSONObject(); - - schemaProps.put("schemaName", schema.getName()); - schemaProps.put("fullyQualifiedName", schema.getSchemaName()); - schemaProps.put("description", schema.getDescription()); - schemaProps.put("hidden", schema.isHidden()); - NavTree tree = schema.getSchemaBrowserLinks(user); - if (tree != null && tree.hasChildren()) - schemaProps.put("menu", tree.toJSON()); - - // Collect children schemas - JSONObject children = new JSONObject(); - visit(schema.getSchemas(_includeHidden), path, children); - if (!children.isEmpty()) - schemaProps.put("schemas", children); - - // Add node's schemaProps to the parent's json. - json.put(schema.getName(), schemaProps); - return null; - } - }; - - // By default, start from the root. - QuerySchema schema; - if (form.getSchemaName() != null) - schema = DefaultSchema.get(user, container, form.getSchemaName()); - else - schema = DefaultSchema.get(user, container); - - // Ensure consistent exception as other query actions - QueryForm.ensureSchemaNotNull(schema); - - // Create the JSON response by visiting the schema children. The parent schema information isn't included. - JSONObject ret = new JSONObject(); - visitor.visitTop(schema.getSchemas(includeHidden), ret); - - return new ApiSimpleResponse(ret); - } - else - { - return new ApiSimpleResponse("schemas", DefaultSchema.get(user, container).getUserSchemaPaths(includeHidden)); - } - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class GetQueriesForm - { - private String _schemaName; - private boolean _includeUserQueries = true; - private boolean _includeSystemQueries = true; - private boolean _includeColumns = true; - private boolean _includeViewDataUrl = true; - private boolean _includeTitle = true; - private boolean _queryDetailColumns = false; - - public String getSchemaName() - { - return _schemaName; - } - - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public boolean isIncludeUserQueries() - { - return _includeUserQueries; - } - - public void setIncludeUserQueries(boolean includeUserQueries) - { - _includeUserQueries = includeUserQueries; - } - - public boolean isIncludeSystemQueries() - { - return _includeSystemQueries; - } - - public void setIncludeSystemQueries(boolean includeSystemQueries) - { - _includeSystemQueries = includeSystemQueries; - } - - public boolean isIncludeColumns() - { - return _includeColumns; - } - - public void setIncludeColumns(boolean includeColumns) - { - _includeColumns = includeColumns; - } - - public boolean isQueryDetailColumns() - { - return _queryDetailColumns; - } - - public void setQueryDetailColumns(boolean queryDetailColumns) - { - _queryDetailColumns = queryDetailColumns; - } - - public boolean isIncludeViewDataUrl() - { - return _includeViewDataUrl; - } - - public void setIncludeViewDataUrl(boolean includeViewDataUrl) - { - _includeViewDataUrl = includeViewDataUrl; - } - - public boolean isIncludeTitle() - { - return _includeTitle; - } - - public void setIncludeTitle(boolean includeTitle) - { - _includeTitle = includeTitle; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) - public static class GetQueriesAction extends ReadOnlyApiAction - { - @Override - protected long getLastModified(GetQueriesForm form) - { - return QueryService.get().metadataLastModified(); - } - - @Override - public ApiResponse execute(GetQueriesForm form, BindException errors) - { - if (null == StringUtils.trimToNull(form.getSchemaName())) - throw new IllegalArgumentException("You must supply a value for the 'schemaName' parameter!"); - - ApiSimpleResponse response = new ApiSimpleResponse(); - UserSchema uschema = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()); - if (null == uschema) - throw new NotFoundException("The schema name '" + form.getSchemaName() - + "' was not found within the folder '" + getContainer().getPath() + "'"); - - response.put("schemaName", form.getSchemaName()); - - List> qinfos = new ArrayList<>(); - - //user-defined queries - if (form.isIncludeUserQueries()) - { - for (QueryDefinition qdef : uschema.getQueryDefs().values()) - { - if (!qdef.isTemporary()) - { - ActionURL viewDataUrl = form.isIncludeViewDataUrl() ? uschema.urlFor(QueryAction.executeQuery, qdef) : null; - qinfos.add(getQueryProps(qdef, viewDataUrl, true, uschema, form.isIncludeColumns(), form.isQueryDetailColumns(), form.isIncludeTitle())); - } - } - } - - //built-in tables - if (form.isIncludeSystemQueries()) - { - for (String qname : uschema.getVisibleTableNames()) - { - // Go direct against the UserSchema instead of calling into QueryService, which takes a schema and - // query name as strings and therefore has to create new instances - QueryDefinition qdef = uschema.getQueryDefForTable(qname); - if (qdef != null) - { - ActionURL viewDataUrl = form.isIncludeViewDataUrl() ? uschema.urlFor(QueryAction.executeQuery, qdef) : null; - qinfos.add(getQueryProps(qdef, viewDataUrl, false, uschema, form.isIncludeColumns(), form.isQueryDetailColumns(), form.isIncludeTitle())); - } - } - } - response.put("queries", qinfos); - - return response; - } - - protected Map getQueryProps(QueryDefinition qdef, ActionURL viewDataUrl, boolean isUserDefined, UserSchema schema, boolean includeColumns, boolean useQueryDetailColumns, boolean includeTitle) - { - Map qinfo = new HashMap<>(); - qinfo.put("hidden", qdef.isHidden()); - qinfo.put("snapshot", qdef.isSnapshot()); - qinfo.put("inherit", qdef.canInherit()); - qinfo.put("isUserDefined", isUserDefined); - boolean canEdit = qdef.canEdit(getUser()); - qinfo.put("canEdit", canEdit); - qinfo.put("canEditSharedViews", getContainer().hasPermission(getUser(), EditSharedViewPermission.class)); - // CONSIDER: do we want to separate the 'canEditMetadata' property and 'isMetadataOverridable' properties to differentiate between capability and the permission check? - qinfo.put("isMetadataOverrideable", qdef.isMetadataEditable() && qdef.canEditMetadata(getUser())); - - if (isUserDefined) - qinfo.put("moduleName", qdef.getModuleName()); - boolean isInherited = qdef.canInherit() && !getContainer().equals(qdef.getDefinitionContainer()); - qinfo.put("isInherited", isInherited); - if (isInherited) - qinfo.put("containerPath", qdef.getDefinitionContainer().getPath()); - qinfo.put("isIncludedForLookups", qdef.isIncludedForLookups()); - - if (null != qdef.getDescription()) - qinfo.put("description", qdef.getDescription()); - if (viewDataUrl != null) - qinfo.put("viewDataUrl", viewDataUrl); - - String title = qdef.getName(); - String name = qdef.getName(); - try - { - // get the TableInfo if the user requested column info or title, otherwise skip (it can be expensive) - if (includeColumns || includeTitle) - { - TableInfo table = qdef.getTable(schema, null, true); - - if (null != table) - { - if (includeColumns) - { - Collection> columns; - - if (useQueryDetailColumns) - { - columns = JsonWriter - .getNativeColProps(table, Collections.emptyList(), null, false, false) - .values(); - } - else - { - columns = new ArrayList<>(); - for (ColumnInfo col : table.getColumns()) - { - Map cinfo = new HashMap<>(); - cinfo.put("name", col.getName()); - if (null != col.getLabel()) - cinfo.put("caption", col.getLabel()); - if (null != col.getShortLabel()) - cinfo.put("shortCaption", col.getShortLabel()); - if (null != col.getDescription()) - cinfo.put("description", col.getDescription()); - - columns.add(cinfo); - } - } - - if (!columns.isEmpty()) - qinfo.put("columns", columns); - } - - if (includeTitle) - { - name = table.getPublicName(); - title = table.getTitle(); - } - } - } - } - catch(Exception e) - { - //may happen due to query failing parse - } - - qinfo.put("title", title); - qinfo.put("name", name); - return qinfo; - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class GetQueryViewsForm - { - private String _schemaName; - private String _queryName; - private String _viewName; - private boolean _metadata; - private boolean _excludeSessionView; - - public String getSchemaName() - { - return _schemaName; - } - - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public String getQueryName() - { - return _queryName; - } - - public void setQueryName(String queryName) - { - _queryName = queryName; - } - - public String getViewName() - { - return _viewName; - } - - public void setViewName(String viewName) - { - _viewName = viewName; - } - - public boolean isMetadata() - { - return _metadata; - } - - public void setMetadata(boolean metadata) - { - _metadata = metadata; - } - - public boolean isExcludeSessionView() - { - return _excludeSessionView; - } - - public void setExcludeSessionView(boolean excludeSessionView) - { - _excludeSessionView = excludeSessionView; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) - public static class GetQueryViewsAction extends ReadOnlyApiAction - { - @Override - protected long getLastModified(GetQueryViewsForm form) - { - return QueryService.get().metadataLastModified(); - } - - @Override - public ApiResponse execute(GetQueryViewsForm form, BindException errors) - { - if (null == StringUtils.trimToNull(form.getSchemaName())) - throw new IllegalArgumentException("You must pass a value for the 'schemaName' parameter!"); - if (null == StringUtils.trimToNull(form.getQueryName())) - throw new IllegalArgumentException("You must pass a value for the 'queryName' parameter!"); - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()); - if (null == schema) - throw new NotFoundException("The schema name '" + form.getSchemaName() - + "' was not found within the folder '" + getContainer().getPath() + "'"); - - QueryDefinition querydef = QueryService.get().createQueryDefForTable(schema, form.getQueryName()); - if (null == querydef || querydef.getTable(null, true) == null) - throw new NotFoundException("The query '" + form.getQueryName() + "' was not found within the '" - + form.getSchemaName() + "' schema in the container '" - + getContainer().getPath() + "'!"); - - Map views = querydef.getCustomViews(getUser(), getViewContext().getRequest(), true, false, form.isExcludeSessionView()); - if (null == views) - views = Collections.emptyMap(); - - Map> columnMetadata = new HashMap<>(); - - List> viewInfos = Collections.emptyList(); - if (getViewContext().getBindPropertyValues().contains("viewName")) - { - // Get info for a named view or the default view (null) - String viewName = StringUtils.trimToNull(form.getViewName()); - CustomView view = views.get(viewName); - if (view != null) - { - viewInfos = Collections.singletonList(CustomViewUtil.toMap(view, getUser(), form.isMetadata())); - } - else if (viewName == null) - { - // The default view was requested but it hasn't been customized yet. Create the 'default default' view. - viewInfos = Collections.singletonList(CustomViewUtil.toMap(getViewContext(), schema, form.getQueryName(), null, form.isMetadata(), true, columnMetadata)); - } - } - else - { - boolean foundDefault = false; - viewInfos = new ArrayList<>(views.size()); - for (CustomView view : views.values()) - { - if (view.getName() == null) - foundDefault = true; - viewInfos.add(CustomViewUtil.toMap(view, getUser(), form.isMetadata())); - } - - if (!foundDefault) - { - // The default view hasn't been customized yet. Create the 'default default' view. - viewInfos.add(CustomViewUtil.toMap(getViewContext(), schema, form.getQueryName(), null, form.isMetadata(), true, columnMetadata)); - } - } - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("schemaName", form.getSchemaName()); - response.put("queryName", form.getQueryName()); - response.put("views", viewInfos); - - return response; - } - } - - @RequiresNoPermission - public static class GetServerDateAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(Object o, BindException errors) - { - return new ApiSimpleResponse("date", new Date()); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - private static class SaveApiTestForm - { - private String _getUrl; - private String _postUrl; - private String _postData; - private String _response; - - public String getGetUrl() - { - return _getUrl; - } - - public void setGetUrl(String getUrl) - { - _getUrl = getUrl; - } - - public String getPostUrl() - { - return _postUrl; - } - - public void setPostUrl(String postUrl) - { - _postUrl = postUrl; - } - - public String getResponse() - { - return _response; - } - - public void setResponse(String response) - { - _response = response; - } - - public String getPostData() - { - return _postData; - } - - public void setPostData(String postData) - { - _postData = postData; - } - } - - - @RequiresPermission(ReadPermission.class) - public static class SaveApiTestAction extends MutatingApiAction - { - @Override - public ApiResponse execute(SaveApiTestForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - ApiTestsDocument doc = ApiTestsDocument.Factory.newInstance(); - - TestCaseType test = doc.addNewApiTests().addNewTest(); - test.setName("recorded test case"); - ActionURL url = null; - - if (!StringUtils.isEmpty(form.getGetUrl())) - { - test.setType("get"); - url = new ActionURL(form.getGetUrl()); - } - else if (!StringUtils.isEmpty(form.getPostUrl())) - { - test.setType("post"); - test.setFormData(form.getPostData()); - url = new ActionURL(form.getPostUrl()); - } - - if (url != null) - { - String uri = url.getLocalURIString(); - if (uri.startsWith(url.getContextPath())) - uri = uri.substring(url.getContextPath().length() + 1); - - test.setUrl(uri); - } - test.setResponse(form.getResponse()); - - XmlOptions opts = new XmlOptions(); - opts.setSaveCDataEntityCountThreshold(0); - opts.setSaveCDataLengthThreshold(0); - opts.setSavePrettyPrint(); - opts.setUseDefaultNamespace(); - - response.put("xml", doc.xmlText(opts)); - - return response; - } - } - - - private abstract static class ParseAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - List qpe = new ArrayList<>(); - String expr = getViewContext().getRequest().getParameter("q"); - ArrayList html = new ArrayList<>(); - PageConfig config = getPageConfig(); - var inputId = config.makeId("submit_"); - config.addHandler(inputId, "click", "Ext.getBody().mask();"); - html.add("
\n" + - "" - ); - - QNode e = null; - if (null != expr) - { - try - { - e = _parse(expr,qpe); - } - catch (RuntimeException x) - { - qpe.add(new QueryParseException(x.getMessage(),x, 0, 0)); - } - } - - Tree tree = null; - if (null != expr) - { - try - { - tree = _tree(expr); - } catch (Exception x) - { - qpe.add(new QueryParseException(x.getMessage(),x, 0, 0)); - } - } - - for (Throwable x : qpe) - { - if (null != x.getCause() && x != x.getCause()) - x = x.getCause(); - html.add("
" + PageFlowUtil.filter(x.toString())); - LogManager.getLogger(QueryController.class).debug(expr,x); - } - if (null != e) - { - String prefix = SqlParser.toPrefixString(e); - html.add("
"); - html.add(PageFlowUtil.filter(prefix)); - } - if (null != tree) - { - String prefix = SqlParser.toPrefixString(tree); - html.add("
"); - html.add(PageFlowUtil.filter(prefix)); - } - html.add(""); - return HtmlView.unsafe(StringUtils.join(html,"")); - } - - @Override - public void addNavTrail(NavTree root) - { - } - - abstract QNode _parse(String e, List errors); - abstract Tree _tree(String e) throws Exception; - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class ParseExpressionAction extends ParseAction - { - @Override - QNode _parse(String s, List errors) - { - return new SqlParser().parseExpr(s, true, errors); - } - - @Override - Tree _tree(String e) - { - return null; - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class ParseQueryAction extends ParseAction - { - @Override - QNode _parse(String s, List errors) - { - return new SqlParser().parseQuery(s, errors, null); - } - - @Override - Tree _tree(String s) throws Exception - { - return new SqlParser().rawQuery(s); - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) - public static class ValidateQueryMetadataAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(QueryForm form, BindException errors) - { - UserSchema schema = form.getSchema(); - - if (null == schema) - { - errors.reject(ERROR_MSG, "could not resolve schema: " + form.getSchemaName()); - return null; - } - - List parseErrors = new ArrayList<>(); - List parseWarnings = new ArrayList<>(); - ApiSimpleResponse response = new ApiSimpleResponse(); - - try - { - TableInfo table = schema.getTable(form.getQueryName(), null); - - if (null == table) - { - errors.reject(ERROR_MSG, "could not resolve table: " + form.getQueryName()); - return null; - } - - if (!QueryManager.get().validateQuery(table, true, parseErrors, parseWarnings)) - { - for (QueryParseException e : parseErrors) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - return response; - } - - SchemaKey schemaKey = SchemaKey.fromString(form.getSchemaName()); - QueryManager.get().validateQueryMetadata(schemaKey, form.getQueryName(), getUser(), getContainer(), parseErrors, parseWarnings); - QueryManager.get().validateQueryViews(schemaKey, form.getQueryName(), getUser(), getContainer(), parseErrors, parseWarnings); - } - catch (QueryParseException e) - { - parseErrors.add(e); - } - - for (QueryParseException e : parseErrors) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - - for (QueryParseException e : parseWarnings) - { - errors.reject(ERROR_MSG, "WARNING: " + e.getMessage()); - } - - return response; - } - - @Override - protected ApiResponseWriter createResponseWriter() throws IOException - { - ApiResponseWriter result = super.createResponseWriter(); - // Issue 44875 - don't send a 400 or 500 response code when there's a bogus query or metadata - result.setErrorResponseStatus(HttpServletResponse.SC_OK); - return result; - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class QueryExportAuditForm - { - private int rowId; - - public int getRowId() - { - return rowId; - } - - public void setRowId(int rowId) - { - this.rowId = rowId; - } - } - - /** - * Action used to redirect QueryAuditProvider [details] column to the exported table's grid view. - */ - @RequiresPermission(AdminPermission.class) - public static class QueryExportAuditRedirectAction extends SimpleRedirectAction - { - @Override - public URLHelper getRedirectURL(QueryExportAuditForm form) - { - if (form.getRowId() == 0) - throw new NotFoundException("Query export audit rowid required"); - - UserSchema auditSchema = QueryService.get().getUserSchema(getUser(), getContainer(), AbstractAuditTypeProvider.QUERY_SCHEMA_NAME); - TableInfo queryExportAuditTable = auditSchema.getTable(QueryExportAuditProvider.QUERY_AUDIT_EVENT, null); - if (null == queryExportAuditTable) - throw new NotFoundException(); - - TableSelector selector = new TableSelector(queryExportAuditTable, - PageFlowUtil.set( - QueryExportAuditProvider.COLUMN_NAME_SCHEMA_NAME, - QueryExportAuditProvider.COLUMN_NAME_QUERY_NAME, - QueryExportAuditProvider.COLUMN_NAME_DETAILS_URL), - new SimpleFilter(FieldKey.fromParts(AbstractAuditTypeProvider.COLUMN_NAME_ROW_ID), form.getRowId()), null); - - Map result = selector.getMap(); - if (result == null) - throw new NotFoundException("Query export audit event not found for rowId"); - - String schemaName = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_SCHEMA_NAME); - String queryName = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_QUERY_NAME); - String detailsURL = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_DETAILS_URL); - - if (schemaName == null || queryName == null) - throw new NotFoundException("Query export audit event has not schemaName or queryName"); - - ActionURL url = new ActionURL(ExecuteQueryAction.class, getContainer()); - - // Apply the sorts and filters - if (detailsURL != null) - { - ActionURL sortFilterURL = new ActionURL(detailsURL); - url.setPropertyValues(sortFilterURL.getPropertyValues()); - } - - if (url.getParameter(QueryParam.schemaName) == null) - url.addParameter(QueryParam.schemaName, schemaName); - if (url.getParameter(QueryParam.queryName) == null && url.getParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName) == null) - url.addParameter(QueryParam.queryName, queryName); - - return url; - } - } - - @RequiresPermission(ReadPermission.class) - public static class AuditHistoryAction extends SimpleViewAction - { - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - return QueryUpdateAuditProvider.createHistoryQueryView(getViewContext(), form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Audit History"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class AuditDetailsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(QueryDetailsForm form, BindException errors) - { - return QueryUpdateAuditProvider.createDetailsQueryView(getViewContext(), form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Audit History"); - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class QueryDetailsForm extends QueryForm - { - String _keyValue; - - public String getKeyValue() - { - return _keyValue; - } - - public void setKeyValue(String keyValue) - { - _keyValue = keyValue; - } - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportTablesAction extends FormViewAction - { - private ActionURL _successUrl; - - @Override - public void validateCommand(ExportTablesForm form, Errors errors) - { - } - - @Override - public boolean handlePost(ExportTablesForm form, BindException errors) - { - HttpServletResponse httpResponse = getViewContext().getResponse(); - Container container = getContainer(); - QueryServiceImpl svc = (QueryServiceImpl)QueryService.get(); - - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); OutputStream outputStream = new BufferedOutputStream(baos)) - { - try (ZipFile zip = new ZipFile(outputStream, true)) - { - svc.writeTables(container, getUser(), zip, form.getSchemas(), form.getHeaderType()); - } - - PageFlowUtil.streamFileBytes(httpResponse, FileUtil.makeFileNameWithTimestamp(container.getName(), "tables.zip"), baos.toByteArray(), false); - } - catch (Exception e) - { - errors.reject(ERROR_MSG, e.getMessage() != null ? e.getMessage() : e.getClass().getName()); - LOG.error("Errror exporting tables", e); - } - - if (errors.hasErrors()) - { - _successUrl = new ActionURL(ExportTablesAction.class, getContainer()); - } - - return !errors.hasErrors(); - } - - @Override - public ModelAndView getView(ExportTablesForm form, boolean reshow, BindException errors) - { - // When exporting the zip to the browser, the base action will attempt to reshow the view since we returned - // null as the success URL; returning null here causes the base action to stop pestering the action. - if (reshow && !errors.hasErrors()) - return null; - - return new JspView<>("/org/labkey/query/view/exportTables.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Export Tables"); - } - - @Override - public ActionURL getSuccessURL(ExportTablesForm form) - { - return _successUrl; - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportTablesForm implements HasBindParameters - { - ColumnHeaderType _headerType = ColumnHeaderType.DisplayFieldKey; - Map>> _schemas = new HashMap<>(); - - public ColumnHeaderType getHeaderType() - { - return _headerType; - } - - public void setHeaderType(ColumnHeaderType headerType) - { - _headerType = headerType; - } - - public Map>> getSchemas() - { - return _schemas; - } - - public void setSchemas(Map>> schemas) - { - _schemas = schemas; - } - - @Override - public @NotNull BindException bindParameters(PropertyValues values) - { - BindException errors = new NullSafeBindException(this, "form"); - - PropertyValue schemasProperty = values.getPropertyValue("schemas"); - if (schemasProperty != null && schemasProperty.getValue() != null) - { - try - { - _schemas = JsonUtil.DEFAULT_MAPPER.readValue((String)schemasProperty.getValue(), _schemas.getClass()); - } - catch (IOException e) - { - errors.rejectValue("schemas", ERROR_MSG, e.getMessage()); - } - } - - PropertyValue headerTypeProperty = values.getPropertyValue("headerType"); - if (headerTypeProperty != null && headerTypeProperty.getValue() != null) - { - try - { - _headerType = ColumnHeaderType.valueOf(String.valueOf(headerTypeProperty.getValue())); - } - catch (IllegalArgumentException ex) - { - // ignore - } - } - - return errors; - } - } - - - @RequiresPermission(ReadPermission.class) - public static class SaveNamedSetAction extends MutatingApiAction - { - @Override - public Object execute(NamedSetForm namedSetForm, BindException errors) - { - QueryService.get().saveNamedSet(namedSetForm.getSetName(), namedSetForm.parseSetList()); - return new ApiSimpleResponse("success", true); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class NamedSetForm - { - String setName; - String[] setList; - - public String getSetName() - { - return setName; - } - - public void setSetName(String setName) - { - this.setName = setName; - } - - public String[] getSetList() - { - return setList; - } - - public void setSetList(String[] setList) - { - this.setList = setList; - } - - public List parseSetList() - { - return Arrays.asList(setList); - } - } - - - @RequiresPermission(ReadPermission.class) - public static class DeleteNamedSetAction extends MutatingApiAction - { - - @Override - public Object execute(NamedSetForm namedSetForm, BindException errors) - { - QueryService.get().deleteNamedSet(namedSetForm.getSetName()); - return new ApiSimpleResponse("success", true); - } - } - - @RequiresPermission(ReadPermission.class) - public static class AnalyzeQueriesAction extends ReadOnlyApiAction - { - @Override - public Object execute(Object o, BindException errors) throws Exception - { - JSONObject ret = new JSONObject(); - - try - { - QueryService.QueryAnalysisService analysisService = QueryService.get().getQueryAnalysisService(); - if (analysisService != null) - { - DefaultSchema start = DefaultSchema.get(getUser(), getContainer()); - var deps = new HashSetValuedHashMap(); - - analysisService.analyzeFolder(start, deps); - ret.put("success", true); - - JSONObject objects = new JSONObject(); - for (var from : deps.keySet()) - { - objects.put(from.getKey(), from.toJSON()); - for (var to : deps.get(from)) - objects.put(to.getKey(), to.toJSON()); - } - ret.put("objects", objects); - - JSONArray dependants = new JSONArray(); - for (var from : deps.keySet()) - { - for (var to : deps.get(from)) - dependants.put(new String[] {from.getKey(), to.getKey()}); - } - ret.put("graph", dependants); - } - else - { - ret.put("success", false); - } - return ret; - } - catch (Throwable e) - { - LOG.error(e); - throw UnexpectedException.wrap(e); - } - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetQueryEditorMetadataAction extends ReadOnlyApiAction - { - @Override - protected ObjectMapper createRequestObjectMapper() - { - PropertyService propertyService = PropertyService.get(); - if (null != propertyService) - { - ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy(); - mapper.addMixIn(GWTPropertyDescriptor.class, MetadataTableJSONMixin.class); - return mapper; - } - else - { - throw new RuntimeException("Could not serialize request object"); - } - } - - @Override - protected ObjectMapper createResponseObjectMapper() - { - return createRequestObjectMapper(); - } - - @Override - public Object execute(QueryForm queryForm, BindException errors) throws Exception - { - QueryDefinition queryDef = queryForm.getQueryDef(); - return MetadataTableJSON.getMetadata(queryDef.getSchema().getSchemaName(), queryDef.getName(), getUser(), getContainer()); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - public static class SaveQueryMetadataAction extends MutatingApiAction - { - @Override - protected ObjectMapper createRequestObjectMapper() - { - PropertyService propertyService = PropertyService.get(); - if (null != propertyService) - { - ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy(); - propertyService.configureObjectMapper(mapper, null); - return mapper; - } - else - { - throw new RuntimeException("Could not serialize request object"); - } - } - - @Override - protected ObjectMapper createResponseObjectMapper() - { - return createRequestObjectMapper(); - } - - @Override - public Object execute(QueryMetadataApiForm queryMetadataApiForm, BindException errors) throws Exception - { - String schemaName = queryMetadataApiForm.getSchemaName(); - MetadataTableJSON domain = queryMetadataApiForm.getDomain(); - MetadataTableJSON.saveMetadata(schemaName, domain.getName(), null, domain.getFields(true), queryMetadataApiForm.isUserDefinedQuery(), false, getUser(), getContainer()); - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", true); - resp.put("domain", MetadataTableJSON.getMetadata(schemaName, domain.getName(), getUser(), getContainer())); - return resp; - } - } - - @Marshal(Marshaller.Jackson) - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - public static class ResetQueryMetadataAction extends MutatingApiAction - { - @Override - public Object execute(QueryForm queryForm, BindException errors) throws Exception - { - QueryDefinition queryDef = queryForm.getQueryDef(); - return MetadataTableJSON.resetToDefault(queryDef.getSchema().getSchemaName(), queryDef.getName(), getUser(), getContainer()); - } - } - - private static class QueryMetadataApiForm - { - private MetadataTableJSON _domain; - private String _schemaName; - private boolean _userDefinedQuery; - - public MetadataTableJSON getDomain() - { - return _domain; - } - - @SuppressWarnings("unused") - public void setDomain(MetadataTableJSON domain) - { - _domain = domain; - } - - public String getSchemaName() - { - return _schemaName; - } - - @SuppressWarnings("unused") - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public boolean isUserDefinedQuery() - { - return _userDefinedQuery; - } - - @SuppressWarnings("unused") - public void setUserDefinedQuery(boolean userDefinedQuery) - { - _userDefinedQuery = userDefinedQuery; - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetDefaultVisibleColumnsAction extends ReadOnlyApiAction - { - @Override - public Object execute(GetQueryDetailsAction.Form form, BindException errors) throws Exception - { - ApiSimpleResponse resp = new ApiSimpleResponse(); - - Container container = getContainer(); - User user = getUser(); - - if (StringUtils.isEmpty(form.getSchemaName())) - throw new NotFoundException("SchemaName not specified"); - - QuerySchema querySchema = DefaultSchema.get(user, container, form.getSchemaName()); - if (!(querySchema instanceof UserSchema schema)) - throw new NotFoundException("Could not find the specified schema in the folder '" + container.getPath() + "'"); - - QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, form.getQueryName()); - QueryDefinition queryDef = settings.getQueryDef(schema); - if (null == queryDef) - // Don't echo the provided query name, but schema name is legit since it was found. See #44528. - throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'"); - - TableInfo tinfo = queryDef.getTable(null, true); - if (null == tinfo) - throw new NotFoundException("Could not find the specified query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); - - List fields = tinfo.getDefaultVisibleColumns(); - - List displayColumns = QueryService.get().getColumns(tinfo, fields) - .values() - .stream() - .filter(cinfo -> fields.contains(cinfo.getFieldKey())) - .map(cinfo -> cinfo.getDisplayColumnFactory().createRenderer(cinfo)) - .collect(Collectors.toList()); - - resp.put("columns", JsonWriter.getNativeColProps(displayColumns, null, false)); - - return resp; - } - } - - public static class ParseForm implements ApiJsonForm - { - String expression = ""; - Map columnMap = new HashMap<>(); - List phiColumns = new ArrayList<>(); - - Map getColumnMap() - { - return columnMap; - } - - public String getExpression() - { - return expression; - } - - public void setExpression(String expression) - { - this.expression = expression; - } - - public List getPhiColumns() - { - return phiColumns; - } - - public void setPhiColumns(List phiColumns) - { - this.phiColumns = phiColumns; - } - - @Override - public void bindJson(JSONObject json) - { - if (json.has("expression")) - setExpression(json.getString("expression")); - if (json.has("phiColumns")) - setPhiColumns(json.getJSONArray("phiColumns").toList().stream().map(s -> FieldKey.fromParts(s.toString())).collect(Collectors.toList())); - if (json.has("columnMap")) - { - JSONObject columnMap = json.getJSONObject("columnMap"); - for (String key : columnMap.keySet()) - { - try - { - getColumnMap().put(FieldKey.fromParts(key), JdbcType.valueOf(String.valueOf(columnMap.get(key)))); - } - catch (IllegalArgumentException iae) - { - getColumnMap().put(FieldKey.fromParts(key), JdbcType.OTHER); - } - } - } - } - } - - - /** - * Since this api purpose is to return parse errors, it does not generally return success:false. - *
- * The API expects JSON like this, note that column names should be in FieldKey.toString() encoded to match the response JSON format. - *
-     *     { "expression": "A$ + B", "columnMap":{"A$D":"VARCHAR", "X":"VARCHAR"}}
-     * 
- * and returns a response like this - *
-     *     {
-     *       "jdbcType" : "OTHER",
-     *       "success" : true,
-     *       "columnMap" : {"A$D":"VARCHAR", "B":"OTHER"}
-     *       "errors" : [ { "msg" : "\"B\" not found.", "type" : "sql" } ]
-     *     }
-     * 
- * The columnMap object keys are the names of columns found in the expression. Names are returned - * in FieldKey.toString() formatting e.g. dollar-sign encoded. The object structure - * is compatible with the columnMap input parameter, so it can be used as a template to make a second request - * with types filled in. If provided, the type will be copied from the input columnMap, otherwise it will be "OTHER". - *
- * Parse exceptions may contain a line (usually 1) and col location e.g. - *
-     * {
-     *     "msg" : "Error on line 1: Syntax error near 'error', expected 'EOF'
-     *     "col" : 2,
-     *     "line" : 1,
-     *     "type" : "sql",
-     *     "errorStr" : "A error B"
-     *   }
-     * 
- */ - @RequiresNoPermission - @CSRF(CSRF.Method.NONE) - public static class ParseCalculatedColumnAction extends ReadOnlyApiAction - { - @Override - public Object execute(ParseForm form, BindException errors) throws Exception - { - if (errors.hasErrors()) - return errors; - JSONObject result = new JSONObject(Map.of("success",true)); - var requiredColumns = new HashSet(); - JdbcType jdbcType = JdbcType.OTHER; - try - { - var schema = DefaultSchema.get(getViewContext().getUser(), getViewContext().getContainer()).getUserSchema("core"); - var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; - ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), form.getExpression(), null); - Map columns = new HashMap<>(); - for (var entry : form.getColumnMap().entrySet()) - { - BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); - // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions - // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects - if (form.getPhiColumns().contains(entry.getKey())) - entryCol.setPHI(PHI.PHI); - columns.put(entry.getKey(), entryCol); - table.addColumn(entryCol); - } - // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() - QueryServiceImpl.get().bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); - jdbcType = calculatedCol.getJdbcType(); - } - catch (QueryException x) - { - JSONArray parseErrors = new JSONArray(); - parseErrors.put(x.toJSON(form.getExpression())); - result.put("errors", parseErrors); - } - finally - { - if (!requiredColumns.isEmpty()) - { - JSONObject columnMap = new JSONObject(); - for (FieldKey fk : requiredColumns) - { - JdbcType type = Objects.requireNonNullElse(form.getColumnMap().get(fk), JdbcType.OTHER); - columnMap.put(fk.toString(), type); - } - result.put("columnMap", columnMap); - } - } - result.put("jdbcType", jdbcType.name()); - return result; - } - } - - @JsonIgnoreProperties(ignoreUnknown = true) - public static class QueryImportTemplateForm - { - private String schemaName; - private String queryName; - private String auditUserComment; - private List templateLabels; - private List templateUrls; - private Long _lastKnownModified; - - public void setQueryName(String queryName) - { - this.queryName = queryName; - } - - public List getTemplateLabels() - { - return templateLabels == null ? Collections.emptyList() : templateLabels; - } - - public void setTemplateLabels(List templateLabels) - { - this.templateLabels = templateLabels; - } - - public List getTemplateUrls() - { - return templateUrls == null ? Collections.emptyList() : templateUrls; - } - - public void setTemplateUrls(List templateUrls) - { - this.templateUrls = templateUrls; - } - - public String getSchemaName() - { - return schemaName; - } - - @SuppressWarnings("unused") - public void setSchemaName(String schemaName) - { - this.schemaName = schemaName; - } - - public String getQueryName() - { - return queryName; - } - - public Long getLastKnownModified() - { - return _lastKnownModified; - } - - public void setLastKnownModified(Long lastKnownModified) - { - _lastKnownModified = lastKnownModified; - } - - public String getAuditUserComment() - { - return auditUserComment; - } - - public void setAuditUserComment(String auditUserComment) - { - this.auditUserComment = auditUserComment; - } - - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) //Real permissions will be enforced later on by the DomainKind - public static class UpdateQueryImportTemplateAction extends MutatingApiAction - { - private DomainKind _kind; - private UserSchema _schema; - private TableInfo _tInfo; - private QueryDefinition _queryDef; - private Domain _domain; - - @Override - protected ObjectMapper createResponseObjectMapper() - { - return this.createRequestObjectMapper(); - } - - @Override - public void validateForm(QueryImportTemplateForm form, Errors errors) - { - User user = getUser(); - Container container = getContainer(); - String domainURI = PropertyService.get().getDomainURI(form.getSchemaName(), form.getQueryName(), container, user); - _kind = PropertyService.get().getDomainKind(domainURI); - _domain = PropertyService.get().getDomain(container, domainURI); - if (_domain == null) - throw new IllegalArgumentException("Domain '" + domainURI + "' not found."); - - if (!_kind.canEditDefinition(user, _domain)) - throw new UnauthorizedException("You don't have permission to update import templates for this domain."); - - QuerySchema querySchema = DefaultSchema.get(user, container, form.getSchemaName()); - if (!(querySchema instanceof UserSchema _schema)) - throw new NotFoundException("Could not find the specified schema in the folder '" + container.getPath() + "'."); - QuerySettings settings = _schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, form.getQueryName()); - _queryDef = settings.getQueryDef(_schema); - if (null == _queryDef) - throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'."); - if (!_queryDef.isMetadataEditable()) - throw new UnsupportedOperationException("Query metadata is not editable."); - _tInfo = _queryDef.getTable(_schema, new ArrayList<>(), true, true); - if (_tInfo == null) - throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'."); - - } - - private Map getRowFiles() - { - Map rowFiles = new IntHashMap<>(); - if (getFileMap() != null) - { - for (Map.Entry fileEntry : getFileMap().entrySet()) - { - // allow for the fileMap key to include the row index for defining which row to attach this file to - // ex: "templateFile::0", "templateFile::1" - String fieldKey = fileEntry.getKey(); - int delimIndex = fieldKey.lastIndexOf("::"); - if (delimIndex > -1) - { - Integer fieldRowIndex = Integer.parseInt(fieldKey.substring(delimIndex + 2)); - SpringAttachmentFile file = new SpringAttachmentFile(fileEntry.getValue()); - rowFiles.put(fieldRowIndex, file.isEmpty() ? null : file); - } - } - } - return rowFiles; - } - - private List> getUploadedTemplates(QueryImportTemplateForm form, DomainKind kind) throws ValidationException, QueryUpdateServiceException, ExperimentException - { - FileContentService fcs = FileContentService.get(); - if (fcs == null) - throw new IllegalStateException("Unable to load file service."); - - User user = getUser(); - Container container = getContainer(); - - Map rowFiles = getRowFiles(); - List templateLabels = form.getTemplateLabels(); - Set labels = new HashSet<>(templateLabels); - if (labels.size() < templateLabels.size()) - throw new IllegalArgumentException("Duplicate template name is not allowed."); - - List templateUrls = form.getTemplateUrls(); - List> uploadedTemplates = new ArrayList<>(); - for (int rowIndex = 0; rowIndex < form.getTemplateLabels().size(); rowIndex++) - { - String templateLabel = templateLabels.get(rowIndex); - if (StringUtils.isBlank(templateLabel.trim())) - throw new IllegalArgumentException("Template name cannot be blank."); - String templateUrl = templateUrls.get(rowIndex); - Object file = rowFiles.get(rowIndex); - if (StringUtils.isEmpty(templateUrl) && file == null) - throw new IllegalArgumentException("Template file is not provided."); - - if (file instanceof MultipartFile || file instanceof SpringAttachmentFile) - { - String fileName; - if (file instanceof MultipartFile f) - fileName = f.getName(); - else - { - SpringAttachmentFile f = (SpringAttachmentFile) file; - fileName = f.getFilename(); - } - String fileNameValidation = FileUtil.validateFileName(fileName); - if (!StringUtils.isEmpty(fileNameValidation)) - throw new IllegalArgumentException(fileNameValidation); - - FileLike uploadDir = ensureUploadDirectory(container, kind.getDomainFileDirectory()); - uploadDir = uploadDir.resolveChild("_templates"); - Object savedFile = saveFile(user, container, "template file", file, uploadDir); - Path savedFilePath; - - if (savedFile instanceof File ioFile) - savedFilePath = ioFile.toPath(); - else if (savedFile instanceof FileLike fl) - savedFilePath = fl.toNioPathForRead(); - else - throw UnexpectedException.wrap(null,"Unable to upload template file."); - - templateUrl = fcs.getWebDavUrl(savedFilePath, container, FileContentService.PathType.serverRelative).toString(); - } - - uploadedTemplates.add(Pair.of(templateLabel, templateUrl)); - } - return uploadedTemplates; - } - - @Override - public Object execute(QueryImportTemplateForm form, BindException errors) throws ValidationException, QueryUpdateServiceException, SQLException, ExperimentException, MetadataUnavailableException - { - User user = getUser(); - Container container = getContainer(); - String schemaName = form.getSchemaName(); - String queryName = form.getQueryName(); - QueryDef queryDef = QueryManager.get().getQueryDef(container, schemaName, queryName, false); - if (queryDef != null && queryDef.getQueryDefId() != 0) - { - Long lastKnownModified = form.getLastKnownModified(); - if (lastKnownModified == null || lastKnownModified != queryDef.getModified().getTime()) - throw new ApiUsageException("Unable to save import templates. The templates appear out of date, reload the page and try again."); - } - - List> updatedTemplates = getUploadedTemplates(form, _kind); - - List> existingTemplates = _tInfo.getImportTemplates(getViewContext()); - List> existingCustomTemplates = new ArrayList<>(); - for (Pair template_ : existingTemplates) - { - if (!template_.second.toLowerCase().contains("exportexceltemplate")) - existingCustomTemplates.add(template_); - } - if (!updatedTemplates.equals(existingCustomTemplates)) - { - TablesDocument doc = null; - TableType xmlTable = null; - TableType.ImportTemplates xmlImportTemplates; - - if (queryDef != null) - { - try - { - doc = parseDocument(queryDef.getMetaData()); - } - catch (XmlException e) - { - throw new MetadataUnavailableException(e.getMessage()); - } - xmlTable = getTableType(form.getQueryName(), doc); - // when there is a queryDef but xmlTable is null it means the xmlMetaData contains tableName which does not - // match with actual queryName then reconstruct the xml table metadata : See Issue 43523 - if (xmlTable == null) - { - doc = null; - } - } - else - { - queryDef = new QueryDef(); - queryDef.setSchema(schemaName); - queryDef.setContainer(container.getId()); - queryDef.setName(queryName); - } - - if (doc == null) - { - doc = TablesDocument.Factory.newInstance(); - } - - if (xmlTable == null) - { - TablesType tables = doc.addNewTables(); - xmlTable = tables.addNewTable(); - xmlTable.setTableName(queryName); - } - - if (xmlTable.getTableDbType() == null) - { - xmlTable.setTableDbType("NOT_IN_DB"); - } - - // remove existing templates - if (xmlTable.isSetImportTemplates()) - xmlTable.unsetImportTemplates(); - xmlImportTemplates = xmlTable.addNewImportTemplates(); - - // set new templates - if (!updatedTemplates.isEmpty()) - { - for (Pair template_ : updatedTemplates) - { - ImportTemplateType importTemplateType = xmlImportTemplates.addNewTemplate(); - importTemplateType.setLabel(template_.first); - importTemplateType.setUrl(template_.second); - } - } - - XmlOptions xmlOptions = new XmlOptions(); - xmlOptions.setSavePrettyPrint(); - // Don't use an explicit namespace, making the XML much more readable - xmlOptions.setUseDefaultNamespace(); - queryDef.setMetaData(doc.xmlText(xmlOptions)); - if (queryDef.getQueryDefId() == 0) - { - QueryManager.get().insert(user, queryDef); - } - else - { - QueryManager.get().update(user, queryDef); - } - - DomainAuditProvider.DomainAuditEvent event = new DomainAuditProvider.DomainAuditEvent(getContainer(), "Import templates updated."); - event.setUserComment(form.getAuditUserComment()); - event.setDomainUri(_domain.getTypeURI()); - event.setDomainName(_domain.getName()); - AuditLogService.get().addEvent(user, event); - } - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", true); - return resp; - } - } - - - public static class TestCase extends AbstractActionPermissionTest - { - @Override - public void testActionPermissions() - { - User user = TestContext.get().getUser(); - assertTrue(user.hasSiteAdminPermission()); - - QueryController controller = new QueryController(); - - // @RequiresPermission(ReadPermission.class) - assertForReadPermission(user, false, - new BrowseAction(), - new BeginAction(), - controller.new SchemaAction(), - controller.new SourceQueryAction(), - controller.new ExecuteQueryAction(), - controller.new PrintRowsAction(), - new ExportScriptAction(), - new ExportRowsExcelAction(), - new ExportRowsXLSXAction(), - new ExportQueriesXLSXAction(), - new ExportExcelTemplateAction(), - new ExportRowsTsvAction(), - new ExcelWebQueryDefinitionAction(), - controller.new SaveQueryViewsAction(), - controller.new PropertiesQueryAction(), - controller.new SelectRowsAction(), - new GetDataAction(), - controller.new ExecuteSqlAction(), - controller.new SelectDistinctAction(), - controller.new GetColumnSummaryStatsAction(), - controller.new ImportAction(), - new ExportSqlAction(), - new UpdateRowsAction(), - new ImportRowsAction(), - new DeleteRowsAction(), - new TableInfoAction(), - new SaveSessionViewAction(), - new GetSchemasAction(), - new GetQueriesAction(), - new GetQueryViewsAction(), - new SaveApiTestAction(), - new ValidateQueryMetadataAction(), - new AuditHistoryAction(), - new AuditDetailsAction(), - new ExportTablesAction(), - new SaveNamedSetAction(), - new DeleteNamedSetAction(), - new ApiTestAction(), - new GetDefaultVisibleColumnsAction() - ); - - - // submitter should be allowed for InsertRows - assertForReadPermission(user, true, new InsertRowsAction()); - - // @RequiresPermission(DeletePermission.class) - assertForUpdateOrDeletePermission(user, - new DeleteQueryRowsAction() - ); - - // @RequiresPermission(AdminPermission.class) - assertForAdminPermission(user, - new DeleteQueryAction(), - controller.new MetadataQueryAction(), - controller.new NewQueryAction(), - new SaveSourceQueryAction(), - - new TruncateTableAction(), - new AdminAction(), - new ManageRemoteConnectionsAction(), - new ReloadExternalSchemaAction(), - new ReloadAllUserSchemas(), - controller.new ManageViewsAction(), - controller.new InternalDeleteView(), - controller.new InternalSourceViewAction(), - controller.new InternalNewViewAction(), - new QueryExportAuditRedirectAction() - ); - - // @RequiresPermission(AdminOperationsPermission.class) - assertForAdminOperationsPermission(user, - new EditRemoteConnectionAction(), - new DeleteRemoteConnectionAction(), - new TestRemoteConnectionAction(), - controller.new RawTableMetaDataAction(), - controller.new RawSchemaMetaDataAction(), - new InsertLinkedSchemaAction(), - new InsertExternalSchemaAction(), - new DeleteSchemaAction(), - new EditLinkedSchemaAction(), - new EditExternalSchemaAction(), - new GetTablesAction(), - new SchemaTemplateAction(), - new SchemaTemplatesAction(), - new ParseExpressionAction(), - new ParseQueryAction() - ); - - // @AdminConsoleAction - assertForAdminPermission(ContainerManager.getRoot(), user, - new DataSourceAdminAction() - ); - - // In addition to administrators (tested above), trusted analysts who are editors can create and edit queries - assertTrustedEditorPermission( - new DeleteQueryAction(), - controller.new MetadataQueryAction(), - controller.new NewQueryAction(), - new SaveSourceQueryAction() - ); - } - } - - public static class SaveRowsTestCase extends Assert - { - private static final String PROJECT_NAME1 = "SaveRowsTestProject1"; - private static final String PROJECT_NAME2 = "SaveRowsTestProject2"; - - private static final String USER_EMAIL = "saveRows@action.test"; - - private static final String LIST1 = "List1"; - private static final String LIST2 = "List2"; - - @Before - public void doSetup() throws Exception - { - doCleanup(); - - Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_NAME1, TestContext.get().getUser()); - Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_NAME2, TestContext.get().getUser()); - - //disable search so we dont get conflicts when deleting folder quickly - ContainerManager.updateSearchable(project1, false, TestContext.get().getUser()); - ContainerManager.updateSearchable(project2, false, TestContext.get().getUser()); - - ListDefinition ld1 = ListService.get().createList(project1, LIST1, ListDefinition.KeyType.Varchar); - ld1.getDomain().addProperty(new PropertyStorageSpec("TextField", JdbcType.VARCHAR)); - ld1.setKeyName("TextField"); - ld1.save(TestContext.get().getUser()); - - ListDefinition ld2 = ListService.get().createList(project2, LIST2, ListDefinition.KeyType.Varchar); - ld2.getDomain().addProperty(new PropertyStorageSpec("TextField", JdbcType.VARCHAR)); - ld2.setKeyName("TextField"); - ld2.save(TestContext.get().getUser()); - } - - @After - public void doCleanup() throws Exception - { - Container project = ContainerManager.getForPath(PROJECT_NAME1); - if (project != null) - { - ContainerManager.deleteAll(project, TestContext.get().getUser()); - } - - Container project2 = ContainerManager.getForPath(PROJECT_NAME2); - if (project2 != null) - { - ContainerManager.deleteAll(project2, TestContext.get().getUser()); - } - - User u = UserManager.getUser(new ValidEmail(USER_EMAIL)); - if (u != null) - { - UserManager.deleteUser(u.getUserId()); - } - } - - private JSONObject getCommand(String val1, String val2) - { - JSONObject command1 = new JSONObject(); - command1.put("containerPath", ContainerManager.getForPath(PROJECT_NAME1).getPath()); - command1.put("command", "insert"); - command1.put("schemaName", "lists"); - command1.put("queryName", LIST1); - command1.put("rows", getTestRows(val1)); - - JSONObject command2 = new JSONObject(); - command2.put("containerPath", ContainerManager.getForPath(PROJECT_NAME2).getPath()); - command2.put("command", "insert"); - command2.put("schemaName", "lists"); - command2.put("queryName", LIST2); - command2.put("rows", getTestRows(val2)); - - JSONObject json = new JSONObject(); - json.put("commands", Arrays.asList(command1, command2)); - - return json; - } - - private MockHttpServletResponse makeRequest(JSONObject json, User user) throws Exception - { - Map headers = new HashMap<>(); - headers.put("Content-Type", "application/json"); - - HttpServletRequest request = ViewServlet.mockRequest(RequestMethod.POST.name(), DetailsURL.fromString("/query/saveRows.view").copy(ContainerManager.getForPath(PROJECT_NAME1)).getActionURL(), user, headers, json.toString()); - return ViewServlet.mockDispatch(request, null); - } - - @Test - public void testCrossFolderSaveRows() throws Exception - { - User user = TestContext.get().getUser(); - assertTrue(user.hasSiteAdminPermission()); - - JSONObject json = getCommand(PROJECT_NAME1, PROJECT_NAME2); - MockHttpServletResponse response = makeRequest(json, TestContext.get().getUser()); - if (response.getStatus() != HttpServletResponse.SC_OK) - { - JSONObject responseJson = new JSONObject(response.getContentAsString()); - throw new RuntimeException("Problem saving rows across folders: " + responseJson.getString("exception")); - } - - Container project1 = ContainerManager.getForPath(PROJECT_NAME1); - Container project2 = ContainerManager.getForPath(PROJECT_NAME2); - - TableInfo list1 = ListService.get().getList(project1, LIST1).getTable(TestContext.get().getUser()); - TableInfo list2 = ListService.get().getList(project2, LIST2).getTable(TestContext.get().getUser()); - - assertEquals("Incorrect row count, list1", 1L, new TableSelector(list1).getRowCount()); - assertEquals("Incorrect row count, list2", 1L, new TableSelector(list2).getRowCount()); - - assertEquals("Incorrect value", PROJECT_NAME1, new TableSelector(list1, PageFlowUtil.set("TextField")).getObject(PROJECT_NAME1, String.class)); - assertEquals("Incorrect value", PROJECT_NAME2, new TableSelector(list2, PageFlowUtil.set("TextField")).getObject(PROJECT_NAME2, String.class)); - - list1.getUpdateService().truncateRows(TestContext.get().getUser(), project1, null, null); - list2.getUpdateService().truncateRows(TestContext.get().getUser(), project2, null, null); - } - - @Test - public void testWithoutPermissions() throws Exception - { - // Now test failure without appropriate permissions: - User withoutPermissions = SecurityManager.addUser(new ValidEmail(USER_EMAIL), TestContext.get().getUser()).getUser(); - - User user = TestContext.get().getUser(); - assertTrue(user.hasSiteAdminPermission()); - - Container project1 = ContainerManager.getForPath(PROJECT_NAME1); - Container project2 = ContainerManager.getForPath(PROJECT_NAME2); - - MutableSecurityPolicy securityPolicy = new MutableSecurityPolicy(SecurityPolicyManager.getPolicy(project1)); - securityPolicy.addRoleAssignment(withoutPermissions, EditorRole.class); - SecurityPolicyManager.savePolicyForTests(securityPolicy, TestContext.get().getUser()); - - assertTrue("Should have insert permission", project1.hasPermission(withoutPermissions, InsertPermission.class)); - assertFalse("Should not have insert permission", project2.hasPermission(withoutPermissions, InsertPermission.class)); - - // repeat insert: - JSONObject json = getCommand("ShouldFail1", "ShouldFail2"); - MockHttpServletResponse response = makeRequest(json, withoutPermissions); - if (response.getStatus() != HttpServletResponse.SC_FORBIDDEN) - { - JSONObject responseJson = new JSONObject(response.getContentAsString()); - throw new RuntimeException("Problem saving rows across folders: " + responseJson.getString("exception")); - } - - TableInfo list1 = ListService.get().getList(project1, LIST1).getTable(TestContext.get().getUser()); - TableInfo list2 = ListService.get().getList(project2, LIST2).getTable(TestContext.get().getUser()); - - // The insert should have failed - assertEquals("Incorrect row count, list1", 0L, new TableSelector(list1).getRowCount()); - assertEquals("Incorrect row count, list2", 0L, new TableSelector(list2).getRowCount()); - } - - private JSONArray getTestRows(String val) - { - JSONArray rows = new JSONArray(); - rows.put(Map.of("TextField", val)); - - return rows; - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed 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. + */ + +package org.labkey.query.controllers; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.antlr.runtime.tree.Tree; +import org.apache.commons.beanutils.ConversionException; +import org.apache.commons.beanutils.ConvertUtils; +import org.apache.commons.collections4.MultiValuedMap; +import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; +import org.apache.commons.collections4.multimap.HashSetValuedHashMap; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.commons.lang3.mutable.MutableInt; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.xmlbeans.XmlError; +import org.apache.xmlbeans.XmlException; +import org.apache.xmlbeans.XmlOptions; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONParserConfiguration; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.labkey.api.action.Action; +import org.labkey.api.action.ActionType; +import org.labkey.api.action.ApiJsonForm; +import org.labkey.api.action.ApiJsonWriter; +import org.labkey.api.action.ApiQueryResponse; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiResponseWriter; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.action.ApiVersion; +import org.labkey.api.action.ConfirmAction; +import org.labkey.api.action.ExportAction; +import org.labkey.api.action.ExportException; +import org.labkey.api.action.ExtendedApiQueryResponse; +import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.FormViewAction; +import org.labkey.api.action.HasBindParameters; +import org.labkey.api.action.JsonInputLimit; +import org.labkey.api.action.LabKeyError; +import org.labkey.api.action.Marshal; +import org.labkey.api.action.Marshaller; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.NullSafeBindException; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.action.ReportingApiQueryResponse; +import org.labkey.api.action.SimpleApiJsonForm; +import org.labkey.api.action.SimpleErrorView; +import org.labkey.api.action.SimpleRedirectAction; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.AdminUrls; +import org.labkey.api.attachments.SpringAttachmentFile; +import org.labkey.api.audit.AbstractAuditTypeProvider; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.AuditTypeEvent; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.audit.provider.ContainerAuditProvider; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.collections.CaseInsensitiveHashSet; +import org.labkey.api.collections.IntHashMap; +import org.labkey.api.collections.LabKeyCollectors; +import org.labkey.api.collections.RowMapFactory; +import org.labkey.api.collections.Sets; +import org.labkey.api.data.AbstractTableInfo; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.Aggregate; +import org.labkey.api.data.AnalyticsProviderItem; +import org.labkey.api.data.BaseColumnInfo; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.CachedResultSets; +import org.labkey.api.data.ColumnHeaderType; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.ContainerType; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbSchemaType; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.ExcelWriter; +import org.labkey.api.data.ForeignKey; +import org.labkey.api.data.JdbcMetaDataSelector; +import org.labkey.api.data.JdbcType; +import org.labkey.api.data.JsonWriter; +import org.labkey.api.data.PHI; +import org.labkey.api.data.PropertyManager; +import org.labkey.api.data.PropertyManager.PropertyMap; +import org.labkey.api.data.PropertyManager.WritablePropertyMap; +import org.labkey.api.data.PropertyStorageSpec; +import org.labkey.api.data.QueryLogging; +import org.labkey.api.data.ResultSetView; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SchemaTableInfo; +import org.labkey.api.data.ShowRows; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TSVWriter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.data.VirtualTable; +import org.labkey.api.data.dialect.JdbcMetaDataLocator; +import org.labkey.api.data.dialect.SqlDialect; +import org.labkey.api.dataiterator.DataIteratorBuilder; +import org.labkey.api.dataiterator.DataIteratorContext; +import org.labkey.api.dataiterator.DataIteratorUtil; +import org.labkey.api.dataiterator.DetailedAuditLogDataIterator; +import org.labkey.api.dataiterator.ListofMapsDataIterator; +import org.labkey.api.exceptions.OptimisticConflictException; +import org.labkey.api.exp.ExperimentException; +import org.labkey.api.exp.api.ProvenanceRecordingParams; +import org.labkey.api.exp.api.ProvenanceService; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.exp.list.ListService; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainAuditProvider; +import org.labkey.api.exp.property.DomainKind; +import org.labkey.api.exp.property.PropertyService; +import org.labkey.api.files.FileContentService; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.RecordedAction; +import org.labkey.api.query.AbstractQueryImportAction; +import org.labkey.api.query.AbstractQueryUpdateService; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.CustomView; +import org.labkey.api.query.DefaultSchema; +import org.labkey.api.query.DetailsURL; +import org.labkey.api.query.DuplicateKeyException; +import org.labkey.api.query.ExportScriptModel; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.FilteredTable; +import org.labkey.api.query.InvalidKeyException; +import org.labkey.api.query.MetadataUnavailableException; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryDefinition; +import org.labkey.api.query.QueryException; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryParam; +import org.labkey.api.query.QueryParseException; +import org.labkey.api.query.QuerySchema; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryUpdateForm; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.QueryUrls; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.RuntimeValidationException; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.SimpleSchemaTreeVisitor; +import org.labkey.api.query.TempQuerySettings; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.UserSchemaAction; +import org.labkey.api.query.ValidationException; +import org.labkey.api.reports.report.ReportDescriptor; +import org.labkey.api.security.ActionNames; +import org.labkey.api.security.AdminConsoleAction; +import org.labkey.api.security.CSRF; +import org.labkey.api.security.IgnoresTermsOfUse; +import org.labkey.api.security.MutableSecurityPolicy; +import org.labkey.api.security.RequiresAllOf; +import org.labkey.api.security.RequiresAnyOf; +import org.labkey.api.security.RequiresNoPermission; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.SecurityManager; +import org.labkey.api.security.SecurityPolicyManager; +import org.labkey.api.security.User; +import org.labkey.api.security.UserManager; +import org.labkey.api.security.ValidEmail; +import org.labkey.api.security.permissions.AbstractActionPermissionTest; +import org.labkey.api.security.permissions.AdminOperationsPermission; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.EditSharedViewPermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.MoveEntitiesPermission; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.permissions.PlatformDeveloperPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.settings.AdminConsole; +import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.LookAndFeelProperties; +import org.labkey.api.stats.BaseAggregatesAnalyticsProvider; +import org.labkey.api.stats.ColumnAnalyticsProvider; +import org.labkey.api.util.ButtonBuilder; +import org.labkey.api.util.DOM; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.JavaScriptFragment; +import org.labkey.api.util.JsonUtil; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.ResponseHelper; +import org.labkey.api.util.ReturnURLString; +import org.labkey.api.util.StringExpression; +import org.labkey.api.util.TestContext; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.UnexpectedException; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DetailsView; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.InsertView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.UpdateView; +import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.ViewServlet; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.PageConfig; +import org.labkey.api.writer.HtmlWriter; +import org.labkey.api.writer.ZipFile; +import org.labkey.data.xml.ColumnType; +import org.labkey.data.xml.ImportTemplateType; +import org.labkey.data.xml.TableType; +import org.labkey.data.xml.TablesDocument; +import org.labkey.data.xml.TablesType; +import org.labkey.data.xml.externalSchema.TemplateSchemaType; +import org.labkey.data.xml.queryCustomView.FilterType; +import org.labkey.query.AutoGeneratedDetailsCustomView; +import org.labkey.query.AutoGeneratedInsertCustomView; +import org.labkey.query.AutoGeneratedUpdateCustomView; +import org.labkey.query.CustomViewImpl; +import org.labkey.query.CustomViewUtil; +import org.labkey.query.EditQueriesPermission; +import org.labkey.query.EditableCustomView; +import org.labkey.query.LinkedTableInfo; +import org.labkey.query.MetadataTableJSON; +import org.labkey.query.ModuleCustomQueryDefinition; +import org.labkey.query.ModuleCustomView; +import org.labkey.query.QueryServiceImpl; +import org.labkey.query.TableXML; +import org.labkey.query.audit.QueryExportAuditProvider; +import org.labkey.query.audit.QueryUpdateAuditProvider; +import org.labkey.query.model.MetadataTableJSONMixin; +import org.labkey.query.persist.AbstractExternalSchemaDef; +import org.labkey.query.persist.CstmView; +import org.labkey.query.persist.ExternalSchemaDef; +import org.labkey.query.persist.ExternalSchemaDefCache; +import org.labkey.query.persist.LinkedSchemaDef; +import org.labkey.query.persist.QueryDef; +import org.labkey.query.persist.QueryManager; +import org.labkey.query.reports.ReportsController; +import org.labkey.query.reports.getdata.DataRequest; +import org.labkey.query.sql.QNode; +import org.labkey.query.sql.Query; +import org.labkey.query.sql.SqlParser; +import org.labkey.query.xml.ApiTestsDocument; +import org.labkey.query.xml.TestCaseType; +import org.labkey.remoteapi.RemoteConnections; +import org.labkey.remoteapi.SelectRowsStreamHack; +import org.labkey.remoteapi.query.SelectRowsCommand; +import org.labkey.vfs.FileLike; +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.PropertyValue; +import org.springframework.beans.PropertyValues; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.ModelAndView; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.trimToEmpty; +import static org.labkey.api.action.ApiJsonWriter.CONTENT_TYPE_JSON; +import static org.labkey.api.assay.AssayFileWriter.ensureUploadDirectory; +import static org.labkey.api.data.DbScope.NO_OP_TRANSACTION; +import static org.labkey.api.query.AbstractQueryUpdateService.saveFile; +import static org.labkey.api.util.DOM.BR; +import static org.labkey.api.util.DOM.DIV; +import static org.labkey.api.util.DOM.FONT; +import static org.labkey.api.util.DOM.Renderable; +import static org.labkey.api.util.DOM.TABLE; +import static org.labkey.api.util.DOM.TD; +import static org.labkey.api.util.DOM.TR; +import static org.labkey.api.util.DOM.at; +import static org.labkey.api.util.DOM.cl; +import static org.labkey.query.MetadataTableJSON.getTableType; +import static org.labkey.query.MetadataTableJSON.parseDocument; + +@SuppressWarnings("DefaultAnnotationParam") + +public class QueryController extends SpringActionController +{ + private static final Logger LOG = LogManager.getLogger(QueryController.class); + private static final String ROW_ATTACHMENT_INDEX_DELIM = "::"; + + private static final Set RESERVED_VIEW_NAMES = CaseInsensitiveHashSet.of( + "Default", + AutoGeneratedDetailsCustomView.NAME, + AutoGeneratedInsertCustomView.NAME, + AutoGeneratedUpdateCustomView.NAME + ); + + private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(QueryController.class, + ValidateQueryAction.class, + ValidateQueriesAction.class, + GetSchemaQueryTreeAction.class, + GetQueryDetailsAction.class, + ViewQuerySourceAction.class + ); + + public QueryController() + { + setActionResolver(_actionResolver); + } + + public static void registerAdminConsoleLinks() + { + AdminConsole.addLink(AdminConsole.SettingsLinkType.Diagnostics, "data sources", new ActionURL(DataSourceAdminAction.class, ContainerManager.getRoot())); + } + + public static class RemoteQueryConnectionUrls + { + public static ActionURL urlManageRemoteConnection(Container c) + { + return new ActionURL(ManageRemoteConnectionsAction.class, c); + } + + public static ActionURL urlCreateRemoteConnection(Container c) + { + return new ActionURL(EditRemoteConnectionAction.class, c); + } + + public static ActionURL urlEditRemoteConnection(Container c, String connectionName) + { + ActionURL url = new ActionURL(EditRemoteConnectionAction.class, c); + url.addParameter("connectionName", connectionName); + return url; + } + + public static ActionURL urlSaveRemoteConnection(Container c) + { + return new ActionURL(EditRemoteConnectionAction.class, c); + } + + public static ActionURL urlDeleteRemoteConnection(Container c, @Nullable String connectionName) + { + ActionURL url = new ActionURL(DeleteRemoteConnectionAction.class, c); + if (connectionName != null) + url.addParameter("connectionName", connectionName); + return url; + } + + public static ActionURL urlTestRemoteConnection(Container c, String connectionName) + { + ActionURL url = new ActionURL(TestRemoteConnectionAction.class, c); + url.addParameter("connectionName", connectionName); + return url; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class EditRemoteConnectionAction extends FormViewAction + { + @Override + public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) + { + remoteConnectionForm.setConnectionKind(RemoteConnections.CONNECTION_KIND_QUERY); + if (!errors.hasErrors()) + { + String name = remoteConnectionForm.getConnectionName(); + // package the remote-connection properties into the remoteConnectionForm and pass them along + Map map1 = RemoteConnections.getRemoteConnection(RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY, name, getContainer()); + remoteConnectionForm.setUrl(map1.get("URL")); + remoteConnectionForm.setUserEmail(map1.get("user")); + remoteConnectionForm.setPassword(map1.get("password")); + remoteConnectionForm.setFolderPath(map1.get("container")); + } + setHelpTopic("remoteConnection"); + return new JspView<>("/org/labkey/query/view/createRemoteConnection.jsp", remoteConnectionForm, errors); + } + + @Override + public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) + { + return RemoteConnections.createOrEditRemoteConnection(remoteConnectionForm, getContainer(), errors); + } + + @Override + public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) + { + return RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Create/Edit Remote Connection", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class DeleteRemoteConnectionAction extends FormViewAction + { + @Override + public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/query/view/confirmDeleteConnection.jsp", remoteConnectionForm, errors); + } + + @Override + public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) + { + remoteConnectionForm.setConnectionKind(RemoteConnections.CONNECTION_KIND_QUERY); + return RemoteConnections.deleteRemoteConnection(remoteConnectionForm, getContainer()); + } + + @Override + public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) + { + return RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Confirm Delete Connection", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class TestRemoteConnectionAction extends FormViewAction + { + @Override + public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) + { + String name = remoteConnectionForm.getConnectionName(); + String schemaName = "core"; // test Schema Name + String queryName = "Users"; // test Query Name + + // Extract the username, password, and container from the secure property store + Map singleConnectionMap = RemoteConnections.getRemoteConnection(RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY, name, getContainer()); + if (singleConnectionMap.isEmpty()) + throw new NotFoundException(); + String url = singleConnectionMap.get(RemoteConnections.FIELD_URL); + String user = singleConnectionMap.get(RemoteConnections.FIELD_USER); + String password = singleConnectionMap.get(RemoteConnections.FIELD_PASSWORD); + String container = singleConnectionMap.get(RemoteConnections.FIELD_CONTAINER); + + // connect to the remote server and retrieve an input stream + org.labkey.remoteapi.Connection cn = new org.labkey.remoteapi.Connection(url, user, password); + final SelectRowsCommand cmd = new SelectRowsCommand(schemaName, queryName); + try + { + DataIteratorBuilder source = SelectRowsStreamHack.go(cn, container, cmd, getContainer()); + // immediately close the source after opening it, this is a test. + source.getDataIterator(new DataIteratorContext()).close(); + } + catch (Exception e) + { + errors.addError(new LabKeyError("The listed credentials for this remote connection failed to connect.")); + return new JspView<>("/org/labkey/query/view/testRemoteConnectionsFailure.jsp", remoteConnectionForm); + } + + return new JspView<>("/org/labkey/query/view/testRemoteConnectionsSuccess.jsp", remoteConnectionForm); + } + + @Override + public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) + { + return true; + } + + @Override + public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Manage Remote Connections", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + public static class QueryUrlsImpl implements QueryUrls + { + @Override + public ActionURL urlSchemaBrowser(Container c) + { + return new ActionURL(BeginAction.class, c); + } + + @Override + public ActionURL urlSchemaBrowser(Container c, @Nullable String schemaName) + { + ActionURL ret = urlSchemaBrowser(c); + if (schemaName != null) + { + ret.addParameter(QueryParam.schemaName.toString(), schemaName); + } + return ret; + } + + @Override + public ActionURL urlSchemaBrowser(Container c, @Nullable String schemaName, @Nullable String queryName) + { + if (StringUtils.isEmpty(queryName)) + return urlSchemaBrowser(c, schemaName); + ActionURL ret = urlSchemaBrowser(c); + ret.addParameter(QueryParam.schemaName.toString(), trimToEmpty(schemaName)); + ret.addParameter(QueryParam.queryName.toString(), trimToEmpty(queryName)); + return ret; + } + + public ActionURL urlExternalSchemaAdmin(Container c) + { + return urlExternalSchemaAdmin(c, null); + } + + public ActionURL urlExternalSchemaAdmin(Container c, @Nullable String message) + { + ActionURL url = new ActionURL(AdminAction.class, c); + + if (null != message) + url.addParameter("message", message); + + return url; + } + + public ActionURL urlInsertExternalSchema(Container c) + { + return new ActionURL(InsertExternalSchemaAction.class, c); + } + + public ActionURL urlNewQuery(Container c) + { + return new ActionURL(NewQueryAction.class, c); + } + + public ActionURL urlUpdateExternalSchema(Container c, AbstractExternalSchemaDef def) + { + ActionURL url = new ActionURL(EditExternalSchemaAction.class, c); + url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); + return url; + } + + public ActionURL urlReloadExternalSchema(Container c, AbstractExternalSchemaDef def) + { + ActionURL url = new ActionURL(ReloadExternalSchemaAction.class, c); + url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); + return url; + } + + public ActionURL urlDeleteSchema(Container c, AbstractExternalSchemaDef def) + { + ActionURL url = new ActionURL(DeleteSchemaAction.class, c); + url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); + return url; + } + + @Override + public ActionURL urlStartBackgroundRReport(@NotNull ActionURL baseURL, String reportId) + { + ActionURL result = baseURL.clone(); + result.setAction(ReportsController.StartBackgroundRReportAction.class); + result.replaceParameter(ReportDescriptor.Prop.reportId, reportId); + return result; + } + + @Override + public ActionURL urlExecuteQuery(@NotNull ActionURL baseURL) + { + ActionURL result = baseURL.clone(); + result.setAction(ExecuteQueryAction.class); + return result; + } + + @Override + public ActionURL urlExecuteQuery(Container c, String schemaName, String queryName) + { + return new ActionURL(ExecuteQueryAction.class, c) + .addParameter(QueryParam.schemaName, schemaName) + .addParameter(QueryParam.queryName, queryName); + } + + @Override + public @NotNull ActionURL urlCreateExcelTemplate(Container c, String schemaName, String queryName) + { + return new ActionURL(ExportExcelTemplateAction.class, c) + .addParameter(QueryParam.schemaName, schemaName) + .addParameter("query.queryName", queryName); + } + + @Override + public ActionURL urlMetadataQuery(Container c, String schemaName, String queryName) + { + return new ActionURL(MetadataQueryAction.class, c) + .addParameter(QueryParam.schemaName, schemaName) + .addParameter(QueryParam.queryName, queryName); + } + } + + @Override + public PageConfig defaultPageConfig() + { + // set default help topic for query controller + PageConfig config = super.defaultPageConfig(); + config.setHelpTopic("querySchemaBrowser"); + return config; + } + + @AdminConsoleAction(AdminOperationsPermission.class) + public static class DataSourceAdminAction extends SimpleViewAction + { + public DataSourceAdminAction() + { + } + + public DataSourceAdminAction(ViewContext viewContext) + { + setViewContext(viewContext); + } + + @Override + public ModelAndView getView(Object o, BindException errors) + { + // Site Admin or Troubleshooter? Troubleshooters can see all the information but can't test data sources. + boolean hasAdminOpsPerms = getContainer().hasPermission(getUser(), AdminOperationsPermission.class); + List allDefs = QueryManager.get().getExternalSchemaDefs(null); + + MultiValuedMap byDataSourceName = new ArrayListValuedHashMap<>(); + + for (ExternalSchemaDef def : allDefs) + byDataSourceName.put(def.getDataSource(), def); + + MutableInt row = new MutableInt(); + + Renderable r = DOM.DIV( + DIV("This page lists all the data sources defined in your " + AppProps.getInstance().getWebappConfigurationFilename() + " file that were available when first referenced and the external schemas defined in each."), + BR(), + TABLE(cl("labkey-data-region"), + TR(cl("labkey-show-borders"), + hasAdminOpsPerms ? TD(cl("labkey-column-header"), "Test") : null, + TD(cl("labkey-column-header"), "Data Source"), + TD(cl("labkey-column-header"), "Current Status"), + TD(cl("labkey-column-header"), "URL"), + TD(cl("labkey-column-header"), "Database Name"), + TD(cl("labkey-column-header"), "Product Name"), + TD(cl("labkey-column-header"), "Product Version"), + TD(cl("labkey-column-header"), "Max Connections"), + TD(cl("labkey-column-header"), "Active Connections"), + TD(cl("labkey-column-header"), "Idle Connections"), + TD(cl("labkey-column-header"), "Max Wait (ms)") + ), + DbScope.getDbScopes().stream() + .flatMap(scope -> { + String rowStyle = row.getAndIncrement() % 2 == 0 ? "labkey-alternate-row labkey-show-borders" : "labkey-row labkey-show-borders"; + Object status; + boolean connected = false; + try (Connection ignore = scope.getConnection()) + { + status = "connected"; + connected = true; + } + catch (Exception e) + { + status = FONT(cl("labkey-error"), "disconnected"); + } + + return Stream.of( + TR( + cl(rowStyle), + hasAdminOpsPerms ? TD(connected ? new ButtonBuilder("Test").href(new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", scope.getDataSourceName())) : "") : null, + TD(HtmlString.NBSP, scope.getDisplayName()), + TD(status), + TD(scope.getDatabaseUrl()), + TD(scope.getDatabaseName()), + TD(scope.getDatabaseProductName()), + TD(scope.getDatabaseProductVersion()), + TD(scope.getDataSourceProperties().getMaxTotal()), + TD(scope.getDataSourceProperties().getNumActive()), + TD(scope.getDataSourceProperties().getNumIdle()), + TD(scope.getDataSourceProperties().getMaxWaitMillis()) + ), + TR( + cl(rowStyle), + TD(HtmlString.NBSP), + TD(at(DOM.Attribute.colspan, 10), getDataSourceTable(byDataSourceName.get(scope.getDataSourceName()))) + ) + ); + }) + ) + ); + + return new HtmlView(r); + } + + private Renderable getDataSourceTable(Collection dsDefs) + { + if (dsDefs.isEmpty()) + return TABLE(TR(TD(HtmlString.NBSP))); + + MultiValuedMap byContainerPath = new ArrayListValuedHashMap<>(); + + for (ExternalSchemaDef def : dsDefs) + byContainerPath.put(def.getContainerPath(), def); + + TreeSet paths = new TreeSet<>(byContainerPath.keySet()); + + return TABLE(paths.stream() + .map(path -> TR(TD(at(DOM.Attribute.colspan, 4), getDataSourcePath(path, byContainerPath.get(path))))) + ); + } + + private Renderable getDataSourcePath(String path, Collection unsorted) + { + List defs = new ArrayList<>(unsorted); + defs.sort(Comparator.comparing(AbstractExternalSchemaDef::getUserSchemaName, String.CASE_INSENSITIVE_ORDER)); + Container c = ContainerManager.getForPath(path); + + if (null == c) + return TD(); + + boolean hasRead = c.hasPermission(getUser(), ReadPermission.class); + QueryUrlsImpl urls = new QueryUrlsImpl(); + + return + TD(TABLE( + TR(TD( + at(DOM.Attribute.colspan, 3), + hasRead ? LinkBuilder.simpleLink(path, urls.urlExternalSchemaAdmin(c)) : path + )), + TR(TD(TABLE( + defs.stream() + .map(def -> TR(TD( + at(DOM.Attribute.style, "padding-left:20px"), + hasRead ? LinkBuilder.simpleLink(def.getUserSchemaName() + + (!Strings.CS.equals(def.getSourceSchemaName(), def.getUserSchemaName()) ? " (" + def.getSourceSchemaName() + ")" : ""), urls.urlUpdateExternalSchema(c, def)) + : def.getUserSchemaName() + ))) + ))) + )); + } + + @Override + public void addNavTrail(NavTree root) + { + urlProvider(AdminUrls.class).addAdminNavTrail(root, "Data Source Administration", getClass(), getContainer()); + } + } + + public static class TestDataSourceForm + { + private String _dataSource; + + public String getDataSource() + { + return _dataSource; + } + + @SuppressWarnings("unused") + public void setDataSource(String dataSource) + { + _dataSource = dataSource; + } + } + + public static class TestDataSourceConfirmForm extends TestDataSourceForm + { + private String _excludeSchemas; + private String _excludeTables; + + public String getExcludeSchemas() + { + return _excludeSchemas; + } + + @SuppressWarnings("unused") + public void setExcludeSchemas(String excludeSchemas) + { + _excludeSchemas = excludeSchemas; + } + + public String getExcludeTables() + { + return _excludeTables; + } + + @SuppressWarnings("unused") + public void setExcludeTables(String excludeTables) + { + _excludeTables = excludeTables; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class TestDataSourceConfirmAction extends FormViewAction + { + private DbScope _scope; + + @Override + public ModelAndView getView(TestDataSourceConfirmForm form, boolean reshow, BindException errors) throws Exception + { + validateCommand(form, errors); + return new JspView<>("/org/labkey/query/view/testDataSourceConfirm.jsp", _scope); + } + + @Override + public void validateCommand(TestDataSourceConfirmForm form, Errors errors) + { + _scope = DbScope.getDbScope(form.getDataSource()); + + if (null == _scope) + throw new NotFoundException("Could not resolve data source " + form.getDataSource()); + } + + @Override + public boolean handlePost(TestDataSourceConfirmForm form, BindException errors) throws Exception + { + saveTestDataSourceProperties(form); + return true; + } + + @Override + public URLHelper getSuccessURL(TestDataSourceConfirmForm form) + { + return new ActionURL(TestDataSourceAction.class, getContainer()).addParameter("dataSource", _scope.getDataSourceName()); + } + + @Override + public void addNavTrail(NavTree root) + { + new DataSourceAdminAction(getViewContext()).addNavTrail(root); + root.addChild("Prepare Test of " + _scope.getDataSourceName()); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class TestDataSourceAction extends SimpleViewAction + { + private DbScope _scope; + + @Override + public ModelAndView getView(TestDataSourceForm form, BindException errors) + { + _scope = DbScope.getDbScope(form.getDataSource()); + + if (null == _scope) + throw new NotFoundException("Could not resolve data source " + form.getDataSource()); + + return new JspView<>("/org/labkey/query/view/testDataSource.jsp", _scope); + } + + @Override + public void addNavTrail(NavTree root) + { + new DataSourceAdminAction(getViewContext()).addNavTrail(root); + root.addChild("Test " + _scope.getDataSourceName()); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class ResetDataSourcePropertiesAction extends FormHandlerAction + { + @Override + public void validateCommand(TestDataSourceForm target, Errors errors) + { + } + + @Override + public boolean handlePost(TestDataSourceForm form, BindException errors) throws Exception + { + WritablePropertyMap map = PropertyManager.getWritableProperties(getCategory(form.getDataSource()), false); + if (map != null) + map.delete(); + return true; + } + + @Override + public URLHelper getSuccessURL(TestDataSourceForm form) + { + return new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", form.getDataSource()) ; + } + } + + private static final String TEST_DATA_SOURCE_CATEGORY = "testDataSourceProperties"; + private static final String TEST_DATA_SOURCE_SCHEMAS_PROPERTY = "excludeSchemas"; + private static final String TEST_DATA_SOURCE_TABLES_PROPERTY = "excludeTables"; + + private static String getCategory(String dataSourceName) + { + return TEST_DATA_SOURCE_CATEGORY + "|" + dataSourceName; + } + + public static void saveTestDataSourceProperties(TestDataSourceConfirmForm form) + { + WritablePropertyMap map = PropertyManager.getWritableProperties(getCategory(form.getDataSource()), true); + // Save empty entries as empty string to distinguish from null (which results in default values) + map.put(TEST_DATA_SOURCE_SCHEMAS_PROPERTY, StringUtils.trimToEmpty(form.getExcludeSchemas())); + map.put(TEST_DATA_SOURCE_TABLES_PROPERTY, StringUtils.trimToEmpty(form.getExcludeTables())); + map.save(); + } + + public static TestDataSourceConfirmForm getTestDataSourceProperties(DbScope scope) + { + TestDataSourceConfirmForm form = new TestDataSourceConfirmForm(); + PropertyMap map = PropertyManager.getProperties(getCategory(scope.getDataSourceName())); + form.setExcludeSchemas(map.getOrDefault(TEST_DATA_SOURCE_SCHEMAS_PROPERTY, scope.getSqlDialect().getDefaultSchemasToExcludeFromTesting())); + form.setExcludeTables(map.getOrDefault(TEST_DATA_SOURCE_TABLES_PROPERTY, scope.getSqlDialect().getDefaultTablesToExcludeFromTesting())); + + return form; + } + + @RequiresPermission(ReadPermission.class) + public static class BrowseAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/query/view/browse.jsp", null); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Schema Browser"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class BeginAction extends QueryViewAction + { + @SuppressWarnings("UnusedDeclaration") + public BeginAction() + { + } + + public BeginAction(ViewContext ctx) + { + setViewContext(ctx); + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + JspView view = new JspView<>("/org/labkey/query/view/browse.jsp", form); + view.setFrame(WebPartView.FrameType.NONE); + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Query Schema Browser", new QueryUrlsImpl().urlSchemaBrowser(getContainer())); + } + } + + @RequiresPermission(ReadPermission.class) + public class SchemaAction extends QueryViewAction + { + public SchemaAction() {} + + SchemaAction(QueryForm form) + { + _form = form; + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + _form = form; + return new JspView<>("/org/labkey/query/view/browse.jsp", form); + } + + @Override + public void addNavTrail(NavTree root) + { + if (_form != null && _form.getSchema() != null) + addSchemaActionNavTrail(root, _form.getSchema().getSchemaPath(), _form.getQueryName()); + } + } + + + void addSchemaActionNavTrail(NavTree root, SchemaKey schemaKey, String queryName) + { + if (getContainer().hasOneOf(getUser(), AdminPermission.class, PlatformDeveloperPermission.class)) + { + // Don't show the full query nav trail to non-admin/non-developer users as they almost certainly don't + // want it + try + { + String schemaName = schemaKey.toDisplayString(); + ActionURL url = new ActionURL(BeginAction.class, getContainer()); + url.addParameter("schemaName", schemaKey.toString()); + url.addParameter("queryName", queryName); + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild(schemaName + " Schema", url); + } + catch (NullPointerException e) + { + LOG.error("NullPointerException in addNavTrail", e); + } + } + } + + + // Trusted analysts who are editors can create and modify queries + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + @Action(ActionType.SelectData.class) + public class NewQueryAction extends FormViewAction + { + private NewQueryForm _form; + private ActionURL _successUrl; + + @Override + public void validateCommand(NewQueryForm target, org.springframework.validation.Errors errors) + { + target.ff_newQueryName = StringUtils.trimToNull(target.ff_newQueryName); + if (null == target.ff_newQueryName) + errors.reject(ERROR_MSG, "QueryName is required"); + } + + @Override + public ModelAndView getView(NewQueryForm form, boolean reshow, BindException errors) + { + form.ensureSchemaExists(); + + if (!form.getSchema().canCreate()) + { + throw new UnauthorizedException(); + } + + getPageConfig().setFocusId("ff_newQueryName"); + _form = form; + setHelpTopic("sqlTutorial"); + return new JspView<>("/org/labkey/query/view/newQuery.jsp", form, errors); + } + + @Override + public boolean handlePost(NewQueryForm form, BindException errors) + { + form.ensureSchemaExists(); + + if (!form.getSchema().canCreate()) + { + throw new UnauthorizedException(); + } + + try + { + if (StringUtils.isEmpty(form.ff_baseTableName)) + { + errors.reject(ERROR_MSG, "You must select a base table or query name."); + return false; + } + + UserSchema schema = form.getSchema(); + String newQueryName = form.ff_newQueryName; + QueryDef existing = QueryManager.get().getQueryDef(getContainer(), form.getSchemaName(), newQueryName, true); + if (existing != null) + { + errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists."); + return false; + } + TableInfo existingTable = form.getSchema().getTable(newQueryName, null); + if (existingTable != null) + { + errors.reject(ERROR_MSG, "A table with the name '" + newQueryName + "' already exists."); + return false; + } + // bug 6095 -- conflicting query and dataset names + if (form.getSchema().getTableNames().contains(newQueryName)) + { + errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists as a table"); + return false; + } + QueryDefinition newDef = QueryService.get().createQueryDef(getUser(), getContainer(), form.getSchemaKey(), form.ff_newQueryName); + Query query = new Query(schema); + query.setRootTable(FieldKey.fromParts(form.ff_baseTableName)); + String sql = query.getQueryText(); + if (null == sql) + sql = "SELECT * FROM \"" + form.ff_baseTableName + "\""; + newDef.setSql(sql); + + try + { + newDef.save(getUser(), getContainer()); + } + catch (SQLException x) + { + if (RuntimeSQLException.isConstraintException(x)) + { + errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists."); + return false; + } + else + { + throw x; + } + } + + _successUrl = newDef.urlFor(form.ff_redirect); + return true; + } + catch (Exception e) + { + ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), e); + errors.reject(ERROR_MSG, Objects.toString(e.getMessage(), e.toString())); + return false; + } + } + + @Override + public ActionURL getSuccessURL(NewQueryForm newQueryForm) + { + return _successUrl; + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + root.addChild("New Query", new QueryUrlsImpl().urlNewQuery(getContainer())); + } + } + + // CONSIDER : deleting this action after the SQL editor UI changes are finalized, keep in mind that built-in views + // use this view as well via the edit metadata page. + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) // Note: This action deals with just meta data; it AJAXes data into place using GetWebPartAction + public class SourceQueryAction extends SimpleViewAction + { + public SourceForm _form; + public UserSchema _schema; + public QueryDefinition _queryDef; + + + @Override + public void validate(SourceForm target, BindException errors) + { + _form = target; + if (StringUtils.isEmpty(target.getSchemaName())) + throw new NotFoundException("schema name not specified"); + if (StringUtils.isEmpty(target.getQueryName())) + throw new NotFoundException("query name not specified"); + + QuerySchema querySchema = DefaultSchema.get(getUser(), getContainer(), _form.getSchemaKey()); + if (null == querySchema) + throw new NotFoundException("schema not found: " + _form.getSchemaKey().toDisplayString()); + if (!(querySchema instanceof UserSchema)) + throw new NotFoundException("Could not find the schema '" + _form.getSchemaName() + "' in the folder '" + getContainer().getPath() + "'"); + _schema = (UserSchema)querySchema; + } + + + @Override + public ModelAndView getView(SourceForm form, BindException errors) + { + _queryDef = _schema.getQueryDef(form.getQueryName()); + if (null == _queryDef) + _queryDef = _schema.getQueryDefForTable(form.getQueryName()); + if (null == _queryDef) + throw new NotFoundException("Could not find the query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); + + try + { + if (form.ff_queryText == null) + { + form.ff_queryText = _queryDef.getSql(); + form.ff_metadataText = _queryDef.getMetadataXml(); + if (null == form.ff_metadataText) + form.ff_metadataText = form.getDefaultMetadataText(); + } + + for (QueryException qpe : _queryDef.getParseErrors(_schema)) + { + errors.reject(ERROR_MSG, Objects.toString(qpe.getMessage(), qpe.toString())); + } + } + catch (Exception e) + { + try + { + ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), e); + } + catch (Throwable t) + { + // + } + errors.reject("ERROR_MSG", e.toString()); + LOG.error("Error", e); + } + + Renderable moduleWarning = null; + if (_queryDef instanceof ModuleCustomQueryDefinition mcqd && _queryDef.canEdit(getUser())) + { + moduleWarning = DIV(cl("labkey-warning-messages"), + "This SQL query is defined in the '" + mcqd.getModuleName() + "' module in directory '" + mcqd.getSqlFile().getParent() + "'.", + BR(), + "Changes to this query will be reflected in all usages across different folders on the server." + ); + } + + var sourceQueryView = new JspView<>("/org/labkey/query/view/sourceQuery.jsp", this, errors); + WebPartView ret = sourceQueryView; + if (null != moduleWarning) + ret = new VBox(new HtmlView(moduleWarning), sourceQueryView); + return ret; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("useSqlEditor"); + + addSchemaActionNavTrail(root, _form.getSchemaKey(), _form.getQueryName()); + + root.addChild("Edit " + _form.getQueryName()); + } + } + + + /** + * Ajax action to save a query. If the save is successful the request will return successfully. A query + * with SQL syntax errors can still be saved successfully. + * + * If the SQL contains parse errors, a parseErrors object will be returned which contains an array of + * JSON serialized error information. + */ + // Trusted analysts who are editors can create and modify queries + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + @Action(ActionType.Configure.class) + public static class SaveSourceQueryAction extends MutatingApiAction + { + private UserSchema _schema; + + @Override + public void validateForm(SourceForm form, Errors errors) + { + if (StringUtils.isEmpty(form.getSchemaName())) + throw new NotFoundException("Query definition not found, schemaName and queryName are required."); + if (StringUtils.isEmpty(form.getQueryName())) + throw new NotFoundException("Query definition not found, schemaName and queryName are required."); + + QuerySchema querySchema = DefaultSchema.get(getUser(), getContainer(), form.getSchemaKey()); + if (null == querySchema) + throw new NotFoundException("schema not found: " + form.getSchemaKey().toDisplayString()); + if (!(querySchema instanceof UserSchema)) + throw new NotFoundException("Could not find the schema '" + form.getSchemaName() + "' in the folder '" + getContainer().getPath() + "'"); + _schema = (UserSchema)querySchema; + + XmlOptions options = XmlBeansUtil.getDefaultParseOptions(); + List xmlErrors = new ArrayList<>(); + options.setErrorListener(xmlErrors); + try + { + // had a couple of real-world failures due to null pointers in this code, so it's time to be paranoid + if (form.ff_metadataText != null) + { + TablesDocument tablesDoc = TablesDocument.Factory.parse(form.ff_metadataText, options); + if (tablesDoc != null) + { + tablesDoc.validate(options); + TablesType tablesType = tablesDoc.getTables(); + if (tablesType != null) + { + for (TableType tableType : tablesType.getTableArray()) + { + if (null != tableType) + { + if (!Objects.equals(tableType.getTableName(), form.getQueryName())) + { + errors.reject(ERROR_MSG, "Table name in the XML metadata must match the table/query name: " + form.getQueryName()); + } + + TableType.Columns tableColumns = tableType.getColumns(); + if (null != tableColumns) + { + ColumnType[] tableColumnArray = tableColumns.getColumnArray(); + for (ColumnType column : tableColumnArray) + { + if (column.isSetPhi() || column.isSetProtected()) + { + throw new IllegalArgumentException("PHI/protected metadata must not be set here."); + } + + ColumnType.Fk fk = column.getFk(); + if (null != fk) + { + try + { + validateForeignKey(fk, column, errors); + validateLookupFilter(AbstractTableInfo.parseXMLLookupFilters(fk.getFilters()), errors); + } + catch (ValidationException e) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + } + } + } + } + } + } + } + } + } + catch (XmlException e) + { + throw new RuntimeValidationException(e); + } + + for (XmlError xmle : xmlErrors) + { + errors.reject(ERROR_MSG, XmlBeansUtil.getErrorMessage(xmle)); + } + } + + private void validateForeignKey(ColumnType.Fk fk, ColumnType column, Errors errors) + { + if (fk.isSetFkMultiValued()) + { + // issue 51695 : don't let users create unsupported MVFK types + String type = fk.getFkMultiValued(); + if (!AbstractTableInfo.MultiValuedFkType.junction.name().equals(type)) + { + errors.reject(ERROR_MSG, String.format("Column : \"%s\" has an invalid fkMultiValued value : \"%s\" is not supported.", column.getColumnName(), type)); + } + } + } + + private void validateLookupFilter(Map> filterMap, Errors errors) + { + filterMap.forEach((operation, filters) -> { + + String displayStr = "Filter for operation : " + operation.name(); + for (FilterType filter : filters) + { + if (isBlank(filter.getColumn())) + errors.reject(ERROR_MSG, displayStr + " requires columnName"); + + if (null == filter.getOperator()) + { + errors.reject(ERROR_MSG, displayStr + " requires operator"); + } + else + { + CompareType compareType = CompareType.getByURLKey(filter.getOperator().toString()); + if (null == compareType) + { + errors.reject(ERROR_MSG, displayStr + " operator is invalid"); + } + else + { + if (compareType.isDataValueRequired() && null == filter.getValue()) + errors.reject(ERROR_MSG, displayStr + " requires a value but none is specified"); + } + } + } + + try + { + // attempt to convert to something we can query against + SimpleFilter.fromXml(filters.toArray(new FilterType[0])); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + }); + } + + @Override + public ApiResponse execute(SourceForm form, BindException errors) + { + var queryDef = _schema.getQueryDef(form.getQueryName()); + if (null == queryDef) + queryDef = _schema.getQueryDefForTable(form.getQueryName()); + if (null == queryDef) + throw new NotFoundException("Could not find the query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); + + ApiSimpleResponse response = new ApiSimpleResponse(); + + try + { + if (form.ff_queryText != null) + { + if (!queryDef.isSqlEditable()) + throw new UnauthorizedException("Query SQL is not editable."); + + if (!queryDef.canEdit(getUser())) + throw new UnauthorizedException("Edit permissions are required."); + + queryDef.setSql(form.ff_queryText); + } + + String metadataText = StringUtils.trimToNull(form.ff_metadataText); + if (!Objects.equals(metadataText, queryDef.getMetadataXml())) + { + if (queryDef.isMetadataEditable()) + { + if (!queryDef.canEditMetadata(getUser())) + throw new UnauthorizedException("Edit metadata permissions are required."); + + if (!getUser().isTrustedBrowserDev()) + { + JavaScriptFragment.ensureXMLMetadataNoJavaScript(metadataText); + } + + queryDef.setMetadataXml(metadataText); + } + else + { + if (metadataText != null) + throw new UnsupportedOperationException("Query metadata is not editable."); + } + } + + queryDef.save(getUser(), getContainer()); + + // the query was successfully saved, validate the query but return any errors in the success response + List parseErrors = new ArrayList<>(); + List parseWarnings = new ArrayList<>(); + queryDef.validateQuery(_schema, parseErrors, parseWarnings); + if (!parseErrors.isEmpty()) + { + JSONArray errorArray = new JSONArray(); + + for (QueryException e : parseErrors) + { + errorArray.put(e.toJSON(form.ff_queryText)); + } + response.put("parseErrors", errorArray); + } + else if (!parseWarnings.isEmpty()) + { + JSONArray errorArray = new JSONArray(); + + for (QueryException e : parseWarnings) + { + errorArray.put(e.toJSON(form.ff_queryText)); + } + response.put("parseWarnings", errorArray); + } + } + catch (SQLException e) + { + errors.reject(ERROR_MSG, "An exception occurred: " + e); + LOG.error("Error", e); + } + catch (RuntimeException e) + { + errors.reject(ERROR_MSG, "An exception occurred: " + e.getMessage()); + LOG.error("Error", e); + } + + if (errors.hasErrors()) + return null; + + //if we got here, the query is OK + response.put("success", true); + return response; + } + + } + + + // Trusted analysts who are editors can create and modify queries + @RequiresAllOf({EditQueriesPermission.class, DeletePermission.class}) + @Action(ActionType.Configure.class) + public static class DeleteQueryAction extends ConfirmAction + { + public SourceForm _form; + public QuerySchema _baseSchema; + public QueryDefinition _queryDef; + + + @Override + public void validateCommand(SourceForm target, Errors errors) + { + _form = target; + if (StringUtils.isEmpty(target.getSchemaName())) + throw new NotFoundException("Query definition not found, schemaName and queryName are required."); + if (StringUtils.isEmpty(target.getQueryName())) + throw new NotFoundException("Query definition not found, schemaName and queryName are required."); + + _baseSchema = DefaultSchema.get(getUser(), getContainer(), _form.getSchemaKey()); + if (null == _baseSchema) + throw new NotFoundException("Schema not found: " + _form.getSchemaKey().toDisplayString()); + } + + + @Override + public ModelAndView getConfirmView(SourceForm form, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Delete Query"); + _queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), _baseSchema.getSchemaName(), form.getQueryName()); + + if (null == _queryDef) + throw new NotFoundException("Query not found: " + form.getQueryName()); + + if (!_queryDef.canDelete(getUser())) + { + errors.reject(ERROR_MSG, "Sorry, this query can not be deleted"); + } + + return new JspView<>("/org/labkey/query/view/deleteQuery.jsp", this, errors); + } + + + @Override + public boolean handlePost(SourceForm form, BindException errors) throws Exception + { + _queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), _baseSchema.getSchemaName(), form.getQueryName()); + + if (null == _queryDef) + return false; + try + { + _queryDef.delete(getUser()); + } + catch (OptimisticConflictException x) + { + /* reshow will throw NotFound, so just ignore */ + } + return true; + } + + @Override + @NotNull + public ActionURL getSuccessURL(SourceForm queryForm) + { + return ((UserSchema)_baseSchema).urlFor(QueryAction.schema); + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public class ExecuteQueryAction extends QueryViewAction + { + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + _form = form; + + if (errors.hasErrors()) + return new SimpleErrorView(errors, true); + + QueryView queryView = Objects.requireNonNull(form.getQueryView()); + + var t = queryView.getTable(); + if (null != t && !t.allowRobotsIndex()) + { + getPageConfig().setRobotsNone(); + } + + if (isPrint()) + { + queryView.setPrintView(true); + getPageConfig().setTemplate(PageConfig.Template.Print); + getPageConfig().setShowPrintDialog(true); + } + + queryView.setShadeAlternatingRows(true); + queryView.setShowBorders(true); + setHelpTopic("customSQL"); + _queryView = queryView; + return queryView; + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + TableInfo ti = null; + try + { + if (null != _queryView) + ti = _queryView.getTable(); + } + catch (QueryParseException x) + { + /* */ + } + String display = ti == null ? _form.getQueryName() : ti.getTitle(); + root.addChild(display); + } + } + + + @RequiresPermission(AdminOperationsPermission.class) + public class RawTableMetaDataAction extends QueryViewAction + { + private String _dbSchemaName; + private String _dbTableName; + + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + _form = form; + + QueryView queryView = form.getQueryView(); + String userSchemaName = queryView.getSchema().getName(); + TableInfo ti = queryView.getTable(); + if (null == ti) + throw new NotFoundException(); + + DbScope scope = ti.getSchema().getScope(); + + // Test for provisioned table + if (ti.getDomain() != null) + { + Domain domain = ti.getDomain(); + if (domain.getStorageTableName() != null) + { + // Use the real table and schema names for getting the metadata + _dbTableName = domain.getStorageTableName(); + _dbSchemaName = domain.getDomainKind().getStorageSchemaName(); + } + } + + // No domain or domain with non-provisioned storage (e.g., core.Users) + if (null == _dbSchemaName || null == _dbTableName) + { + DbSchema dbSchema = ti.getSchema(); + _dbSchemaName = dbSchema.getName(); + + // Try to get the underlying schema table and use the meta data name, #12015 + if (ti instanceof FilteredTable fti) + ti = fti.getRealTable(); + + if (ti instanceof SchemaTableInfo) + _dbTableName = ti.getMetaDataIdentifier().getId(); + else if (ti instanceof LinkedTableInfo) + _dbTableName = ti.getName(); + + if (null == _dbTableName) + { + TableInfo tableInfo = dbSchema.getTable(ti.getName()); + if (null != tableInfo) + _dbTableName = tableInfo.getMetaDataIdentifier().getId(); + } + } + + if (null != _dbTableName) + { + VBox result = new VBox(); + + ActionURL url = null; + QuerySchema qs = DefaultSchema.get(getUser(), getContainer()).getSchema(userSchemaName); + if (qs != null) + { + url = new ActionURL(RawSchemaMetaDataAction.class, getContainer()); + url.addParameter("schemaName", userSchemaName); + } + + SqlDialect dialect = scope.getSqlDialect(); + ScopeView scopeInfo = new ScopeView("Scope and Schema Information", scope, _dbSchemaName, url, _dbTableName); + + result.addView(scopeInfo); + + try (JdbcMetaDataLocator locator = dialect.getTableResolver().getSingleTableLocator(scope, _dbSchemaName, _dbTableName)) + { + JdbcMetaDataSelector columnSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getColumns(l.getCatalogName(), l.getSchemaNamePattern(), l.getTableNamePattern(), null)); + result.addView(new ResultSetView(CachedResultSets.create(columnSelector.getResultSet(), true, Table.ALL_ROWS), "Table Meta Data")); + + JdbcMetaDataSelector pkSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getPrimaryKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); + result.addView(new ResultSetView(CachedResultSets.create(pkSelector.getResultSet(), true, Table.ALL_ROWS), "Primary Key Meta Data")); + + if (dialect.canCheckIndices(ti)) + { + JdbcMetaDataSelector indexSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getIndexInfo(l.getCatalogName(), l.getSchemaName(), l.getTableName(), false, false)); + result.addView(new ResultSetView(CachedResultSets.create(indexSelector.getResultSet(), true, Table.ALL_ROWS), "Other Index Meta Data")); + } + + JdbcMetaDataSelector ikSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getImportedKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); + result.addView(new ResultSetView(CachedResultSets.create(ikSelector.getResultSet(), true, Table.ALL_ROWS), "Imported Keys Meta Data")); + + JdbcMetaDataSelector ekSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getExportedKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); + result.addView(new ResultSetView(CachedResultSets.create(ekSelector.getResultSet(), true, Table.ALL_ROWS), "Exported Keys Meta Data")); + } + return result; + } + else + { + errors.reject(ERROR_MSG, "Raw metadata not accessible for table " + ti.getName()); + return new SimpleErrorView(errors); + } + } + + @Override + public void addNavTrail(NavTree root) + { + (new SchemaAction(_form)).addNavTrail(root); + if (null != _dbTableName) + root.addChild("JDBC Meta Data For Table \"" + _dbSchemaName + "." + _dbTableName + "\""); + } + } + + + @RequiresPermission(AdminOperationsPermission.class) + public class RawSchemaMetaDataAction extends SimpleViewAction + { + private String _schemaName; + + @Override + public ModelAndView getView(Object form, BindException errors) throws Exception + { + _schemaName = getViewContext().getActionURL().getParameter("schemaName"); + if (null == _schemaName) + throw new NotFoundException(); + QuerySchema qs = DefaultSchema.get(getUser(), getContainer()).getSchema(_schemaName); + if (null == qs) + throw new NotFoundException(_schemaName); + DbSchema schema = qs.getDbSchema(); + String dbSchemaName = schema.getName(); + DbScope scope = schema.getScope(); + SqlDialect dialect = scope.getSqlDialect(); + + HttpView scopeInfo = new ScopeView("Scope Information", scope); + + ModelAndView tablesView; + + try (JdbcMetaDataLocator locator = dialect.getTableResolver().getAllTablesLocator(scope, dbSchemaName)) + { + JdbcMetaDataSelector selector = new JdbcMetaDataSelector(locator, + (dbmd, locator1) -> dbmd.getTables(locator1.getCatalogName(), locator1.getSchemaNamePattern(), locator1.getTableNamePattern(), null)); + Set tableNames = Sets.newCaseInsensitiveHashSet(qs.getTableNames()); + + ActionURL url = new ActionURL(RawTableMetaDataAction.class, getContainer()) + .addParameter("schemaName", _schemaName) + .addParameter("query.queryName", null); + tablesView = new ResultSetView(CachedResultSets.create(selector.getResultSet(), true, Table.ALL_ROWS), "Tables", "TABLE_NAME", url) + { + @Override + protected boolean shouldLink(ResultSet rs) throws SQLException + { + // Only link to tables and views (not indexes or sequences). And only if they're defined in the query schema. + String name = rs.getString("TABLE_NAME"); + String type = rs.getString("TABLE_TYPE"); + return ("TABLE".equalsIgnoreCase(type) || "VIEW".equalsIgnoreCase(type)) && tableNames.contains(name); + } + }; + } + + return new VBox(scopeInfo, tablesView); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("JDBC Meta Data For Schema \"" + _schemaName + "\""); + } + } + + + public static class ScopeView extends WebPartView + { + private final DbScope _scope; + private final String _schemaName; + private final String _tableName; + private final ActionURL _url; + + private ScopeView(String title, DbScope scope) + { + this(title, scope, null, null, null); + } + + private ScopeView(String title, DbScope scope, String schemaName, ActionURL url, String tableName) + { + super(title); + _scope = scope; + _schemaName = schemaName; + _tableName = tableName; + _url = url; + } + + @Override + protected void renderView(Object model, HtmlWriter out) + { + TABLE( + null != _schemaName ? getLabelAndContents("Schema", _url == null ? _schemaName : LinkBuilder.simpleLink(_schemaName, _url)) : null, + null != _tableName ? getLabelAndContents("Table", _tableName) : null, + getLabelAndContents("Scope", _scope.getDisplayName()), + getLabelAndContents("Dialect", _scope.getSqlDialect().getClass().getSimpleName()), + getLabelAndContents("URL", _scope.getDatabaseUrl()) + ).appendTo(out); + } + + // Return a single row (TR) with styled label and contents in separate TDs + private Renderable getLabelAndContents(String label, Object contents) + { + return TR( + TD( + cl("labkey-form-label"), + label + ), + TD( + contents + ) + ); + } + } + + // for backwards compat same as _executeQuery.view ?_print=1 + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public class PrintRowsAction extends ExecuteQueryAction + { + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + _print = true; + ModelAndView result = super.getView(form, errors); + String title = form.getQueryName(); + if (StringUtils.isEmpty(title)) + title = form.getSchemaName(); + getPageConfig().setTitle(title, true); + return result; + } + } + + + abstract static class _ExportQuery extends SimpleViewAction + { + @Override + public ModelAndView getView(K form, BindException errors) throws Exception + { + QueryView view = form.getQueryView(); + getPageConfig().setTemplate(PageConfig.Template.None); + HttpServletResponse response = getViewContext().getResponse(); + response.setHeader("X-Robots-Tag", "noindex"); + try + { + _export(form, view); + return null; + } + catch (QueryService.NamedParameterNotProvided | QueryParseException x) + { + ExceptionUtil.decorateException(x, ExceptionUtil.ExceptionInfo.SkipMothershipLogging, "true", true); + throw x; + } + } + + abstract void _export(K form, QueryView view) throws Exception; + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportScriptForm extends QueryForm + { + private String _type; + + public String getScriptType() + { + return _type; + } + + public void setScriptType(String type) + { + _type = type; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) // This is called "export" but it doesn't export any data + @CSRF(CSRF.Method.ALL) + public static class ExportScriptAction extends SimpleViewAction + { + @Override + public void validate(ExportScriptForm form, BindException errors) + { + // calling form.getQueryView() as a validation check as it will throw if schema/query missing + form.getQueryView(); + + if (StringUtils.isEmpty(form.getScriptType())) + throw new NotFoundException("Missing required parameter: scriptType."); + } + + @Override + public ModelAndView getView(ExportScriptForm form, BindException errors) + { + return ExportScriptModel.getExportScriptView(QueryView.create(form, errors), form.getScriptType(), getPageConfig(), getViewContext().getResponse()); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportRowsExcelAction extends _ExportQuery + { + @Override + void _export(ExportQueryForm form, QueryView view) throws Exception + { + view.exportToExcel(getViewContext().getResponse(), form.getHeaderType(), ExcelWriter.ExcelDocumentType.xls, form.getRenameColumnMap()); + } + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportRowsXLSXAction extends _ExportQuery + { + @Override + void _export(ExportQueryForm form, QueryView view) throws Exception + { + view.exportToExcel(getViewContext().getResponse(), form.getHeaderType(), ExcelWriter.ExcelDocumentType.xlsx, form.getRenameColumnMap()); + } + } + + public static class ExportQueriesForm extends ExportQueryForm implements ApiJsonForm + { + private String filename; + private List queryForms; + + public void setFilename(String filename) + { + this.filename = filename; + } + + public String getFilename() + { + return filename; + } + + public void setQueryForms(List queryForms) + { + this.queryForms = queryForms; + } + + public List getQueryForms() + { + return queryForms; + } + + /** + * Map JSON to Spring PropertyValue objects. + * @param json the properties + */ + private MutablePropertyValues getPropertyValues(JSONObject json) + { + // Collecting mapped properties as a list because adding them to an existing MutablePropertyValues object replaces existing values + List properties = new ArrayList<>(); + + for (String key : json.keySet()) + { + Object value = json.get(key); + if (value instanceof JSONArray val) + { + // Split arrays into individual pairs to be bound (Issue #45452) + for (int i = 0; i < val.length(); i++) + { + properties.add(new PropertyValue(key, val.get(i).toString())); + } + } + else + { + properties.add(new PropertyValue(key, value)); + } + } + + return new MutablePropertyValues(properties); + } + + @Override + public void bindJson(JSONObject json) + { + setFilename(json.get("filename").toString()); + List forms = new ArrayList<>(); + + JSONArray models = json.optJSONArray("queryForms"); + if (models == null) + { + QueryController.LOG.error("No models to export; Form's `queryForms` property was null"); + throw new RuntimeValidationException("No queries to export; Form's `queryForms` property was null"); + } + + for (JSONObject queryModel : JsonUtil.toJSONObjectList(models)) + { + ExportQueryForm qf = new ExportQueryForm(); + qf.setViewContext(getViewContext()); + + qf.bindParameters(getPropertyValues(queryModel)); + forms.add(qf); + } + + setQueryForms(forms); + } + } + + /** + * Export multiple query forms + */ + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportQueriesXLSXAction extends ReadOnlyApiAction + { + @Override + public Object execute(ExportQueriesForm form, BindException errors) throws Exception + { + getPageConfig().setTemplate(PageConfig.Template.None); + HttpServletResponse response = getViewContext().getResponse(); + response.setHeader("X-Robots-Tag", "noindex"); + ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment); + ViewContext viewContext = getViewContext(); + + Map> nameFormMap = new CaseInsensitiveHashMap<>(); + Map sheetNames = new HashMap<>(); + form.getQueryForms().forEach(qf -> { + String sheetName = qf.getSheetName(); + QueryView qv = qf.getQueryView(); + // use the given sheet name if provided, otherwise try the query definition name + String name = StringUtils.isNotBlank(sheetName) ? sheetName : qv.getQueryDef().getName(); + // if there is no sheet name or queryDefinition name, use a data region name if provided. Otherwise, use "Data" + name = StringUtils.isNotBlank(name) ? name : StringUtils.isNotBlank(qv.getDataRegionName()) ? qv.getDataRegionName() : "Data"; + // clean it to remove undesirable characters and make it of an acceptable length + name = ExcelWriter.cleanSheetName(name); + nameFormMap.computeIfAbsent(name, k -> new ArrayList<>()).add(qf); + }); + // Issue 53722: Need to assure unique names for the sheets in the presence of really long names + for (Map.Entry> entry : nameFormMap.entrySet()) { + String name = entry.getKey(); + if (entry.getValue().size() > 1) + { + List queryForms = entry.getValue(); + int countLength = String.valueOf(queryForms.size()).length() + 2; + if (countLength > name.length()) + throw new IllegalArgumentException("Cannot create sheet names from overlapping query names."); + for (int i = 0; i < queryForms.size(); i++) + { + sheetNames.put(entry.getValue().get(i), name.substring(0, name.length() - countLength) + "(" + i + ")"); + } + } + else + { + sheetNames.put(entry.getValue().get(0), name); + } + } + ExcelWriter writer = new ExcelWriter(ExcelWriter.ExcelDocumentType.xlsx) { + @Override + protected void renderSheets(Workbook workbook) + { + for (ExportQueryForm qf : form.getQueryForms()) + { + qf.setViewContext(viewContext); + qf.getSchema(); + + QueryView qv = qf.getQueryView(); + QueryView.ExcelExportConfig config = new QueryView.ExcelExportConfig(response, qf.getHeaderType()) + .setExcludeColumns(qf.getExcludeColumns()) + .setRenamedColumns(qf.getRenameColumnMap()); + qv.configureExcelWriter(this, config); + setSheetName(sheetNames.get(qf)); + setAutoSize(true); + renderNewSheet(workbook); + qv.logAuditEvent("Exported to Excel", getDataRowCount()); + } + + workbook.setActiveSheet(0); + } + }; + writer.setFilenamePrefix(form.getFilename()); + writer.renderWorkbook(response); + return null; //Returning anything here will cause error as excel writer will close the response stream + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class TemplateForm extends ExportQueryForm + { + boolean insertColumnsOnly = true; + String filenamePrefix; + FieldKey[] includeColumn; + String fileType; + + public TemplateForm() + { + _headerType = ColumnHeaderType.Caption; + } + + // "captionType" field backwards compatibility + public void setCaptionType(ColumnHeaderType headerType) + { + _headerType = headerType; + } + + public ColumnHeaderType getCaptionType() + { + return _headerType; + } + + public List getIncludeColumns() + { + if (includeColumn == null || includeColumn.length == 0) + return Collections.emptyList(); + return Arrays.asList(includeColumn); + } + + public FieldKey[] getIncludeColumn() + { + return includeColumn; + } + + public void setIncludeColumn(FieldKey[] includeColumn) + { + this.includeColumn = includeColumn; + } + + @NotNull + public String getFilenamePrefix() + { + return filenamePrefix == null ? getQueryName() : filenamePrefix; + } + + public void setFilenamePrefix(String prefix) + { + filenamePrefix = prefix; + } + + public String getFileType() + { + return fileType; + } + + public void setFileType(String fileType) + { + this.fileType = fileType; + } + } + + + /** + * Can be used to generate an Excel template for import into a table. Supported URL params include: + *
+ *
filenamePrefix
+ *
the prefix of the excel file that is generated, defaults to '_data'
+ * + *
query.viewName
+ *
if provided, the resulting excel file will use the fields present in this view. + * Non-usereditable columns will be skipped. + * Non-existent columns (like a lookup) unless includeMissingColumns is true. + * Any required columns missing from this view will be appended to the end of the query. + *
+ * + *
includeColumn
+ *
List of column names to include, even if the column doesn't exist or is non-userEditable. + * For example, this can be used to add a fake column that is only supported during the import process. + *
+ * + *
excludeColumn
+ *
List of column names to exclude. + *
+ * + *
exportAlias.columns
+ *
Use alternative column name in excel: exportAlias.originalColumnName=aliasColumnName + *
+ * + *
captionType
+ *
determines which column property is used in the header, either Label or Name
+ *
+ */ + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportExcelTemplateAction extends _ExportQuery + { + public ExportExcelTemplateAction() + { + setCommandClass(TemplateForm.class); + } + + @Override + void _export(TemplateForm form, QueryView view) throws Exception + { + boolean respectView = form.getViewName() != null; + ExcelWriter.ExcelDocumentType fileType = ExcelWriter.ExcelDocumentType.xlsx; + if (form.getFileType() != null) + { + try + { + fileType = ExcelWriter.ExcelDocumentType.valueOf(form.getFileType().toLowerCase()); + } + catch (IllegalArgumentException ignored) {} + } + view.exportToExcel( new QueryView.ExcelExportConfig(getViewContext().getResponse(), form.getHeaderType()) + .setTemplateOnly(true) + .setInsertColumnsOnly(form.insertColumnsOnly) + .setDocType(fileType) + .setRespectView(respectView) + .setIncludeColumns(form.getIncludeColumns()) + .setExcludeColumns(form.getExcludeColumns()) + .setRenamedColumns(form.getRenameColumnMap()) + .setPrefix((StringUtils.isEmpty(form.getFilenamePrefix()) ? "Import" : form.getFilenamePrefix()) + "_Template") // Issue 48028: Change template file names + ); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportQueryForm extends QueryForm + { + protected ColumnHeaderType _headerType = null; // QueryView will provide a default header type if the user doesn't select one + FieldKey[] excludeColumn; + Map renameColumns = null; + private String sheetName; + + public void setSheetName(String sheetName) + { + this.sheetName = sheetName; + } + + public String getSheetName() + { + return sheetName; + } + + public ColumnHeaderType getHeaderType() + { + return _headerType; + } + + public void setHeaderType(ColumnHeaderType headerType) + { + _headerType = headerType; + } + + public List getExcludeColumns() + { + if (excludeColumn == null || excludeColumn.length == 0) + return Collections.emptyList(); + return Arrays.asList(excludeColumn); + } + + public void setExcludeColumn(FieldKey[] excludeColumn) + { + this.excludeColumn = excludeColumn; + } + + public Map getRenameColumnMap() + { + if (renameColumns != null) + return renameColumns; + + renameColumns = new CaseInsensitiveHashMap<>(); + final String renameParamPrefix = "exportAlias."; + PropertyValue[] pvs = getInitParameters().getPropertyValues(); + for (PropertyValue pv : pvs) + { + String paramName = pv.getName(); + if (!paramName.startsWith(renameParamPrefix) || pv.getValue() == null) + continue; + + renameColumns.put(paramName.substring(renameParamPrefix.length()), (String) pv.getValue()); + } + + return renameColumns; + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportRowsTsvForm extends ExportQueryForm + { + private TSVWriter.DELIM _delim = TSVWriter.DELIM.TAB; + private TSVWriter.QUOTE _quote = TSVWriter.QUOTE.DOUBLE; + + public TSVWriter.DELIM getDelim() + { + return _delim; + } + + public void setDelim(TSVWriter.DELIM delim) + { + _delim = delim; + } + + public TSVWriter.QUOTE getQuote() + { + return _quote; + } + + public void setQuote(TSVWriter.QUOTE quote) + { + _quote = quote; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportRowsTsvAction extends _ExportQuery + { + public ExportRowsTsvAction() + { + setCommandClass(ExportRowsTsvForm.class); + } + + @Override + void _export(ExportRowsTsvForm form, QueryView view) throws Exception + { + view.exportToTsv(getViewContext().getResponse(), form.getDelim(), form.getQuote(), form.getHeaderType(), form.getRenameColumnMap()); + } + } + + + @RequiresNoPermission + @IgnoresTermsOfUse + @Action(ActionType.Export.class) + public static class ExcelWebQueryAction extends ExportRowsTsvAction + { + @Override + public ModelAndView getView(ExportRowsTsvForm form, BindException errors) throws Exception + { + if (!getContainer().hasPermission(getUser(), ReadPermission.class)) + { + if (!getUser().isGuest()) + { + throw new UnauthorizedException(); + } + getViewContext().getResponse().setHeader("WWW-Authenticate", "Basic realm=\"" + LookAndFeelProperties.getInstance(ContainerManager.getRoot()).getDescription() + "\""); + getViewContext().getResponse().setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return null; + } + + // Bug 5610. Excel web queries don't work over SSL if caching is disabled, + // so we need to allow caching so that Excel can read from IE on Windows. + HttpServletResponse response = getViewContext().getResponse(); + // Set the headers to allow the client to cache, but not proxies + ResponseHelper.setPrivate(response); + + QueryView view = form.getQueryView(); + getPageConfig().setTemplate(PageConfig.Template.None); + view.exportToExcelWebQuery(getViewContext().getResponse()); + return null; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExcelWebQueryDefinitionAction extends SimpleViewAction + { + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + getPageConfig().setTemplate(PageConfig.Template.None); + form.getQueryView(); + String queryViewActionURL = form.getQueryViewActionURL(); + ActionURL url; + if (queryViewActionURL != null) + { + url = new ActionURL(queryViewActionURL); + } + else + { + url = getViewContext().cloneActionURL(); + url.setAction(ExcelWebQueryAction.class); + } + getViewContext().getResponse().setContentType("text/x-ms-iqy"); + String filename = FileUtil.makeFileNameWithTimestamp(form.getQueryName(), "iqy"); + ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, filename); + PrintWriter writer = getViewContext().getResponse().getWriter(); + writer.println("WEB"); + writer.println("1"); + writer.println(url.getURIString()); + + QueryService.get().addAuditEvent(getUser(), getContainer(), form.getSchemaName(), form.getQueryName(), url, "Exported to Excel Web Query definition", null); + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + // Trusted analysts who are editors can create and modify queries + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + @Action(ActionType.SelectMetaData.class) + public class MetadataQueryAction extends SimpleViewAction + { + QueryForm _form = null; + + @Override + public ModelAndView getView(QueryForm queryForm, BindException errors) throws Exception + { + String schemaName = queryForm.getSchemaName(); + String queryName = queryForm.getQueryName(); + + _form = queryForm; + + if (schemaName.isEmpty() && (null == queryName || queryName.isEmpty())) + { + throw new NotFoundException("Must provide schemaName and queryName."); + } + + if (schemaName.isEmpty()) + { + throw new NotFoundException("Must provide schemaName."); + } + + if (null == queryName || queryName.isEmpty()) + { + throw new NotFoundException("Must provide queryName."); + } + + if (!queryForm.getQueryDef().isMetadataEditable()) + throw new UnauthorizedException("Query metadata is not editable"); + + if (!queryForm.canEditMetadata()) + throw new UnauthorizedException("You do not have permission to edit the query metadata"); + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("queryMetadataEditor")); + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + var metadataQuery = _form.getQueryDef().getName(); + if (null != metadataQuery) + root.addChild("Edit Metadata: " + _form.getQueryName(), metadataQuery); + else + root.addChild("Edit Metadata: " + _form.getQueryName()); + } + } + + // Uck. Supports the old and new view designer. + protected JSONObject saveCustomView(Container container, QueryDefinition queryDef, + String regionName, String viewName, boolean replaceExisting, + boolean share, boolean inherit, + boolean session, boolean saveFilter, + boolean hidden, JSONObject jsonView, + ActionURL returnUrl, + BindException errors) + { + User owner = getUser(); + boolean canSaveForAllUsers = container.hasPermission(getUser(), EditSharedViewPermission.class); + if (share && canSaveForAllUsers && !session) + { + owner = null; + } + String name = StringUtils.trimToNull(viewName); + + if (name != null && RESERVED_VIEW_NAMES.contains(name.toLowerCase())) + errors.reject(ERROR_MSG, "The grid view name '" + name + "' is not allowed."); + + boolean isHidden = hidden; + CustomView view; + if (owner == null) + view = queryDef.getSharedCustomView(name); + else + view = queryDef.getCustomView(owner, getViewContext().getRequest(), name); + + if (view != null && !replaceExisting && !StringUtils.isEmpty(name)) + errors.reject(ERROR_MSG, "A saved view by the name \"" + viewName + "\" already exists. "); + + // 11179: Allow editing the view if we're saving to session. + // NOTE: Check for session flag first otherwise the call to canEdit() will add errors to the errors collection. + boolean canEdit = view == null || session || view.canEdit(container, errors); + if (errors.hasErrors()) + return null; + + if (canEdit) + { + // Issue 13594: Disallow setting of the customview inherit bit for query views + // that have no available container filter types. Unfortunately, the only way + // to get the container filters is from the QueryView. Ideally, the query def + // would know if it was container filterable or not instead of using the QueryView. + if (inherit && canSaveForAllUsers && !session) + { + UserSchema schema = queryDef.getSchema(); + QueryView queryView = schema.createView(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, queryDef.getName(), errors); + if (queryView != null) + { + Set allowableContainerFilterTypes = queryView.getAllowableContainerFilterTypes(); + if (allowableContainerFilterTypes.size() <= 1) + { + errors.reject(ERROR_MSG, "QueryView doesn't support inherited custom views"); + return null; + } + } + } + + // Create a new view if none exists or the current view is a shared view + // and the user wants to override the shared view with a personal view. + if (view == null || (owner != null && view.isShared())) + { + if (owner == null) + view = queryDef.createSharedCustomView(name); + else + view = queryDef.createCustomView(owner, name); + + if (owner != null && session) + ((CustomViewImpl) view).isSession(true); + view.setIsHidden(hidden); + } + else if (session != view.isSession()) + { + if (session) + { + assert !view.isSession(); + if (owner == null) + { + errors.reject(ERROR_MSG, "Session views can't be saved for all users"); + return null; + } + + // The form is saving to session but the view is in the database. + // Make a copy in case it's a read-only version from an XML file + view = queryDef.createCustomView(owner, name); + ((CustomViewImpl) view).isSession(true); + } + else + { + // Remove the session view and call saveCustomView again to either create a new view or update an existing view. + assert view.isSession(); + boolean success = false; + try + { + view.delete(getUser(), getViewContext().getRequest()); + JSONObject ret = saveCustomView(container, queryDef, regionName, viewName, replaceExisting, share, inherit, session, saveFilter, hidden, jsonView, returnUrl, errors); + success = !errors.hasErrors() && ret != null; + return success ? ret : null; + } + finally + { + if (!success) + { + // dirty the view then save the deleted session view back in session state + view.setName(view.getName()); + view.save(getUser(), getViewContext().getRequest()); + } + } + } + } + + // NOTE: Updating, saving, and deleting the view may throw an exception + CustomViewImpl cview = null; + if (view instanceof EditableCustomView && view.isOverridable()) + { + cview = ((EditableCustomView)view).getEditableViewInfo(owner, session); + } + if (null == cview) + { + throw new IllegalArgumentException("View cannot be edited"); + } + + cview.update(jsonView, saveFilter); + if (canSaveForAllUsers && !session) + { + cview.setCanInherit(inherit); + } + isHidden = view.isHidden(); + cview.setContainer(container); + cview.save(getUser(), getViewContext().getRequest()); + if (owner == null) + { + // New view is shared so delete any previous custom view owned by the user with the same name. + CustomView personalView = queryDef.getCustomView(getUser(), getViewContext().getRequest(), name); + if (personalView != null && !personalView.isShared()) + { + personalView.delete(getUser(), getViewContext().getRequest()); + } + } + } + + if (null == returnUrl) + { + returnUrl = getViewContext().cloneActionURL().setAction(ExecuteQueryAction.class); + } + else + { + returnUrl = returnUrl.clone(); + if (name == null || !canEdit) + { + returnUrl.deleteParameter(regionName + "." + QueryParam.viewName); + } + else if (!isHidden) + { + returnUrl.replaceParameter(regionName + "." + QueryParam.viewName, name); + } + returnUrl.deleteParameter(regionName + "." + QueryParam.ignoreFilter); + if (saveFilter) + { + for (String key : returnUrl.getKeysByPrefix(regionName + ".")) + { + if (isFilterOrSort(regionName, key)) + returnUrl.deleteFilterParameters(key); + } + } + } + + JSONObject ret = new JSONObject(); + ret.put("redirect", returnUrl); + Map viewAsMap = CustomViewUtil.toMap(view, getUser(), true); + try + { + ret.put("view", new JSONObject(viewAsMap, new JSONParserConfiguration().withMaxNestingDepth(10))); + } + catch (JSONException e) + { + LOG.error("Failed to save view: {}", jsonView, e); + } + return ret; + } + + private boolean isFilterOrSort(String dataRegionName, String param) + { + assert param.startsWith(dataRegionName + "."); + String check = param.substring(dataRegionName.length() + 1); + if (check.contains("~")) + return true; + if ("sort".equals(check)) + return true; + if (check.equals("containerFilterName")) + return true; + return false; + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Configure.class) + @JsonInputLimit(100_000) + public class SaveQueryViewsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(SimpleApiJsonForm form, BindException errors) + { + JSONObject json = form.getJsonObject(); + if (json == null) + throw new NotFoundException("Empty request"); + + String schemaName = json.optString(QueryParam.schemaName.toString(), null); + String queryName = json.optString(QueryParam.queryName.toString(), null); + if (schemaName == null || queryName == null) + throw new NotFoundException("schemaName and queryName are required"); + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); + if (schema == null) + throw new NotFoundException("schema not found"); + + QueryDefinition queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), schemaName, queryName); + if (queryDef == null) + queryDef = schema.getQueryDefForTable(queryName); + if (queryDef == null) + throw new NotFoundException("query not found"); + + JSONObject response = new JSONObject(); + response.put(QueryParam.schemaName.toString(), schemaName); + response.put(QueryParam.queryName.toString(), queryName); + JSONArray views = new JSONArray(); + response.put("views", views); + + ActionURL redirect = null; + JSONArray jsonViews = json.getJSONArray("views"); + for (int i = 0; i < jsonViews.length(); i++) + { + final JSONObject jsonView = jsonViews.getJSONObject(i); + String viewName = jsonView.optString("name", null); + if (viewName == null) + throw new NotFoundException("'name' is required all views'"); + + boolean shared = jsonView.optBoolean("shared", false); + boolean replace = jsonView.optBoolean("replace", true); // "replace" was the default before the flag is introduced + boolean inherit = jsonView.optBoolean("inherit", false); + boolean session = jsonView.optBoolean("session", false); + boolean hidden = jsonView.optBoolean("hidden", false); + // Users may save views to a location other than the current container + String containerPath = jsonView.optString("containerPath", getContainer().getPath()); + Container container; + if (inherit) + { + // Only respect this request if it's a view that is inheritable in subfolders + container = ContainerManager.getForPath(containerPath); + } + else + { + // Otherwise, save it in the current container + container = getContainer().getContainerFor(ContainerType.DataType.customQueryViews); + } + + if (container == null) + { + throw new NotFoundException("No such container: " + containerPath); + } + + JSONObject savedView = saveCustomView( + container, queryDef, QueryView.DATAREGIONNAME_DEFAULT, viewName, replace, + shared, inherit, session, true, hidden, jsonView, null, errors); + + if (savedView != null) + { + if (redirect == null) + redirect = (ActionURL)savedView.get("redirect"); + views.put(savedView.getJSONObject("view")); + } + } + + if (redirect != null) + response.put("redirect", redirect); + + if (errors.hasErrors()) + return null; + else + return new ApiSimpleResponse(response); + } + } + + public static class RenameQueryViewForm extends QueryForm + { + private String newName; + + public String getNewName() + { + return newName; + } + + public void setNewName(String newName) + { + this.newName = newName; + } + } + + @RequiresPermission(ReadPermission.class) + public class RenameQueryViewAction extends MutatingApiAction + { + @Override + public ApiResponse execute(RenameQueryViewForm form, BindException errors) + { + CustomView view = form.getCustomView(); + if (view == null) + { + throw new NotFoundException(); + } + + Container container = getContainer(); + User user = getUser(); + + String schemaName = form.getSchemaName(); + String queryName = form.getQueryName(); + if (schemaName == null || queryName == null) + throw new NotFoundException("schemaName and queryName are required"); + + UserSchema schema = QueryService.get().getUserSchema(user, container, schemaName); + if (schema == null) + throw new NotFoundException("schema not found"); + + QueryDefinition queryDef = QueryService.get().getQueryDef(user, container, schemaName, queryName); + if (queryDef == null) + queryDef = schema.getQueryDefForTable(queryName); + if (queryDef == null) + throw new NotFoundException("query not found"); + + renameCustomView(container, queryDef, view, form.getNewName(), errors); + + if (errors.hasErrors()) + return null; + else + return new ApiSimpleResponse("success", true); + } + } + + protected void renameCustomView(Container container, QueryDefinition queryDef, CustomView fromView, String newViewName, BindException errors) + { + if (newViewName != null && RESERVED_VIEW_NAMES.contains(newViewName.toLowerCase())) + errors.reject(ERROR_MSG, "The grid view name '" + newViewName + "' is not allowed."); + + String newName = StringUtils.trimToNull(newViewName); + if (StringUtils.isEmpty(newName)) + errors.reject(ERROR_MSG, "View name cannot be blank."); + + if (errors.hasErrors()) + return; + + User owner = getUser(); + boolean canSaveForAllUsers = container.hasPermission(getUser(), EditSharedViewPermission.class); + + if (!fromView.canEdit(container, errors)) + return; + + if (fromView.isSession()) + { + errors.reject(ERROR_MSG, "Cannot rename a session view."); + return; + } + + CustomView duplicateView = queryDef.getCustomView(owner, getViewContext().getRequest(), newName); + if (duplicateView == null && canSaveForAllUsers) + duplicateView = queryDef.getSharedCustomView(newName); + if (duplicateView != null) + { + // only allow duplicate view name if creating a new private view to shadow an existing shared view + if (!(!fromView.isShared() && duplicateView.isShared())) + { + errors.reject(ERROR_MSG, "Another saved view by the name \"" + newName + "\" already exists. "); + return; + } + } + + fromView.setName(newViewName); + fromView.save(getUser(), getViewContext().getRequest()); + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Configure.class) + public class PropertiesQueryAction extends FormViewAction + { + PropertiesForm _form = null; + private String _queryName; + + @Override + public void validateCommand(PropertiesForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(PropertiesForm form, boolean reshow, BindException errors) + { + // assertQueryExists requires that it be well-formed + // assertQueryExists(form); + QueryDefinition queryDef = form.getQueryDef(); + _form = form; + _form.setDescription(queryDef.getDescription()); + _form.setInheritable(queryDef.canInherit()); + _form.setHidden(queryDef.isHidden()); + setHelpTopic("editQueryProperties"); + _queryName = form.getQueryName(); + + return new JspView<>("/org/labkey/query/view/propertiesQuery.jsp", form, errors); + } + + @Override + public boolean handlePost(PropertiesForm form, BindException errors) throws Exception + { + // assertQueryExists requires that it be well-formed + // assertQueryExists(form); + if (!form.canEdit()) + { + throw new UnauthorizedException(); + } + QueryDefinition queryDef = form.getQueryDef(); + _queryName = form.getQueryName(); + if (!queryDef.getDefinitionContainer().getId().equals(getContainer().getId())) + throw new NotFoundException("Query not found"); + + _form = form; + + if (!StringUtils.isEmpty(form.rename) && !form.rename.equalsIgnoreCase(queryDef.getName())) + { + // issue 17766: check if query or table exist with this name + if (null != QueryManager.get().getQueryDef(getContainer(), form.getSchemaName(), form.rename, true) + || null != form.getSchema().getTable(form.rename,null)) + { + errors.reject(ERROR_MSG, "A query or table with the name \"" + form.rename + "\" already exists."); + return false; + } + + // Issue 40895: update queryName in xml metadata + updateXmlMetadata(queryDef); + queryDef.setName(form.rename); + // update form so getSuccessURL() works + _form = new PropertiesForm(form.getSchemaName(), form.rename); + _form.setViewContext(form.getViewContext()); + _queryName = form.rename; + } + + queryDef.setDescription(form.description); + queryDef.setCanInherit(form.inheritable); + queryDef.setIsHidden(form.hidden); + queryDef.save(getUser(), getContainer()); + return true; + } + + private void updateXmlMetadata(QueryDefinition queryDef) throws XmlException + { + if (null != queryDef.getMetadataXml()) + { + TablesDocument doc = TablesDocument.Factory.parse(queryDef.getMetadataXml()); + if (null != doc) + { + for (TableType tableType : doc.getTables().getTableArray()) + { + if (tableType.getTableName().equalsIgnoreCase(queryDef.getName())) + { + // update tableName in xml + tableType.setTableName(_form.rename); + } + } + XmlOptions xmlOptions = new XmlOptions(); + xmlOptions.setSavePrettyPrint(); + // Don't use an explicit namespace, making the XML much more readable + xmlOptions.setUseDefaultNamespace(); + queryDef.setMetadataXml(doc.xmlText(xmlOptions)); + } + } + } + + @Override + public ActionURL getSuccessURL(PropertiesForm propertiesForm) + { + ActionURL url = new ActionURL(BeginAction.class, propertiesForm.getViewContext().getContainer()); + url.addParameter("schemaName", propertiesForm.getSchemaName()); + if (null != _queryName) + url.addParameter("queryName", _queryName); + return url; + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + root.addChild("Edit query properties"); + } + } + + @ActionNames("truncateTable") + @RequiresPermission(AdminPermission.class) + public static class TruncateTableAction extends MutatingApiAction + { + UserSchema schema; + TableInfo table; + + @Override + public void validateForm(QueryForm form, Errors errors) + { + String schemaName = form.getSchemaName(); + String queryName = form.getQueryName(); + + if (isBlank(schemaName) || isBlank(queryName)) + throw new NotFoundException("schemaName and queryName are required"); + + schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); + if (null == schema) + throw new NotFoundException("The schema '" + schemaName + "' does not exist."); + + table = schema.getTable(queryName, null); + if (null == table) + throw new NotFoundException("The query '" + queryName + "' in the schema '" + schemaName + "' does not exist."); + } + + @Override + public ApiResponse execute(QueryForm form, BindException errors) throws Exception + { + int deletedRows; + QueryUpdateService qus = table.getUpdateService(); + + if (null == qus) + throw new IllegalArgumentException("The query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "' is not truncatable."); + + try (DbScope.Transaction transaction = table.getSchema().getScope().ensureTransaction()) + { + deletedRows = qus.truncateRows(getUser(), getContainer(), null, null); + transaction.commit(); + } + + ApiSimpleResponse response = new ApiSimpleResponse(); + + response.put("success", true); + response.put(BaseSaveRowsAction.PROP_SCHEMA_NAME, form.getSchemaName()); + response.put(BaseSaveRowsAction.PROP_QUERY_NAME, form.getQueryName()); + response.put("deletedRows", deletedRows); + + return response; + } + } + + + @RequiresPermission(DeletePermission.class) + public static class DeleteQueryRowsAction extends FormHandlerAction + { + @Override + public void validateCommand(QueryForm target, Errors errors) + { + } + + @Override + public boolean handlePost(QueryForm form, BindException errors) + { + TableInfo table = form.getQueryView().getTable(); + + if (!table.hasPermission(getUser(), DeletePermission.class)) + { + throw new UnauthorizedException(); + } + + QueryUpdateService updateService = table.getUpdateService(); + if (updateService == null) + throw new UnsupportedOperationException("Unable to delete - no QueryUpdateService registered for " + form.getSchemaName() + "." + form.getQueryName()); + + Set ids = DataRegionSelection.getSelected(form.getViewContext(), null, true); + List pks = table.getPkColumns(); + int numPks = pks.size(); + + //normalize the pks to arrays of correctly-typed objects + List> keyValues = new ArrayList<>(ids.size()); + for (String id : ids) + { + String[] stringValues; + if (numPks > 1) + { + stringValues = id.split(","); + if (stringValues.length != numPks) + throw new IllegalStateException("This table has " + numPks + " primary-key columns, but " + stringValues.length + " primary-key values were provided!"); + } + else + stringValues = new String[]{id}; + + Map rowKeyValues = new CaseInsensitiveHashMap<>(); + for (int idx = 0; idx < numPks; ++idx) + { + ColumnInfo keyColumn = pks.get(idx); + Object keyValue = keyColumn.getJavaClass() == String.class ? stringValues[idx] : ConvertUtils.convert(stringValues[idx], keyColumn.getJavaClass()); + rowKeyValues.put(keyColumn.getName(), keyValue); + } + keyValues.add(rowKeyValues); + } + + DbSchema dbSchema = table.getSchema(); + try + { + dbSchema.getScope().executeWithRetry(tx -> + { + try + { + updateService.deleteRows(getUser(), getContainer(), keyValues, null, null); + } + catch (SQLException x) + { + if (!RuntimeSQLException.isConstraintException(x)) + throw new RuntimeSQLException(x); + errors.reject(ERROR_MSG, getMessage(table.getSchema().getSqlDialect(), x)); + } + catch (DataIntegrityViolationException | OptimisticConflictException e) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + catch (BatchValidationException x) + { + x.addToErrors(errors); + } + catch (Exception x) + { + errors.reject(ERROR_MSG, null == x.getMessage() ? x.toString() : x.getMessage()); + ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), x); + } + // need to throw here to avoid committing tx + if (errors.hasErrors()) + throw new DbScope.RetryPassthroughException(errors); + return true; + }); + } + catch (DbScope.RetryPassthroughException x) + { + if (x.getCause() != errors) + x.throwRuntimeException(); + } + return !errors.hasErrors(); + } + + @Override + public ActionURL getSuccessURL(QueryForm form) + { + return form.getReturnActionURL(); + } + } + + @RequiresPermission(ReadPermission.class) + public static class DetailsQueryRowAction extends UserSchemaAction + { + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + + if (_schema != null && _table != null) + { + if (_table.hasPermission(getUser(), UpdatePermission.class)) + { + StringExpression updateExpr = _form.getQueryDef().urlExpr(QueryAction.updateQueryRow, _schema.getContainer()); + if (updateExpr != null) + { + String url = updateExpr.eval(tableForm.getTypedValues()); + if (url != null) + { + ActionURL updateUrl = new ActionURL(url); + ActionButton editButton = new ActionButton("Edit", updateUrl); + bb.add(editButton); + } + } + } + + + ActionURL gridUrl; + if (_form.getReturnActionURL() != null) + { + // If we have a specific return URL requested, use that + gridUrl = _form.getReturnActionURL(); + } + else + { + // Otherwise go back to the default grid view + gridUrl = _schema.urlFor(QueryAction.executeQuery, _form.getQueryDef()); + } + if (gridUrl != null) + { + ActionButton gridButton = new ActionButton("Show Grid", gridUrl); + bb.add(gridButton); + } + } + + DetailsView detailsView = new DetailsView(tableForm); + detailsView.setFrame(WebPartView.FrameType.PORTAL); + detailsView.getDataRegion().setButtonBar(bb); + + VBox view = new VBox(detailsView); + + DetailsURL detailsURL = QueryService.get().getAuditDetailsURL(getUser(), getContainer(), _table); + + if (detailsURL != null) + { + String url = detailsURL.eval(tableForm.getTypedValues()); + if (url != null) + { + ActionURL auditURL = new ActionURL(url); + + QueryView historyView = QueryUpdateAuditProvider.createDetailsQueryView(getViewContext(), + auditURL.getParameter(QueryParam.schemaName), + auditURL.getParameter(QueryParam.queryName), + auditURL.getParameter("keyValue"), errors); + + if (null != historyView) + { + historyView.setFrame(WebPartView.FrameType.PORTAL); + historyView.setTitle("History"); + + view.addView(historyView); + } + } + } + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) + { + return false; + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Details"); + } + } + + @RequiresPermission(InsertPermission.class) + public static class InsertQueryRowAction extends UserSchemaAction + { + @Override + public BindException bindParameters(PropertyValues m) throws Exception + { + BindException bind = super.bindParameters(m); + + // what is going on with UserSchemaAction and form binding? Why doesn't successUrl bind? + QueryUpdateForm form = (QueryUpdateForm)bind.getTarget(); + if (null == form.getSuccessUrl() && null != m.getPropertyValue(ActionURL.Param.successUrl.name())) + form.setSuccessUrl(new ReturnURLString(m.getPropertyValue(ActionURL.Param.successUrl.name()).getValue().toString())); + return bind; + } + + Map insertedRow = null; + + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Insert Row"); + + InsertView view = new InsertView(tableForm, errors); + view.getDataRegion().setButtonBar(createSubmitCancelButtonBar(tableForm)); + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) + { + List> list = doInsertUpdate(tableForm, errors, true); + if (null != list && list.size() == 1) + insertedRow = list.get(0); + return 0 == errors.getErrorCount(); + } + + /** + * NOTE: UserSchemaAction.addNavTrail() uses this method getSuccessURL() for the nav trail link (form==null). + * It is used for where to go on success, and also as a "back" link in the nav trail + * If there is a setSuccessUrl specified, we will use that for successful submit + */ + @Override + public ActionURL getSuccessURL(QueryUpdateForm form) + { + if (null == form) + return super.getSuccessURL(null); + + String str = null; + if (form.getSuccessUrl() != null) + str = form.getSuccessUrl().toString(); + if (isBlank(str)) + str = form.getReturnUrl(); + + if ("details.view".equals(str)) + { + if (null == insertedRow) + return super.getSuccessURL(form); + StringExpression se = form.getTable().getDetailsURL(null, getContainer()); + if (null == se) + return super.getSuccessURL(form); + str = se.eval(insertedRow); + } + try + { + if (!isBlank(str)) + return new ActionURL(str); + } + catch (IllegalArgumentException x) + { + // pass + } + return super.getSuccessURL(form); + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Insert " + _table.getName()); + } + } + + @RequiresPermission(UpdatePermission.class) + public static class UpdateQueryRowAction extends UserSchemaAction + { + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + ButtonBar bb = createSubmitCancelButtonBar(tableForm); + UpdateView view = new UpdateView(tableForm, errors); + view.getDataRegion().setButtonBar(bb); + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) throws Exception + { + doInsertUpdate(tableForm, errors, false); + return 0 == errors.getErrorCount(); + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Edit " + _table.getName()); + } + } + + @RequiresPermission(UpdatePermission.class) + public static class UpdateQueryRowsAction extends UpdateQueryRowAction + { + @Override + public ModelAndView handleRequest(QueryUpdateForm tableForm, BindException errors) throws Exception + { + tableForm.setBulkUpdate(true); + return super.handleRequest(tableForm, errors); + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) throws Exception + { + boolean ret; + + if (tableForm.isDataSubmit()) + { + ret = super.handlePost(tableForm, errors); + if (ret) + DataRegionSelection.clearAll(getViewContext(), null); // in case we altered primary keys, see issue #35055 + return ret; + } + + return false; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Edit Multiple " + _table.getName()); + } + } + + // alias + public static class DeleteAction extends DeleteQueryRowsAction + { + } + + public abstract static class QueryViewAction extends SimpleViewAction + { + QueryForm _form; + QueryView _queryView; + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class APIQueryForm extends ContainerFilterQueryForm + { + private Integer _start; + private Integer _limit; + private boolean _includeDetailsColumn = false; + private boolean _includeUpdateColumn = false; + private boolean _includeTotalCount = true; + private boolean _includeStyle = false; + private boolean _includeDisplayValues = false; + private boolean _minimalColumns = true; + private boolean _includeMetadata = true; + + public Integer getStart() + { + return _start; + } + + public void setStart(Integer start) + { + _start = start; + } + + public Integer getLimit() + { + return _limit; + } + + public void setLimit(Integer limit) + { + _limit = limit; + } + + public boolean isIncludeTotalCount() + { + return _includeTotalCount; + } + + public void setIncludeTotalCount(boolean includeTotalCount) + { + _includeTotalCount = includeTotalCount; + } + + public boolean isIncludeStyle() + { + return _includeStyle; + } + + public void setIncludeStyle(boolean includeStyle) + { + _includeStyle = includeStyle; + } + + public boolean isIncludeDetailsColumn() + { + return _includeDetailsColumn; + } + + public void setIncludeDetailsColumn(boolean includeDetailsColumn) + { + _includeDetailsColumn = includeDetailsColumn; + } + + public boolean isIncludeUpdateColumn() + { + return _includeUpdateColumn; + } + + public void setIncludeUpdateColumn(boolean includeUpdateColumn) + { + _includeUpdateColumn = includeUpdateColumn; + } + + public boolean isIncludeDisplayValues() + { + return _includeDisplayValues; + } + + public void setIncludeDisplayValues(boolean includeDisplayValues) + { + _includeDisplayValues = includeDisplayValues; + } + + public boolean isMinimalColumns() + { + return _minimalColumns; + } + + public void setMinimalColumns(boolean minimalColumns) + { + _minimalColumns = minimalColumns; + } + + public boolean isIncludeMetadata() + { + return _includeMetadata; + } + + public void setIncludeMetadata(boolean includeMetadata) + { + _includeMetadata = includeMetadata; + } + + @Override + protected QuerySettings createQuerySettings(UserSchema schema) + { + QuerySettings results = super.createQuerySettings(schema); + + // See dataintegration/202: The java client api / remote ETL calls selectRows with showRows=all. We need to test _initParameters to properly read this + boolean missingShowRows = null == getViewContext().getRequest().getParameter(getDataRegionName() + "." + QueryParam.showRows) && null == _initParameters.getPropertyValue(getDataRegionName() + "." + QueryParam.showRows); + if (null == getLimit() && !results.isMaxRowsSet() && missingShowRows) + { + results.setShowRows(ShowRows.PAGINATED); + results.setMaxRows(DEFAULT_API_MAX_ROWS); + } + + if (getLimit() != null) + { + results.setShowRows(ShowRows.PAGINATED); + results.setMaxRows(getLimit()); + } + if (getStart() != null) + results.setOffset(getStart()); + + return results; + } + } + + public static final int DEFAULT_API_MAX_ROWS = 100000; + + @CSRF(CSRF.Method.NONE) // No need for CSRF token --- this is a non-mutating action that supports POST to allow for large payloads, see #36056 + @ActionNames("selectRows, getQuery") + @RequiresPermission(ReadPermission.class) + @ApiVersion(9.1) + @Action(ActionType.SelectData.class) + public class SelectRowsAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(APIQueryForm form, BindException errors) + { + // Issue 12233: add implicit maxRows=100k when using client API + QueryView view = form.getQueryView(); + + view.setShowPagination(form.isIncludeTotalCount()); + + //if viewName was specified, ensure that it was actually found and used + //QueryView.create() will happily ignore an invalid view name and just return the default view + if (null != StringUtils.trimToNull(form.getViewName()) && + null == view.getQueryDef().getCustomView(getUser(), getViewContext().getRequest(), form.getViewName())) + { + throw new NotFoundException("The requested view '" + form.getViewName() + "' does not exist for this user."); + } + + TableInfo t = view.getTable(); + if (null == t) + { + List qpes = view.getParseErrors(); + if (!qpes.isEmpty()) + throw qpes.get(0); + throw new NotFoundException(form.getQueryName()); + } + + boolean isEditable = isQueryEditable(view.getTable()); + boolean metaDataOnly = form.getQuerySettings().getMaxRows() == 0; + boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; + boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; + + ApiQueryResponse response; + + // 13.2 introduced the getData API action, a condensed response wire format, and a js wrapper to consume the wire format. Support this as an option for legacy APIs. + if (getRequestedApiVersion() >= 13.2) + { + ReportingApiQueryResponse fancyResponse = new ReportingApiQueryResponse(view, isEditable, true, view.getQueryDef().getName(), form.getQuerySettings().getOffset(), null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); + fancyResponse.arrayMultiValueColumns(arrayMultiValueColumns); + fancyResponse.includeFormattedValue(includeFormattedValue); + response = fancyResponse; + } + //if requested version is >= 9.1, use the extended api query response + else if (getRequestedApiVersion() >= 9.1) + { + response = new ExtendedApiQueryResponse(view, isEditable, true, + form.getSchemaName(), form.getQueryName(), form.getQuerySettings().getOffset(), null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); + } + else + { + response = new ApiQueryResponse(view, isEditable, true, + form.getSchemaName(), form.getQueryName(), form.getQuerySettings().getOffset(), null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), + form.isIncludeDisplayValues(), form.isIncludeMetadata()); + } + response.includeStyle(form.isIncludeStyle()); + + // Issues 29515 and 32269 - force key and other non-requested columns to be sent back, but only if the client has + // requested minimal columns, as we now do for ExtJS stores + if (form.isMinimalColumns()) + { + // Be sure to use the settings from the view, as it may have swapped it out with a customized version. + // See issue 38747. + response.setColumnFilter(view.getSettings().getFieldKeys()); + } + + return response; + } + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public static class GetDataAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws Exception + { + ObjectMapper mapper = JsonUtil.createDefaultMapper(); + mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + JSONObject object = form.getJsonObject(); + if (object == null) + { + object = new JSONObject(); + } + DataRequest builder = mapper.readValue(object.toString(), DataRequest.class); + + return builder.render(getViewContext(), errors); + } + } + + protected boolean isQueryEditable(TableInfo table) + { + if (!getContainer().hasPermission("isQueryEditable", getUser(), DeletePermission.class)) + return false; + QueryUpdateService updateService = null; + try + { + updateService = table.getUpdateService(); + } + catch(Exception ignore) {} + return null != table && null != updateService; + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExecuteSqlForm extends APIQueryForm + { + private String _sql; + private Integer _maxRows; + private Integer _offset; + private boolean _saveInSession; + + public String getSql() + { + return _sql; + } + + public void setSql(String sql) + { + _sql = PageFlowUtil.wafDecode(StringUtils.trim(sql)); + } + + public Integer getMaxRows() + { + return _maxRows; + } + + public void setMaxRows(Integer maxRows) + { + _maxRows = maxRows; + } + + public Integer getOffset() + { + return _offset; + } + + public void setOffset(Integer offset) + { + _offset = offset; + } + + @Override + public void setLimit(Integer limit) + { + _maxRows = limit; + } + + @Override + public void setStart(Integer start) + { + _offset = start; + } + + public boolean isSaveInSession() + { + return _saveInSession; + } + + public void setSaveInSession(boolean saveInSession) + { + _saveInSession = saveInSession; + } + + @Override + public String getQueryName() + { + // ExecuteSqlAction doesn't allow setting query name parameter. + return null; + } + + @Override + public void setQueryName(String name) + { + // ExecuteSqlAction doesn't allow setting query name parameter. + } + } + + @CSRF(CSRF.Method.NONE) // No need for CSRF token --- this is a non-mutating action that supports POST to allow for large payloads, see #36056 + @RequiresPermission(ReadPermission.class) + @ApiVersion(9.1) + @Action(ActionType.SelectData.class) + public class ExecuteSqlAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(ExecuteSqlForm form, BindException errors) + { + form.ensureSchemaExists(); + + String schemaName = StringUtils.trimToNull(form.getQuerySettings().getSchemaName()); + if (null == schemaName) + throw new IllegalArgumentException("No value was supplied for the required parameter 'schemaName'."); + String sql = form.getSql(); + if (StringUtils.isBlank(sql)) + throw new IllegalArgumentException("No value was supplied for the required parameter 'sql'."); + + //create a temp query settings object initialized with the posted LabKey SQL + //this will provide a temporary QueryDefinition to Query + QuerySettings settings = form.getQuerySettings(); + if (form.isSaveInSession()) + { + HttpSession session = getViewContext().getSession(); + if (session == null) + throw new IllegalStateException("Session required"); + + QueryDefinition def = QueryService.get().saveSessionQuery(getViewContext(), getContainer(), schemaName, sql); + settings.setDataRegionName("executeSql"); + settings.setQueryName(def.getName()); + } + else + { + settings = new TempQuerySettings(getViewContext(), sql, settings); + } + + //need to explicitly turn off various UI options that will try to refer to the + //current URL and query string + settings.setAllowChooseView(false); + settings.setAllowCustomizeView(false); + + // Issue 12233: add implicit maxRows=100k when using client API + settings.setShowRows(ShowRows.PAGINATED); + settings.setMaxRows(DEFAULT_API_MAX_ROWS); + + // 16961: ExecuteSql API without maxRows parameter defaults to returning 100 rows + //apply optional settings (maxRows, offset) + boolean metaDataOnly = false; + if (null != form.getMaxRows() && (form.getMaxRows() >= 0 || form.getMaxRows() == Table.ALL_ROWS)) + { + settings.setMaxRows(form.getMaxRows()); + metaDataOnly = Table.NO_ROWS == form.getMaxRows(); + } + + int offset = 0; + if (null != form.getOffset()) + { + settings.setOffset(form.getOffset().longValue()); + offset = form.getOffset(); + } + + //build a query view using the schema and settings + QueryView view = new QueryView(form.getSchema(), settings, errors); + view.setShowRecordSelectors(false); + view.setShowExportButtons(false); + view.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + view.setShowPagination(form.isIncludeTotalCount()); + + TableInfo t = view.getTable(); + boolean isEditable = null != t && isQueryEditable(view.getTable()); + boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; + boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; + + ApiQueryResponse response; + + // 13.2 introduced the getData API action, a condensed response wire format, and a js wrapper to consume the wire format. Support this as an option for legacy APIs. + if (getRequestedApiVersion() >= 13.2) + { + ReportingApiQueryResponse fancyResponse = new ReportingApiQueryResponse(view, isEditable, false, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); + fancyResponse.arrayMultiValueColumns(arrayMultiValueColumns); + fancyResponse.includeFormattedValue(includeFormattedValue); + response = fancyResponse; + } + else if (getRequestedApiVersion() >= 9.1) + { + response = new ExtendedApiQueryResponse(view, isEditable, + false, schemaName, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); + } + else + { + response = new ApiQueryResponse(view, isEditable, + false, schemaName, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), + form.isIncludeDisplayValues()); + } + response.includeStyle(form.isIncludeStyle()); + + return response; + } + } + + public static class ContainerFilterQueryForm extends QueryForm + { + private String _containerFilter; + + public String getContainerFilter() + { + return _containerFilter; + } + + public void setContainerFilter(String containerFilter) + { + _containerFilter = containerFilter; + } + + @Override + protected QuerySettings createQuerySettings(UserSchema schema) + { + var result = super.createQuerySettings(schema); + if (getContainerFilter() != null) + { + // If the user specified an incorrect filter, throw an IllegalArgumentException + try + { + ContainerFilter.Type containerFilterType = ContainerFilter.Type.valueOf(getContainerFilter()); + result.setContainerFilterName(containerFilterType.name()); + } + catch (IllegalArgumentException e) + { + // Remove bogus value from error message, Issue 45567 + throw new IllegalArgumentException("'containerFilter' parameter is not valid"); + } + } + return result; + } + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public class SelectDistinctAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(ContainerFilterQueryForm form, BindException errors) throws Exception + { + TableInfo table = form.getQueryView().getTable(); + if (null == table) + throw new NotFoundException(); + SqlSelector sqlSelector = getDistinctSql(table, form, errors); + + if (errors.hasErrors() || null == sqlSelector) + return null; + + ApiResponseWriter writer = new ApiJsonWriter(getViewContext().getResponse()); + + try (ResultSet rs = sqlSelector.getResultSet()) + { + writer.startResponse(); + writer.writeProperty("schemaName", form.getSchemaName()); + writer.writeProperty("queryName", form.getQueryName()); + writer.startList("values"); + + while (rs.next()) + { + writer.writeListEntry(rs.getObject(1)); + } + } + catch (SQLException x) + { + throw new RuntimeSQLException(x); + } + catch (DataAccessException x) // Spring error translator can return various subclasses of this + { + throw new RuntimeException(x); + } + writer.endList(); + writer.endResponse(); + + return null; + } + + @Nullable + private SqlSelector getDistinctSql(TableInfo table, ContainerFilterQueryForm form, BindException errors) + { + QuerySettings settings = form.getQuerySettings(); + QueryService service = QueryService.get(); + + if (null == getViewContext().getRequest().getParameter(QueryParam.maxRows.toString())) + { + settings.setMaxRows(DEFAULT_API_MAX_ROWS); + } + else + { + try + { + int maxRows = Integer.parseInt(getViewContext().getRequest().getParameter(QueryParam.maxRows.toString())); + settings.setMaxRows(maxRows); + } + catch (NumberFormatException e) + { + // Standard exception message, Issue 45567 + QuerySettings.throwParameterParseException(QueryParam.maxRows); + } + } + + List fieldKeys = settings.getFieldKeys(); + if (null == fieldKeys || fieldKeys.size() != 1) + { + errors.reject(ERROR_MSG, "Select Distinct requires that only one column be requested."); + return null; + } + Map columns = service.getColumns(table, fieldKeys); + if (columns.size() != 1) + { + errors.reject(ERROR_MSG, "Select Distinct requires that only one column be requested."); + return null; + } + + ColumnInfo col = columns.get(settings.getFieldKeys().get(0)); + if (col == null) + { + errors.reject(ERROR_MSG, "\"" + settings.getFieldKeys().get(0).getName() + "\" is not a valid column."); + return null; + } + + try + { + SimpleFilter filter = getFilterFromQueryForm(form); + + // Strip out filters on columns that don't exist - issue 21669 + service.ensureRequiredColumns(table, columns.values(), filter, null, new HashSet<>()); + QueryLogging queryLogging = new QueryLogging(); + QueryService.SelectBuilder builder = service.getSelectBuilder(table) + .columns(columns.values()) + .filter(filter) + .queryLogging(queryLogging) + .distinct(true); + SQLFragment selectSql = builder.buildSqlFragment(); + + // TODO: queryLogging.isShouldAudit() is always false at this point. + // The only place that seems to set this is ComplianceQueryLoggingProfileListener.queryInvoked() + if (queryLogging.isShouldAudit() && null != queryLogging.getExceptionToThrowIfLoggingIsEnabled()) + { + // this is probably a more helpful message + errors.reject(ERROR_MSG, "Cannot choose values from a column that requires logging."); + return null; + } + + // Regenerate the column since the alias may have changed after call to getSelectSQL() + columns = service.getColumns(table, settings.getFieldKeys()); + var colGetAgain = columns.get(settings.getFieldKeys().get(0)); + // I don't believe the above comment, so here's an assert + assert(colGetAgain.getAlias().equals(col.getAlias())); + + SQLFragment sql = new SQLFragment("SELECT ").appendIdentifier(col.getAlias()).append(" AS value FROM ("); + sql.append(selectSql); + sql.append(") S ORDER BY value"); + + sql = table.getSqlDialect().limitRows(sql, settings.getMaxRows()); + + // 18875: Support Parameterized queries in Select Distinct + Map _namedParameters = settings.getQueryParameters(); + + service.bindNamedParameters(sql, _namedParameters); + service.validateNamedParameters(sql); + + return new SqlSelector(table.getSchema().getScope(), sql, queryLogging); + } + catch (ConversionException | QueryService.NamedParameterNotProvided e) + { + errors.reject(ERROR_MSG, e.getMessage()); + return null; + } + } + } + + private SimpleFilter getFilterFromQueryForm(QueryForm form) + { + QuerySettings settings = form.getQuerySettings(); + SimpleFilter filter = null; + + // 21032: Respect 'ignoreFilter' + if (settings != null && !settings.getIgnoreUserFilter()) + { + // Attach any URL-based filters. This would apply to 'filterArray' from the JavaScript API. + filter = new SimpleFilter(settings.getBaseFilter()); + + String dataRegionName = form.getDataRegionName(); + if (StringUtils.trimToNull(dataRegionName) == null) + dataRegionName = QueryView.DATAREGIONNAME_DEFAULT; + + // Support for 'viewName' + CustomView view = settings.getCustomView(getViewContext(), form.getQueryDef()); + if (null != view && view.hasFilterOrSort() && !settings.getIgnoreViewFilter()) + { + ActionURL url = new ActionURL(SelectDistinctAction.class, getContainer()); + view.applyFilterAndSortToURL(url, dataRegionName); + filter.addAllClauses(new SimpleFilter(url, dataRegionName)); + } + + filter.addUrlFilters(settings.getSortFilterURL(), dataRegionName, Collections.emptyList(), getUser(), getContainer()); + } + + return filter; + } + + @RequiresPermission(ReadPermission.class) + public class GetColumnSummaryStatsAction extends ReadOnlyApiAction + { + private FieldKey _colFieldKey; + + @Override + public void validateForm(QueryForm form, Errors errors) + { + QuerySettings settings = form.getQuerySettings(); + List fieldKeys = settings != null ? settings.getFieldKeys() : null; + if (null == fieldKeys || fieldKeys.size() != 1) + errors.reject(ERROR_MSG, "GetColumnSummaryStats requires that only one column be requested."); + else + _colFieldKey = fieldKeys.get(0); + } + + @Override + public ApiResponse execute(QueryForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + QueryView view = form.getQueryView(); + DisplayColumn displayColumn = null; + + for (DisplayColumn dc : view.getDisplayColumns()) + { + if (dc.getColumnInfo() != null && _colFieldKey.equals(dc.getColumnInfo().getFieldKey())) + { + displayColumn = dc; + break; + } + } + + if (displayColumn != null && displayColumn.getColumnInfo() != null) + { + // get the map of the analytics providers to their relevant aggregates and add the information to the response + Map> analyticsProviders = new LinkedHashMap<>(); + Set colAggregates = new HashSet<>(); + for (ColumnAnalyticsProvider analyticsProvider : displayColumn.getAnalyticsProviders()) + { + if (analyticsProvider instanceof BaseAggregatesAnalyticsProvider baseAggProvider) + { + Map props = new HashMap<>(); + props.put("label", baseAggProvider.getLabel()); + + List aggregateNames = new ArrayList<>(); + for (Aggregate aggregate : AnalyticsProviderItem.createAggregates(baseAggProvider, _colFieldKey, null)) + { + aggregateNames.add(aggregate.getType().getName()); + colAggregates.add(aggregate); + } + props.put("aggregates", aggregateNames); + + analyticsProviders.put(baseAggProvider.getName(), props); + } + } + + // get the filter set from the queryform and verify that they resolve + SimpleFilter filter = getFilterFromQueryForm(form); + if (filter != null) + { + Map resolvedCols = QueryService.get().getColumns(view.getTable(), filter.getAllFieldKeys()); + for (FieldKey filterFieldKey : filter.getAllFieldKeys()) + { + if (!resolvedCols.containsKey(filterFieldKey)) + filter.deleteConditions(filterFieldKey); + } + } + + // query the table/view for the aggregate results + Collection columns = Collections.singleton(displayColumn.getColumnInfo()); + TableSelector selector = new TableSelector(view.getTable(), columns, filter, null).setNamedParameters(form.getQuerySettings().getQueryParameters()); + Map> aggResults = selector.getAggregates(new ArrayList<>(colAggregates)); + + // create a response object mapping the analytics providers to their relevant aggregate results + Map> aggregateResults = new HashMap<>(); + if (aggResults.containsKey(_colFieldKey.toString())) + { + for (Aggregate.Result r : aggResults.get(_colFieldKey.toString())) + { + Map props = new HashMap<>(); + Aggregate.Type type = r.getAggregate().getType(); + props.put("label", type.getFullLabel()); + props.put("description", type.getDescription()); + props.put("value", r.getFormattedValue(displayColumn, getContainer()).value()); + aggregateResults.put(type.getName(), props); + } + + response.put("success", true); + response.put("analyticsProviders", analyticsProviders); + response.put("aggregateResults", aggregateResults); + } + else + { + response.put("success", false); + response.put("message", "Unable to get aggregate results for " + _colFieldKey); + } + } + else + { + response.put("success", false); + response.put("message", "Unable to find ColumnInfo for " + _colFieldKey); + } + + return response; + } + } + + @RequiresPermission(ReadPermission.class) + public class ImportAction extends AbstractQueryImportAction + { + private QueryForm _form; + + @Override + protected void initRequest(QueryForm form) throws ServletException + { + _form = form; + + _insertOption = form.getInsertOption(); + QueryDefinition query = form.getQueryDef(); + List qpe = new ArrayList<>(); + TableInfo t = query.getTable(form.getSchema(), qpe, true); + if (!qpe.isEmpty()) + throw qpe.get(0); + if (null != t) + setTarget(t); + _auditBehaviorType = form.getAuditBehavior(); + _auditUserComment = form.getAuditUserComment(); + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + initRequest(form); + return super.getDefaultImportView(form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + var executeQuery = _form.urlFor(QueryAction.executeQuery); + if (null == executeQuery) + root.addChild(_form.getQueryName()); + else + root.addChild(_form.getQueryName(), executeQuery); + root.addChild("Import Data"); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportSqlForm + { + private String _sql; + private String _schemaName; + private String _containerFilter; + private String _format = "excel"; + + public String getSql() + { + return _sql; + } + + public void setSql(String sql) + { + _sql = PageFlowUtil.wafDecode(sql); + } + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public String getContainerFilter() + { + return _containerFilter; + } + + public void setContainerFilter(String containerFilter) + { + _containerFilter = containerFilter; + } + + public String getFormat() + { + return _format; + } + + public void setFormat(String format) + { + _format = format; + } + } + + @RequiresPermission(ReadPermission.class) + @ApiVersion(9.2) + @Action(ActionType.Export.class) + public static class ExportSqlAction extends ExportAction + { + @Override + public void export(ExportSqlForm form, HttpServletResponse response, BindException errors) throws IOException, ExportException + { + String schemaName = StringUtils.trimToNull(form.getSchemaName()); + if (null == schemaName) + throw new NotFoundException("No value was supplied for the required parameter 'schemaName'"); + String sql = StringUtils.trimToNull(form.getSql()); + if (null == sql) + throw new NotFoundException("No value was supplied for the required parameter 'sql'"); + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); + + if (null == schema) + throw new NotFoundException("Schema '" + schemaName + "' not found in this folder"); + + //create a temp query settings object initialized with the posted LabKey SQL + //this will provide a temporary QueryDefinition to Query + TempQuerySettings settings = new TempQuerySettings(getViewContext(), sql); + + //need to explicitly turn off various UI options that will try to refer to the + //current URL and query string + settings.setAllowChooseView(false); + settings.setAllowCustomizeView(false); + + //return all rows + settings.setShowRows(ShowRows.ALL); + + //add container filter if supplied + if (form.getContainerFilter() != null && !form.getContainerFilter().isEmpty()) + { + ContainerFilter.Type containerFilterType = + ContainerFilter.Type.valueOf(form.getContainerFilter()); + settings.setContainerFilterName(containerFilterType.name()); + } + + //build a query view using the schema and settings + QueryView view = new QueryView(schema, settings, errors); + view.setShowRecordSelectors(false); + view.setShowExportButtons(false); + view.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + + //export it + ResponseHelper.setPrivate(response); + response.setHeader("X-Robots-Tag", "noindex"); + + if ("excel".equalsIgnoreCase(form.getFormat())) + view.exportToExcel(response); + else if ("tsv".equalsIgnoreCase(form.getFormat())) + view.exportToTsv(response); + else + errors.reject(null, "Invalid format specified; must be 'excel' or 'tsv'"); + + for (QueryException qe : view.getParseErrors()) + errors.reject(null, qe.getMessage()); + + if (errors.hasErrors()) + throw new ExportException(new SimpleErrorView(errors, false)); + } + } + + public static class ApiSaveRowsForm extends SimpleApiJsonForm + { + } + + private enum CommandType + { + insert(InsertPermission.class, QueryService.AuditAction.INSERT) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException + { + BatchValidationException errors = new BatchValidationException(); + List> insertedRows = qus.insertRows(user, container, rows, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + // Issue 42519: Submitter role not able to insert + // as per the definition of submitter, should allow insert without read + if (qus.hasPermission(user, ReadPermission.class) && shouldReselect(configParameters)) + { + return qus.getRows(user, container, insertedRows); + } + else + { + return insertedRows; + } + } + }, + insertWithKeys(InsertPermission.class, QueryService.AuditAction.INSERT) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException + { + List> newRows = new ArrayList<>(); + List> oldKeys = new ArrayList<>(); + for (Map row : rows) + { + //issue 13719: use CaseInsensitiveHashMaps. Also allow either values or oldKeys to be null + CaseInsensitiveHashMap newMap = row.get(SaveRowsAction.PROP_VALUES) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_VALUES)).toMap()) : new CaseInsensitiveHashMap<>(); + newRows.add(newMap); + + CaseInsensitiveHashMap oldMap = row.get(SaveRowsAction.PROP_OLD_KEYS) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_OLD_KEYS)).toMap()) : new CaseInsensitiveHashMap<>(); + oldKeys.add(oldMap); + } + BatchValidationException errors = new BatchValidationException(); + List> updatedRows = qus.insertRows(user, container, newRows, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + // Issue 42519: Submitter role not able to insert + // as per the definition of submitter, should allow insert without read + if (qus.hasPermission(user, ReadPermission.class) && shouldReselect(configParameters)) + { + updatedRows = qus.getRows(user, container, updatedRows); + } + List> results = new ArrayList<>(); + for (int i = 0; i < updatedRows.size(); i++) + { + Map result = new HashMap<>(); + result.put(SaveRowsAction.PROP_VALUES, updatedRows.get(i)); + result.put(SaveRowsAction.PROP_OLD_KEYS, oldKeys.get(i)); + results.add(result); + } + return results; + } + }, + importRows(InsertPermission.class, QueryService.AuditAction.INSERT) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, BatchValidationException + { + BatchValidationException errors = new BatchValidationException(); + DataIteratorBuilder it = new ListofMapsDataIterator.Builder(rows.get(0).keySet(), rows); + qus.importRows(user, container, it, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + return Collections.emptyList(); + } + }, + moveRows(MoveEntitiesPermission.class, QueryService.AuditAction.UPDATE) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException + { + BatchValidationException errors = new BatchValidationException(); + + Container targetContainer = (Container) configParameters.get(QueryUpdateService.ConfigParameters.TargetContainer); + Map updatedCounts = qus.moveRows(user, container, targetContainer, rows, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + return Collections.singletonList(updatedCounts); + } + }, + update(UpdatePermission.class, QueryService.AuditAction.UPDATE) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException + { + BatchValidationException errors = new BatchValidationException(); + List> updatedRows = qus.updateRows(user, container, rows, null, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + return shouldReselect(configParameters) ? qus.getRows(user, container, updatedRows) : updatedRows; + } + }, + updateChangingKeys(UpdatePermission.class, QueryService.AuditAction.UPDATE) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException + { + List> newRows = new ArrayList<>(); + List> oldKeys = new ArrayList<>(); + for (Map row : rows) + { + // issue 13719: use CaseInsensitiveHashMaps. Also allow either values or oldKeys to be null. + // this should never happen on an update, but we will let it fail later with a better error message instead of the NPE here + CaseInsensitiveHashMap newMap = row.get(SaveRowsAction.PROP_VALUES) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_VALUES)).toMap()) : new CaseInsensitiveHashMap<>(); + newRows.add(newMap); + + CaseInsensitiveHashMap oldMap = row.get(SaveRowsAction.PROP_OLD_KEYS) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_OLD_KEYS)).toMap()) : new CaseInsensitiveHashMap<>(); + oldKeys.add(oldMap); + } + BatchValidationException errors = new BatchValidationException(); + List> updatedRows = qus.updateRows(user, container, newRows, oldKeys, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + if (shouldReselect(configParameters)) + updatedRows = qus.getRows(user, container, updatedRows); + List> results = new ArrayList<>(); + for (int i = 0; i < updatedRows.size(); i++) + { + Map result = new HashMap<>(); + result.put(SaveRowsAction.PROP_VALUES, updatedRows.get(i)); + result.put(SaveRowsAction.PROP_OLD_KEYS, oldKeys.get(i)); + results.add(result); + } + return results; + } + }, + delete(DeletePermission.class, QueryService.AuditAction.DELETE) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException + { + return qus.deleteRows(user, container, rows, configParameters, extraContext); + } + }; + + private final Class _permission; + private final QueryService.AuditAction _auditAction; + + CommandType(Class permission, QueryService.AuditAction auditAction) + { + _permission = permission; + _auditAction = auditAction; + } + + public Class getPermission() + { + return _permission; + } + + public QueryService.AuditAction getAuditAction() + { + return _auditAction; + } + + public static boolean shouldReselect(Map configParameters) + { + if (configParameters == null || !configParameters.containsKey(QueryUpdateService.ConfigParameters.SkipReselectRows)) + return true; + + return Boolean.TRUE != configParameters.get(QueryUpdateService.ConfigParameters.SkipReselectRows); + } + + public abstract List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException; + } + + /** + * Base action class for insert/update/delete actions + */ + protected abstract static class BaseSaveRowsAction
extends MutatingApiAction + { + public static final String PROP_SCHEMA_NAME = "schemaName"; + public static final String PROP_QUERY_NAME = "queryName"; + public static final String PROP_CONTAINER_PATH = "containerPath"; + public static final String PROP_TARGET_CONTAINER_PATH = "targetContainerPath"; + public static final String PROP_COMMAND = "command"; + public static final String PROP_ROWS = "rows"; + + private JSONObject _json; + + @Override + public void validateForm(FORM apiSaveRowsForm, Errors errors) + { + _json = apiSaveRowsForm.getJsonObject(); + + // if the POST was done using FormData, the apiSaveRowsForm would not have bound the json data, so + // we'll instead look for that data in the request param directly + if (_json == null && getViewContext().getRequest() != null && getViewContext().getRequest().getParameter("json") != null) + _json = new JSONObject(getViewContext().getRequest().getParameter("json")); + } + + protected JSONObject getJsonObject() + { + return _json; + } + + protected Container getContainerForCommand(JSONObject json) + { + return getContainerForCommand(json, PROP_CONTAINER_PATH, getContainer()); + } + + protected Container getContainerForCommand(JSONObject json, String containerPathProp, @Nullable Container defaultContainer) + { + Container container; + String containerPath = StringUtils.trimToNull(json.optString(containerPathProp)); + if (containerPath == null) + { + if (defaultContainer != null) + container = defaultContainer; + else + throw new IllegalArgumentException(containerPathProp + " is required but was not provided."); + } + else + { + container = ContainerManager.getForPath(containerPath); + if (container == null) + { + throw new IllegalArgumentException("Unknown container: " + containerPath); + } + } + + // Issue 21850: Verify that the user has at least some sort of basic access to the container. We'll check for more downstream + if (!container.hasPermission(getUser(), ReadPermission.class) && + !container.hasPermission(getUser(), DeletePermission.class) && + !container.hasPermission(getUser(), InsertPermission.class) && + !container.hasPermission(getUser(), UpdatePermission.class)) + { + throw new UnauthorizedException(); + } + + return container; + } + + protected String getTargetContainerProp() + { + JSONObject json = getJsonObject(); + return json.optString(PROP_TARGET_CONTAINER_PATH, null); + } + + protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors) throws Exception + { + return executeJson(json, commandType, allowTransaction, errors, false); + } + + protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors, boolean isNestedTransaction) throws Exception + { + return executeJson(json, commandType, allowTransaction, errors, isNestedTransaction, null); + } + + protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors, boolean isNestedTransaction, @Nullable Integer commandIndex) throws Exception + { + JSONObject response = new JSONObject(); + Container container = getContainerForCommand(json); + User user = getUser(); + + if (json == null) + throw new ValidationException("Empty request"); + + JSONArray rows; + try + { + rows = json.getJSONArray(PROP_ROWS); + if (rows.isEmpty()) + throw new ValidationException("No '" + PROP_ROWS + "' array supplied."); + } + catch (JSONException x) + { + throw new ValidationException("No '" + PROP_ROWS + "' array supplied."); + } + + String schemaName = json.getString(PROP_SCHEMA_NAME); + String queryName = json.getString(PROP_QUERY_NAME); + TableInfo table = getTableInfo(container, user, schemaName, queryName); + + if (!table.hasPermission(user, commandType.getPermission())) + throw new UnauthorizedException(); + + if (commandType != CommandType.insert && table.getPkColumns().isEmpty()) + throw new IllegalArgumentException("The table '" + table.getPublicSchemaName() + "." + + table.getPublicName() + "' cannot be updated because it has no primary key defined!"); + + QueryUpdateService qus = table.getUpdateService(); + if (null == qus) + throw new IllegalArgumentException("The query '" + queryName + "' in the schema '" + schemaName + + "' is not updatable via the HTTP-based APIs."); + + int rowsAffected = 0; + + List> rowsToProcess = new ArrayList<>(); + + // NOTE RowMapFactory is faster, but for update it's important to preserve missing v explicit NULL values + // Do we need to support some sort of UNDEFINED and NULL instance of MvFieldWrapper? + RowMapFactory f = null; + if (commandType == CommandType.insert || commandType == CommandType.insertWithKeys || commandType == CommandType.delete) + f = new RowMapFactory<>(); + CaseInsensitiveHashMap referenceCasing = new CaseInsensitiveHashMap<>(); + + for (int idx = 0; idx < rows.length(); ++idx) + { + JSONObject jsonObj; + try + { + jsonObj = rows.getJSONObject(idx); + } + catch (JSONException x) + { + throw new IllegalArgumentException("rows[" + idx + "] is not an object."); + } + if (null != jsonObj) + { + Map rowMap = null == f ? new CaseInsensitiveHashMap<>(new HashMap<>(), referenceCasing) : f.getRowMap(); + // Use shallow copy since jsonObj.toMap() will translate contained JSONObjects into Maps, which we don't want + boolean conflictingCasing = JsonUtil.fillMapShallow(jsonObj, rowMap); + if (conflictingCasing) + { + // Issue 52616 + LOG.error("Row contained conflicting casing for key names in the incoming row: {}", jsonObj); + } + if (allowRowAttachments()) + addRowAttachments(table, rowMap, idx, commandIndex); + + rowsToProcess.add(rowMap); + rowsAffected++; + } + } + + Map extraContext = json.has("extraContext") ? json.getJSONObject("extraContext").toMap() : new CaseInsensitiveHashMap<>(); + + Map configParameters = new HashMap<>(); + + // Check first if the audit behavior has been defined for the table either in code or through XML. + // If not defined there, check for the audit behavior defined in the action form (json). + AuditBehaviorType behaviorType = table.getEffectiveAuditBehavior(json.optString("auditBehavior", null)); + if (behaviorType != null) + { + configParameters.put(DetailedAuditLogDataIterator.AuditConfigs.AuditBehavior, behaviorType); + String auditComment = json.optString("auditUserComment", null); + if (!StringUtils.isEmpty(auditComment)) + configParameters.put(DetailedAuditLogDataIterator.AuditConfigs.AuditUserComment, auditComment); + } + + boolean skipReselectRows = json.optBoolean("skipReselectRows", false); + if (skipReselectRows) + configParameters.put(QueryUpdateService.ConfigParameters.SkipReselectRows, true); + + if (getTargetContainerProp() != null) + { + Container targetContainer = getContainerForCommand(json, PROP_TARGET_CONTAINER_PATH, null); + configParameters.put(QueryUpdateService.ConfigParameters.TargetContainer, targetContainer); + } + + //set up the response, providing the schema name, query name, and operation + //so that the client can sort out which request this response belongs to + //(clients often submit these async) + response.put(PROP_SCHEMA_NAME, schemaName); + response.put(PROP_QUERY_NAME, queryName); + response.put("command", commandType.name()); + response.put("containerPath", container.getPath()); + + //we will transact operations by default, but the user may + //override this by sending a "transacted" property set to false + // 11741: A transaction may already be active if we're trying to + // insert/update/delete from within a transformation/validation script. + boolean transacted = allowTransaction && json.optBoolean("transacted", true); + TransactionAuditProvider.TransactionAuditEvent auditEvent = null; + try (DbScope.Transaction transaction = transacted ? table.getSchema().getScope().ensureTransaction() : NO_OP_TRANSACTION) + { + if (behaviorType != null && behaviorType != AuditBehaviorType.NONE) + { + DbScope.Transaction auditTransaction = !transacted && isNestedTransaction ? table.getSchema().getScope().getCurrentTransaction() : transaction; + if (auditTransaction == null) + auditTransaction = NO_OP_TRANSACTION; + + if (auditTransaction.getAuditEvent() != null) + auditEvent = auditTransaction.getAuditEvent(); + else + { + auditEvent = AbstractQueryUpdateService.createTransactionAuditEvent(container, commandType.getAuditAction()); + AbstractQueryUpdateService.addTransactionAuditEvent(auditTransaction, getUser(), auditEvent); + } + } + + QueryService.get().setEnvironment(QueryService.Environment.CONTAINER, container); + List> responseRows = + commandType.saveRows(qus, rowsToProcess, getUser(), container, configParameters, extraContext); + if (auditEvent != null) + auditEvent.addComment(commandType.getAuditAction(), responseRows.size()); + + if (commandType == CommandType.moveRows) + { + // moveRows returns a single map of updateCounts + response.put("updateCounts", responseRows.get(0)); + } + else if (commandType != CommandType.importRows) + { + response.put("rows", responseRows.stream() + .map(JsonUtil::toMapPreserveNonFinite) + .map(JsonUtil::toJsonPreserveNulls) + .collect(LabKeyCollectors.toJSONArray())); + } + + // if there is any provenance information, save it here + ProvenanceService svc = ProvenanceService.get(); + if (json.has("provenance")) + { + JSONObject provenanceJSON = json.getJSONObject("provenance"); + ProvenanceRecordingParams params = svc.createRecordingParams(getViewContext(), provenanceJSON, ProvenanceService.ADD_RECORDING); + RecordedAction action = svc.createRecordedAction(getViewContext(), params); + if (action != null && params.getRecordingId() != null) + { + // check for any row level provenance information + if (json.has("rows")) + { + Object rowObject = json.get("rows"); + if (rowObject instanceof JSONArray jsonArray) + { + // we need to match any provenance object inputs to the object outputs from the response rows, this typically would + // be the row lsid but it configurable in the provenance recording params + // + List> provenanceMap = svc.createProvenanceMapFromRows(getViewContext(), params, jsonArray, responseRows); + if (!provenanceMap.isEmpty()) + { + action.getProvenanceMap().addAll(provenanceMap); + } + svc.addRecordingStep(getViewContext().getRequest(), params.getRecordingId(), action); + } + else + { + errors.reject(SpringActionController.ERROR_MSG, "Unable to process provenance information, the rows object was not an array"); + } + } + } + } + transaction.commit(); + } + catch (OptimisticConflictException e) + { + //issue 13967: provide better message for OptimisticConflictException + errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); + } + catch (QueryUpdateServiceException | ConversionException | DuplicateKeyException | DataIntegrityViolationException e) + { + //Issue 14294: improve handling of ConversionException (and DuplicateKeyException (Issue 28037), and DataIntegrity (uniqueness) (Issue 22779) + errors.reject(SpringActionController.ERROR_MSG, e.getMessage() == null ? e.toString() : e.getMessage()); + } + catch (BatchValidationException e) + { + if (isSuccessOnValidationError()) + { + response.put("errors", createResponseWriter().toJSON(e)); + } + else + { + ExceptionUtil.decorateException(e, ExceptionUtil.ExceptionInfo.SkipMothershipLogging, "true", true); + throw e; + } + } + if (auditEvent != null) + { + response.put("transactionAuditId", auditEvent.getRowId()); + response.put("reselectRowCount", auditEvent.hasMultiActions()); + } + + response.put("rowsAffected", rowsAffected); + + return response; + } + + protected boolean allowRowAttachments() + { + return false; + } + + private void addRowAttachments(TableInfo tableInfo, Map rowMap, int rowIndex, @Nullable Integer commandIndex) + { + if (getFileMap() != null) + { + for (Map.Entry fileEntry : getFileMap().entrySet()) + { + // Allow for the fileMap key to include the row index, and optionally command index, for defining + // which row to attach this file to + String fullKey = fileEntry.getKey(); + String fieldKey = fullKey; + // Issue 52827: Cannot attach a file if the field name contains :: + // use lastIndexOf instead of split to get the proper parts + int lastDelimIndex = fullKey.lastIndexOf(ROW_ATTACHMENT_INDEX_DELIM); + if (lastDelimIndex > -1) + { + String fieldKeyExcludeIndex = fullKey.substring(0, lastDelimIndex); + String fieldRowIndex = fullKey.substring(lastDelimIndex + ROW_ATTACHMENT_INDEX_DELIM.length()); + if (!fieldRowIndex.equals(rowIndex+"")) continue; + + if (commandIndex == null) + { + // Single command, so we're parsing file names in the format of: FileField::0 + fieldKey = fieldKeyExcludeIndex; + } + else + { + // Multi-command, so we're parsing file names in the format of: FileField::0::1 + int subDelimIndex = fieldKeyExcludeIndex.lastIndexOf(ROW_ATTACHMENT_INDEX_DELIM); + if (subDelimIndex > -1) + { + fieldKey = fieldKeyExcludeIndex.substring(0, subDelimIndex); + String fieldCommandIndex = fieldKeyExcludeIndex.substring(subDelimIndex + ROW_ATTACHMENT_INDEX_DELIM.length()); + if (!fieldCommandIndex.equals(commandIndex+"")) + continue; + } + else + continue; + } + } + + SpringAttachmentFile file = new SpringAttachmentFile(fileEntry.getValue()); + rowMap.put(fieldKey, file.isEmpty() ? null : file); + } + } + + for (ColumnInfo col : tableInfo.getColumns()) + DataIteratorUtil.MatchType.multiPartFormData.updateRowMap(col, rowMap); + } + + protected boolean isSuccessOnValidationError() + { + return getRequestedApiVersion() >= 13.2; + } + + @NotNull + protected TableInfo getTableInfo(Container container, User user, String schemaName, String queryName) + { + if (null == schemaName || null == queryName) + throw new IllegalArgumentException("You must supply a schemaName and queryName!"); + + UserSchema schema = QueryService.get().getUserSchema(user, container, schemaName); + if (null == schema) + throw new IllegalArgumentException("The schema '" + schemaName + "' does not exist."); + + TableInfo table = schema.getTableForInsert(queryName); + if (table == null) + throw new IllegalArgumentException("The query '" + queryName + "' in the schema '" + schemaName + "' does not exist."); + return table; + } + } + + // Issue: 20522 - require read access to the action but executeJson will check for update privileges from the table + // + @RequiresPermission(ReadPermission.class) //will check below + @ApiVersion(8.3) + public static class UpdateRowsAction extends BaseSaveRowsAction + { + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + JSONObject response = executeJson(getJsonObject(), CommandType.update, true, errors); + if (response == null || errors.hasErrors()) + return null; + return new ApiSimpleResponse(response); + } + + @Override + protected boolean allowRowAttachments() + { + return true; + } + } + + @RequiresAnyOf({ReadPermission.class, InsertPermission.class}) //will check below + @ApiVersion(8.3) + public static class InsertRowsAction extends BaseSaveRowsAction + { + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + JSONObject response = executeJson(getJsonObject(), CommandType.insert, true, errors); + if (response == null || errors.hasErrors()) + return null; + + return new ApiSimpleResponse(response); + } + + @Override + protected boolean allowRowAttachments() + { + return true; + } + } + + @RequiresPermission(ReadPermission.class) //will check below + @ApiVersion(8.3) + public static class ImportRowsAction extends BaseSaveRowsAction + { + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + JSONObject response = executeJson(getJsonObject(), CommandType.importRows, true, errors); + if (response == null || errors.hasErrors()) + return null; + return new ApiSimpleResponse(response); + } + } + + @ActionNames("deleteRows, delRows") + @RequiresPermission(ReadPermission.class) //will check below + @ApiVersion(8.3) + public static class DeleteRowsAction extends BaseSaveRowsAction + { + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + JSONObject response = executeJson(getJsonObject(), CommandType.delete, true, errors); + if (response == null || errors.hasErrors()) + return null; + return new ApiSimpleResponse(response); + } + } + + @RequiresPermission(ReadPermission.class) //will check below + public static class MoveRowsAction extends BaseSaveRowsAction + { + private Container _targetContainer; + + @Override + public void validateForm(MoveRowsForm form, Errors errors) + { + super.validateForm(form, errors); + + JSONObject json = getJsonObject(); + if (json == null) + { + errors.reject(ERROR_GENERIC, "Empty request"); + } + else + { + // Since we are moving between containers, we know we have product folders enabled + if (getContainer().getProject().getAuditCommentsRequired() && StringUtils.isBlank(json.optString("auditUserComment"))) + errors.reject(ERROR_GENERIC, "A reason for the move of data is required."); + else + { + String queryName = json.optString(PROP_QUERY_NAME, null); + _targetContainer = ContainerManager.getMoveTargetContainer(queryName, getContainer(), getUser(), getTargetContainerProp(), errors); + } + } + } + + @Override + public ApiResponse execute(MoveRowsForm form, BindException errors) throws Exception + { + // if JSON does not have rows array, see if they were provided via selectionKey + if (!getJsonObject().has(PROP_ROWS)) + setRowsFromSelectionKey(form); + + JSONObject response = executeJson(getJsonObject(), CommandType.moveRows, true, errors); + if (response == null || errors.hasErrors()) + return null; + + updateSelections(form); + + response.put("success", true); + response.put("containerPath", _targetContainer.getPath()); + return new ApiSimpleResponse(response); + } + + private void updateSelections(MoveRowsForm form) + { + String selectionKey = form.getDataRegionSelectionKey(); + if (selectionKey != null) + { + Set rowIds = form.getIds(getViewContext(), false) + .stream().map(Object::toString).collect(Collectors.toSet()); + DataRegionSelection.setSelected(getViewContext(), selectionKey, rowIds, false); + + // if moving entities from a type, the selections from other selectionKeys in that container will + // possibly be holding onto invalid keys after the move, so clear them based on the containerPath and selectionKey suffix + String[] keyParts = selectionKey.split("|"); + if (keyParts.length > 1) + DataRegionSelection.clearRelatedByContainerPath(getViewContext(), keyParts[keyParts.length - 1]); + } + } + + private void setRowsFromSelectionKey(MoveRowsForm form) + { + Set rowIds = form.getIds(getViewContext(), false); // handle clear of selectionKey after move complete + + // convert rowIds to a JSONArray of JSONObjects with a single property "RowId" + JSONArray rows = new JSONArray(); + for (Long rowId : rowIds) + { + JSONObject row = new JSONObject(); + row.put("RowId", rowId); + rows.put(row); + } + getJsonObject().put(PROP_ROWS, rows); + } + } + + public static class MoveRowsForm extends ApiSaveRowsForm + { + private String _dataRegionSelectionKey; + private boolean _useSnapshotSelection; + + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + public void setDataRegionSelectionKey(String dataRegionSelectionKey) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + } + + public boolean isUseSnapshotSelection() + { + return _useSnapshotSelection; + } + + public void setUseSnapshotSelection(boolean useSnapshotSelection) + { + _useSnapshotSelection = useSnapshotSelection; + } + + @Override + public void bindJson(JSONObject json) + { + super.bindJson(json); + _dataRegionSelectionKey = json.optString("dataRegionSelectionKey", null); + _useSnapshotSelection = json.optBoolean("useSnapshotSelection", false); + } + + public Set getIds(ViewContext context, boolean clear) + { + if (_useSnapshotSelection) + return new HashSet<>(DataRegionSelection.getSnapshotSelectedIntegers(context, getDataRegionSelectionKey())); + else + return DataRegionSelection.getSelectedIntegers(context, getDataRegionSelectionKey(), clear); + } + } + + @RequiresNoPermission //will check below + public static class SaveRowsAction extends BaseSaveRowsAction + { + public static final String PROP_VALUES = "values"; + public static final String PROP_OLD_KEYS = "oldKeys"; + + @Override + protected boolean isFailure(BindException errors) + { + return !isSuccessOnValidationError() && super.isFailure(errors); + } + + @Override + protected boolean allowRowAttachments() + { + return true; + } + + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + // Issue 21850: Verify that the user has at least some sort of basic access to the container. We'll check for more + // specific permissions later once we've figured out exactly what they're trying to do. This helps us + // give a better HTTP response code when they're trying to access a resource that's not available to guests + if (!getContainer().hasPermission(getUser(), ReadPermission.class) && + !getContainer().hasPermission(getUser(), DeletePermission.class) && + !getContainer().hasPermission(getUser(), InsertPermission.class) && + !getContainer().hasPermission(getUser(), UpdatePermission.class)) + { + throw new UnauthorizedException(); + } + + JSONObject json = getJsonObject(); + if (json == null) + throw new IllegalArgumentException("Empty request"); + + JSONArray commands = json.optJSONArray("commands"); + if (commands == null || commands.isEmpty()) + { + throw new NotFoundException("Empty request"); + } + + boolean validateOnly = json.optBoolean("validateOnly", false); + // If we are going to validate and not commit, we need to be sure we're transacted as well. Otherwise, + // respect the client's request. + boolean transacted = validateOnly || json.optBoolean("transacted", true); + + // Keep track of whether we end up committing or not + boolean committed = false; + + DbScope scope = null; + if (transacted) + { + for (int i = 0; i < commands.length(); i++) + { + JSONObject commandJSON = commands.getJSONObject(i); + String schemaName = commandJSON.getString(PROP_SCHEMA_NAME); + String queryName = commandJSON.getString(PROP_QUERY_NAME); + Container container = getContainerForCommand(commandJSON); + TableInfo tableInfo = getTableInfo(container, getUser(), schemaName, queryName); + if (scope == null) + { + scope = tableInfo.getSchema().getScope(); + } + else if (scope != tableInfo.getSchema().getScope()) + { + throw new IllegalArgumentException("All queries must be from the same source database"); + } + } + assert scope != null; + } + + JSONArray resultArray = new JSONArray(); + JSONObject extraContext = json.optJSONObject("extraContext"); + + int startingErrorIndex = 0; + int errorCount = 0; + // 11741: A transaction may already be active if we're trying to + // insert/update/delete from within a transformation/validation script. + + try (DbScope.Transaction transaction = transacted ? scope.ensureTransaction() : NO_OP_TRANSACTION) + { + for (int i = 0; i < commands.length(); i++) + { + JSONObject commandObject = commands.getJSONObject(i); + String commandName = commandObject.getString(PROP_COMMAND); + if (commandName == null) + { + throw new ApiUsageException(PROP_COMMAND + " is required but was missing"); + } + CommandType command = CommandType.valueOf(commandName); + + // Copy the top-level 'extraContext' and merge in the command-level extraContext. + Map commandExtraContext = new HashMap<>(); + if (extraContext != null) + commandExtraContext.putAll(extraContext.toMap()); + if (commandObject.has("extraContext")) + { + commandExtraContext.putAll(commandObject.getJSONObject("extraContext").toMap()); + } + commandObject.put("extraContext", commandExtraContext); + + JSONObject commandResponse = executeJson(commandObject, command, !transacted, errors, transacted, i); + // Bail out immediately if we're going to return a failure-type response message + if (commandResponse == null || (errors.hasErrors() && !isSuccessOnValidationError())) + return null; + + //this would be populated in executeJson when a BatchValidationException is thrown + if (commandResponse.has("errors")) + { + errorCount += commandResponse.getJSONObject("errors").getInt("errorCount"); + } + + // If we encountered errors with this particular command and the client requested that don't treat + // the whole request as a failure (non-200 HTTP status code), stash the errors for this particular + // command in its response section. + // NOTE: executeJson should handle and serialize BatchValidationException + // these errors upstream + if (errors.getErrorCount() > startingErrorIndex && isSuccessOnValidationError()) + { + commandResponse.put("errors", ApiResponseWriter.convertToJSON(errors, startingErrorIndex).getValue()); + startingErrorIndex = errors.getErrorCount(); + } + + resultArray.put(commandResponse); + } + + // Don't commit if we had errors or if the client requested that we only validate (and not commit) + if (!errors.hasErrors() && !validateOnly && errorCount == 0) + { + transaction.commit(); + committed = true; + } + } + + errorCount += errors.getErrorCount(); + JSONObject result = new JSONObject(); + result.put("result", resultArray); + result.put("committed", committed); + result.put("errorCount", errorCount); + + return new ApiSimpleResponse(result); + } + } + + @RequiresPermission(ReadPermission.class) + public static class ApiTestAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/query/view/apitest.jsp"); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("API Test"); + } + } + + + @RequiresPermission(AdminPermission.class) + public static class AdminAction extends SimpleViewAction + { + @SuppressWarnings("UnusedDeclaration") + public AdminAction() + { + } + + public AdminAction(ViewContext ctx) + { + setViewContext(ctx); + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + setHelpTopic("externalSchemas"); + return new JspView<>("/org/labkey/query/view/admin.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Schema Administration", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ResetRemoteConnectionsForm + { + private boolean _reset; + + public boolean isReset() + { + return _reset; + } + + public void setReset(boolean reset) + { + _reset = reset; + } + } + + + @RequiresPermission(AdminPermission.class) + public static class ManageRemoteConnectionsAction extends FormViewAction + { + @Override + public void validateCommand(ResetRemoteConnectionsForm target, Errors errors) {} + + @Override + public boolean handlePost(ResetRemoteConnectionsForm form, BindException errors) + { + if (form.isReset()) + { + PropertyManager.getEncryptedStore().deletePropertySet(getContainer(), RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY); + } + return true; + } + + @Override + public URLHelper getSuccessURL(ResetRemoteConnectionsForm queryForm) + { + return new ActionURL(ManageRemoteConnectionsAction.class, getContainer()); + } + + @Override + public ModelAndView getView(ResetRemoteConnectionsForm queryForm, boolean reshow, BindException errors) + { + Map connectionMap; + try + { + // if the encrypted property store is configured but no values have yet been set, and empty map is returned + connectionMap = PropertyManager.getEncryptedStore().getProperties(getContainer(), RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY); + } + catch (Exception e) + { + connectionMap = null; // render the failure page + } + setHelpTopic("remoteConnection"); + return new JspView<>("/org/labkey/query/view/manageRemoteConnections.jsp", connectionMap, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Manage Remote Connections", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + private abstract static class BaseInsertExternalSchemaAction, T extends AbstractExternalSchemaDef> extends FormViewAction + { + protected BaseInsertExternalSchemaAction(Class commandClass) + { + super(commandClass); + } + + @Override + public void validateCommand(F form, Errors errors) + { + form.validate(errors); + } + + @Override + public boolean handlePost(F form, BindException errors) throws Exception + { + try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) + { + form.doInsert(); + auditSchemaAdminActivity(form.getBean(), "created", getContainer(), getUser()); + QueryManager.get().updateExternalSchemas(getContainer()); + + t.commit(); + } + catch (RuntimeSQLException e) + { + if (e.isConstraintException()) + { + errors.reject(ERROR_MSG, "A schema by that name is already defined in this folder"); + return false; + } + + throw e; + } + + return true; + } + + @Override + public ActionURL getSuccessURL(F form) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new AdminAction(getViewContext()).addNavTrail(root); + root.addChild("Define Schema", new ActionURL(getClass(), getContainer())); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class InsertLinkedSchemaAction extends BaseInsertExternalSchemaAction + { + public InsertLinkedSchemaAction() + { + super(LinkedSchemaForm.class); + } + + @Override + public ModelAndView getView(LinkedSchemaForm form, boolean reshow, BindException errors) + { + setHelpTopic("filterSchema"); + return new JspView<>("/org/labkey/query/view/linkedSchema.jsp", new LinkedSchemaBean(getContainer(), form.getBean(), true), errors); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class InsertExternalSchemaAction extends BaseInsertExternalSchemaAction + { + public InsertExternalSchemaAction() + { + super(ExternalSchemaForm.class); + } + + @Override + public ModelAndView getView(ExternalSchemaForm form, boolean reshow, BindException errors) + { + setHelpTopic("externalSchemas"); + return new JspView<>("/org/labkey/query/view/externalSchema.jsp", new ExternalSchemaBean(getContainer(), form.getBean(), true), errors); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class DeleteSchemaAction extends ConfirmAction + { + @Override + public String getConfirmText() + { + return "Delete"; + } + + @Override + public ModelAndView getConfirmView(SchemaForm form, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Delete Schema"); + + AbstractExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), AbstractExternalSchemaDef.class); + if (def == null) + throw new NotFoundException(); + + String schemaName = isBlank(def.getUserSchemaName()) ? "this schema" : "the schema '" + def.getUserSchemaName() + "'"; + return new HtmlView(HtmlString.of("Are you sure you want to delete " + schemaName + "? The tables and queries defined in this schema will no longer be accessible.")); + } + + @Override + public boolean handlePost(SchemaForm form, BindException errors) + { + AbstractExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), AbstractExternalSchemaDef.class); + if (def == null) + throw new NotFoundException(); + + try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) + { + auditSchemaAdminActivity(def, "deleted", getContainer(), getUser()); + QueryManager.get().delete(def); + t.commit(); + } + return true; + } + + @Override + public void validateCommand(SchemaForm form, Errors errors) + { + } + + @Override + @NotNull + public ActionURL getSuccessURL(SchemaForm form) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); + } + } + + private static void auditSchemaAdminActivity(AbstractExternalSchemaDef def, String action, Container container, User user) + { + String comment = StringUtils.capitalize(def.getSchemaType().toString()) + " schema '" + def.getUserSchemaName() + "' " + action; + AuditTypeEvent event = new AuditTypeEvent(ContainerAuditProvider.CONTAINER_AUDIT_EVENT, container, comment); + AuditLogService.get().addEvent(user, event); + } + + + private abstract static class BaseEditSchemaAction, T extends AbstractExternalSchemaDef> extends FormViewAction + { + protected BaseEditSchemaAction(Class commandClass) + { + super(commandClass); + } + + @Override + public void validateCommand(F form, Errors errors) + { + form.validate(errors); + } + + @Nullable + protected abstract T getCurrent(int externalSchemaId); + + @NotNull + protected T getDef(F form, boolean reshow) + { + T def; + Container defContainer; + + if (reshow) + { + def = form.getBean(); + T current = getCurrent(def.getExternalSchemaId()); + if (current == null) + throw new NotFoundException(); + + defContainer = current.lookupContainer(); + } + else + { + form.refreshFromDb(); + if (!form.isDataLoaded()) + throw new NotFoundException(); + + def = form.getBean(); + if (def == null) + throw new NotFoundException(); + + defContainer = def.lookupContainer(); + } + + if (!getContainer().equals(defContainer)) + throw new UnauthorizedException(); + + return def; + } + + @Override + public boolean handlePost(F form, BindException errors) throws Exception + { + T def = form.getBean(); + T fromDb = getCurrent(def.getExternalSchemaId()); + + // Unauthorized if def in the database reports a different container + if (!getContainer().equals(fromDb.lookupContainer())) + throw new UnauthorizedException(); + + try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) + { + form.doUpdate(); + auditSchemaAdminActivity(def, "updated", getContainer(), getUser()); + QueryManager.get().updateExternalSchemas(getContainer()); + t.commit(); + } + catch (RuntimeSQLException e) + { + if (e.isConstraintException()) + { + errors.reject(ERROR_MSG, "A schema by that name is already defined in this folder"); + return false; + } + + throw e; + } + return true; + } + + @Override + public ActionURL getSuccessURL(F externalSchemaForm) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new AdminAction(getViewContext()).addNavTrail(root); + root.addChild("Edit Schema", new ActionURL(getClass(), getContainer())); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class EditLinkedSchemaAction extends BaseEditSchemaAction + { + public EditLinkedSchemaAction() + { + super(LinkedSchemaForm.class); + } + + @Nullable + @Override + protected LinkedSchemaDef getCurrent(int externalId) + { + return QueryManager.get().getLinkedSchemaDef(getContainer(), externalId); + } + + @Override + public ModelAndView getView(LinkedSchemaForm form, boolean reshow, BindException errors) + { + LinkedSchemaDef def = getDef(form, reshow); + + setHelpTopic("filterSchema"); + return new JspView<>("/org/labkey/query/view/linkedSchema.jsp", new LinkedSchemaBean(getContainer(), def, false), errors); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class EditExternalSchemaAction extends BaseEditSchemaAction + { + public EditExternalSchemaAction() + { + super(ExternalSchemaForm.class); + } + + @Nullable + @Override + protected ExternalSchemaDef getCurrent(int externalId) + { + return QueryManager.get().getExternalSchemaDef(getContainer(), externalId); + } + + @Override + public ModelAndView getView(ExternalSchemaForm form, boolean reshow, BindException errors) + { + ExternalSchemaDef def = getDef(form, reshow); + + setHelpTopic("externalSchemas"); + return new JspView<>("/org/labkey/query/view/externalSchema.jsp", new ExternalSchemaBean(getContainer(), def, false), errors); + } + } + + + public static class DataSourceInfo + { + public final String sourceName; + public final String displayName; + public final boolean editable; + + public DataSourceInfo(DbScope scope) + { + this(scope.getDataSourceName(), scope.getDisplayName(), scope.getSqlDialect().isEditable()); + } + + public DataSourceInfo(Container c) + { + this(c.getId(), c.getName(), false); + } + + public DataSourceInfo(String sourceName, String displayName, boolean editable) + { + this.sourceName = sourceName; + this.displayName = displayName; + this.editable = editable; + } + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DataSourceInfo that = (DataSourceInfo) o; + return sourceName != null ? sourceName.equals(that.sourceName) : that.sourceName == null; + } + + @Override + public int hashCode() + { + return sourceName != null ? sourceName.hashCode() : 0; + } + } + + public static abstract class BaseExternalSchemaBean + { + protected final Container _c; + protected final T _def; + protected final boolean _insert; + protected final Map _help = new HashMap<>(); + + public BaseExternalSchemaBean(Container c, T def, boolean insert) + { + _c = c; + _def = def; + _insert = insert; + + TableInfo ti = QueryManager.get().getTableInfoExternalSchema(); + + ti.getColumns() + .stream() + .filter(ci -> null != ci.getDescription()) + .forEach(ci -> _help.put(ci.getName(), ci.getDescription())); + } + + public abstract DataSourceInfo getInitialSource(); + + public T getSchemaDef() + { + return _def; + } + + public boolean isInsert() + { + return _insert; + } + + public ActionURL getReturnURL() + { + return new ActionURL(AdminAction.class, _c); + } + + public ActionURL getDeleteURL() + { + return new QueryUrlsImpl().urlDeleteSchema(_c, _def); + } + + public String getHelpHTML(String fieldName) + { + return _help.get(fieldName); + } + } + + public static class LinkedSchemaBean extends BaseExternalSchemaBean + { + public LinkedSchemaBean(Container c, LinkedSchemaDef def, boolean insert) + { + super(c, def, insert); + } + + @Override + public DataSourceInfo getInitialSource() + { + Container sourceContainer = getInitialContainer(); + return new DataSourceInfo(sourceContainer); + } + + private @NotNull Container getInitialContainer() + { + LinkedSchemaDef def = getSchemaDef(); + Container sourceContainer = def.lookupSourceContainer(); + if (sourceContainer == null) + sourceContainer = def.lookupContainer(); + if (sourceContainer == null) + sourceContainer = _c; + return sourceContainer; + } + } + + public static class ExternalSchemaBean extends BaseExternalSchemaBean + { + protected final Map> _sourcesAndSchemas = new LinkedHashMap<>(); + protected final Map> _sourcesAndSchemasIncludingSystem = new LinkedHashMap<>(); + + public ExternalSchemaBean(Container c, ExternalSchemaDef def, boolean insert) + { + super(c, def, insert); + initSources(); + } + + public Collection getSources() + { + return _sourcesAndSchemas.keySet(); + } + + public Collection getSchemaNames(DataSourceInfo source, boolean includeSystem) + { + if (includeSystem) + return _sourcesAndSchemasIncludingSystem.get(source); + else + return _sourcesAndSchemas.get(source); + } + + @Override + public DataSourceInfo getInitialSource() + { + ExternalSchemaDef def = getSchemaDef(); + DbScope scope = def.lookupDbScope(); + if (scope == null) + scope = DbScope.getLabKeyScope(); + return new DataSourceInfo(scope); + } + + protected void initSources() + { + ModuleLoader moduleLoader = ModuleLoader.getInstance(); + + for (DbScope scope : DbScope.getDbScopes()) + { + SqlDialect dialect = scope.getSqlDialect(); + + Collection schemaNames = new LinkedList<>(); + Collection schemaNamesIncludingSystem = new LinkedList<>(); + + for (String schemaName : scope.getSchemaNames()) + { + schemaNamesIncludingSystem.add(schemaName); + + if (dialect.isSystemSchema(schemaName)) + continue; + + if (null != moduleLoader.getModule(scope, schemaName)) + continue; + + schemaNames.add(schemaName); + } + + DataSourceInfo source = new DataSourceInfo(scope); + _sourcesAndSchemas.put(source, schemaNames); + _sourcesAndSchemasIncludingSystem.put(source, schemaNamesIncludingSystem); + } + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class GetTablesForm + { + private String _dataSource; + private String _schemaName; + private boolean _sorted; + + public String getDataSource() + { + return _dataSource; + } + + public void setDataSource(String dataSource) + { + _dataSource = dataSource; + } + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public boolean isSorted() + { + return _sorted; + } + + public void setSorted(boolean sorted) + { + _sorted = sorted; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class GetTablesAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(GetTablesForm form, BindException errors) + { + List> rows = new LinkedList<>(); + List tableNames = new ArrayList<>(); + + if (null != form.getSchemaName()) + { + DbScope scope = DbScope.getDbScope(form.getDataSource()); + if (null != scope) + { + DbSchema schema = scope.getSchema(form.getSchemaName(), DbSchemaType.Bare); + tableNames.addAll(schema.getTableNames()); + } + else + { + Container c = ContainerManager.getForId(form.getDataSource()); + if (null != c) + { + UserSchema schema = QueryService.get().getUserSchema(getUser(), c, form.getSchemaName()); + if (null != schema) + { + if (form.isSorted()) + for (TableInfo table : schema.getSortedTables()) + tableNames.add(table.getName()); + else + tableNames.addAll(schema.getTableAndQueryNames(true)); + } + } + } + } + + Collections.sort(tableNames); + + for (String tableName : tableNames) + { + Map row = new LinkedHashMap<>(); + row.put("table", tableName); + rows.add(row); + } + + Map properties = new HashMap<>(); + properties.put("rows", rows); + + return new ApiSimpleResponse(properties); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class SchemaTemplateForm + { + private String _name; + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + } + + + @RequiresPermission(AdminOperationsPermission.class) + public static class SchemaTemplateAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(SchemaTemplateForm form, BindException errors) + { + String name = form.getName(); + if (name == null) + throw new IllegalArgumentException("name required"); + + Container c = getContainer(); + TemplateSchemaType template = QueryServiceImpl.get().getSchemaTemplate(c, name); + if (template == null) + throw new NotFoundException("template not found"); + + JSONObject templateJson = QueryServiceImpl.get().schemaTemplateJson(name, template); + + return new ApiSimpleResponse("template", templateJson); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class SchemaTemplatesAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(Object form, BindException errors) + { + Container c = getContainer(); + QueryServiceImpl svc = QueryServiceImpl.get(); + Map templates = svc.getSchemaTemplates(c); + + JSONArray ret = new JSONArray(); + for (String key : templates.keySet()) + { + TemplateSchemaType template = templates.get(key); + JSONObject templateJson = svc.schemaTemplateJson(key, template); + ret.put(templateJson); + } + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("templates", ret); + resp.put("success", true); + return resp; + } + } + + @RequiresPermission(AdminPermission.class) + public static class ReloadExternalSchemaAction extends FormHandlerAction + { + private String _userSchemaName; + + @Override + public void validateCommand(SchemaForm form, Errors errors) + { + } + + @Override + public boolean handlePost(SchemaForm form, BindException errors) + { + ExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), ExternalSchemaDef.class); + if (def == null) + throw new NotFoundException(); + + QueryManager.get().reloadExternalSchema(def); + _userSchemaName = def.getUserSchemaName(); + + return true; + } + + @Override + public ActionURL getSuccessURL(SchemaForm form) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "Schema " + _userSchemaName + " was reloaded successfully."); + } + } + + + @RequiresPermission(AdminPermission.class) + public static class ReloadAllUserSchemas extends FormHandlerAction + { + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + QueryManager.get().reloadAllExternalSchemas(getContainer()); + return true; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "All schemas in this folder were reloaded successfully."); + } + } + + @RequiresPermission(AdminPermission.class) + public static class ReloadFailedConnectionsAction extends FormHandlerAction + { + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + DbScope.clearFailedDbScopes(); + return true; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "Reconnection was attempted on all data sources that failed previous connection attempts."); + } + } + + @RequiresPermission(ReadPermission.class) + public static class TableInfoAction extends SimpleViewAction + { + @Override + public ModelAndView getView(TableInfoForm form, BindException errors) throws Exception + { + TablesDocument ret = TablesDocument.Factory.newInstance(); + TablesType tables = ret.addNewTables(); + + FieldKey[] fields = form.getFieldKeys(); + if (fields.length != 0) + { + TableInfo tinfo = QueryView.create(form, errors).getTable(); + Map columnMap = CustomViewImpl.getColumnInfos(tinfo, Arrays.asList(fields)); + TableXML.initTable(tables.addNewTable(), tinfo, null, columnMap.values()); + } + + for (FieldKey tableKey : form.getTableKeys()) + { + TableInfo tableInfo = form.getTableInfo(tableKey); + TableType xbTable = tables.addNewTable(); + TableXML.initTable(xbTable, tableInfo, tableKey); + } + getViewContext().getResponse().setContentType("text/xml"); + getViewContext().getResponse().getWriter().write(ret.toString()); + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + // Issue 18870: Guest user can't revert unsaved custom view changes + // Permission will be checked inline (guests are allowed to delete their session custom views) + @RequiresNoPermission + @Action(ActionType.Configure.class) + public static class DeleteViewAction extends MutatingApiAction + { + @Override + public ApiResponse execute(DeleteViewForm form, BindException errors) + { + CustomView view = form.getCustomView(); + if (view == null) + { + throw new NotFoundException(); + } + + if (getUser().isGuest()) + { + // Guests can only delete session custom views. + if (!view.isSession()) + throw new UnauthorizedException(); + } + else + { + // Logged in users must have read permission + if (!getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException(); + } + + if (view.isShared()) + { + if (!getContainer().hasPermission(getUser(), EditSharedViewPermission.class)) + throw new UnauthorizedException(); + } + + view.delete(getUser(), getViewContext().getRequest()); + + // Delete the first shadowed custom view, if available. + if (form.isComplete()) + { + form.reset(); + CustomView shadowed = form.getCustomView(); + if (shadowed != null && shadowed.isEditable() && !(shadowed instanceof ModuleCustomView)) + { + if (!shadowed.isShared() || getContainer().hasPermission(getUser(), EditSharedViewPermission.class)) + shadowed.delete(getUser(), getViewContext().getRequest()); + } + } + + // Try to get a custom view of the same name as the view we just deleted. + // The deleted view may have been a session view or a personal view masking shared view with the same name. + form.reset(); + view = form.getCustomView(); + String nextViewName = null; + if (view != null) + nextViewName = view.getName(); + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("viewName", nextViewName); + return response; + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class SaveSessionViewForm extends QueryForm + { + private String newName; + private boolean inherit; + private boolean shared; + private boolean hidden; + private boolean replace; + private String containerPath; + + public String getNewName() + { + return newName; + } + + public void setNewName(String newName) + { + this.newName = newName; + } + + public boolean isInherit() + { + return inherit; + } + + public void setInherit(boolean inherit) + { + this.inherit = inherit; + } + + public boolean isShared() + { + return shared; + } + + public void setShared(boolean shared) + { + this.shared = shared; + } + + public String getContainerPath() + { + return containerPath; + } + + public void setContainerPath(String containerPath) + { + this.containerPath = containerPath; + } + + public boolean isHidden() + { + return hidden; + } + + public void setHidden(boolean hidden) + { + this.hidden = hidden; + } + + public boolean isReplace() + { + return replace; + } + + public void setReplace(boolean replace) + { + this.replace = replace; + } + } + + // Moves a session view into the database. + @RequiresPermission(ReadPermission.class) + public static class SaveSessionViewAction extends MutatingApiAction + { + @Override + public ApiResponse execute(SaveSessionViewForm form, BindException errors) + { + CustomView view = form.getCustomView(); + if (view == null) + { + throw new NotFoundException(); + } + if (!view.isSession()) + throw new IllegalArgumentException("This action only supports saving session views."); + + //if (!getContainer().getId().equals(view.getContainer().getId())) + // throw new IllegalArgumentException("View may only be saved from container it was created in."); + + assert !view.canInherit() && !view.isShared() && view.isEditable(): "Session view should never be inheritable or shared and always be editable"; + + // Users may save views to a location other than the current container + String containerPath = form.getContainerPath(); + Container container; + if (form.isInherit() && containerPath != null) + { + // Only respect this request if it's a view that is inheritable in subfolders + container = ContainerManager.getForPath(containerPath); + } + else + { + // Otherwise, save it in the current container + container = getContainer(); + } + + if (container == null) + throw new NotFoundException("No such container: " + containerPath); + + if (form.isShared() || form.isInherit()) + { + if (!container.hasPermission(getUser(), EditSharedViewPermission.class)) + throw new UnauthorizedException(); + } + + DbScope scope = QueryManager.get().getDbSchema().getScope(); + try (DbScope.Transaction tx = scope.ensureTransaction()) + { + // Delete the session view. The view will be restored if an exception is thrown. + view.delete(getUser(), getViewContext().getRequest()); + + // Get any previously existing non-session view. + // The session custom view and the view-to-be-saved may have different names. + // If they do have different names, we may need to delete an existing session view with that name. + // UNDONE: If the view has a different name, we will clobber it without asking. + CustomView existingView = form.getQueryDef().getCustomView(getUser(), null, form.getNewName()); + if (existingView != null && existingView.isSession()) + { + // Delete any session view we are overwriting. + existingView.delete(getUser(), getViewContext().getRequest()); + existingView = form.getQueryDef().getCustomView(getUser(), null, form.getNewName()); + } + + // save a new private view if shared is false but existing view is shared + if (existingView != null && !form.isShared() && existingView.getOwner() == null) + { + existingView = null; + } + + if (existingView != null && !form.isReplace() && !StringUtils.isEmpty(form.getNewName())) + throw new IllegalArgumentException("A saved view by the name \"" + form.getNewName() + "\" already exists. "); + + if (existingView == null || (existingView instanceof ModuleCustomView && existingView.isEditable())) + { + User owner = form.isShared() ? null : getUser(); + + CustomViewImpl viewCopy = new CustomViewImpl(form.getQueryDef(), owner, form.getNewName()); + viewCopy.setColumns(view.getColumns()); + viewCopy.setCanInherit(form.isInherit()); + viewCopy.setFilterAndSort(view.getFilterAndSort()); + viewCopy.setColumnProperties(view.getColumnProperties()); + viewCopy.setIsHidden(form.isHidden()); + if (form.isInherit()) + viewCopy.setContainer(container); + + viewCopy.save(getUser(), getViewContext().getRequest()); + } + else if (!existingView.isEditable()) + { + throw new IllegalArgumentException("Existing view '" + form.getNewName() + "' is not editable. You may save this view with a different name."); + } + else + { + // UNDONE: changing shared property of an existing view is unimplemented. Not sure if it makes sense from a usability point of view. + existingView.setColumns(view.getColumns()); + existingView.setFilterAndSort(view.getFilterAndSort()); + existingView.setColumnProperties(view.getColumnProperties()); + existingView.setCanInherit(form.isInherit()); + if (form.isInherit()) + ((CustomViewImpl)existingView).setContainer(container); + existingView.setIsHidden(form.isHidden()); + + existingView.save(getUser(), getViewContext().getRequest()); + } + + tx.commit(); + return new ApiSimpleResponse("success", true); + } + catch (Exception e) + { + // dirty the view then save the deleted session view back in session state + view.setName(view.getName()); + view.save(getUser(), getViewContext().getRequest()); + + throw e; + } + } + } + + /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ + @RequiresPermission(AdminPermission.class) + public class ManageViewsAction extends SimpleViewAction + { + @SuppressWarnings("UnusedDeclaration") + public ManageViewsAction() + { + } + + public ManageViewsAction(ViewContext ctx) + { + setViewContext(ctx); + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + return new JspView<>("/org/labkey/query/view/manageViews.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Manage Views", QueryController.this.getViewContext().getActionURL()); + } + } + + + /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ + @RequiresPermission(AdminPermission.class) + public class InternalDeleteView extends ConfirmAction + { + @Override + public ModelAndView getConfirmView(InternalViewForm form, BindException errors) + { + return new JspView<>("/org/labkey/query/view/internalDeleteView.jsp", form, errors); + } + + @Override + public boolean handlePost(InternalViewForm form, BindException errors) + { + CstmView view = form.getViewAndCheckPermission(); + QueryManager.get().delete(view); + return true; + } + + @Override + public void validateCommand(InternalViewForm internalViewForm, Errors errors) + { + } + + @Override + @NotNull + public ActionURL getSuccessURL(InternalViewForm internalViewForm) + { + return new ActionURL(ManageViewsAction.class, getContainer()); + } + } + + /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ + @RequiresPermission(AdminPermission.class) + public class InternalSourceViewAction extends FormViewAction + { + @Override + public void validateCommand(InternalSourceViewForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(InternalSourceViewForm form, boolean reshow, BindException errors) + { + CstmView view = form.getViewAndCheckPermission(); + form.ff_inherit = QueryManager.get().canInherit(view.getFlags()); + form.ff_hidden = QueryManager.get().isHidden(view.getFlags()); + form.ff_columnList = view.getColumns(); + form.ff_filter = view.getFilter(); + return new JspView<>("/org/labkey/query/view/internalSourceView.jsp", form, errors); + } + + @Override + public boolean handlePost(InternalSourceViewForm form, BindException errors) + { + CstmView view = form.getViewAndCheckPermission(); + int flags = view.getFlags(); + flags = QueryManager.get().setCanInherit(flags, form.ff_inherit); + flags = QueryManager.get().setIsHidden(flags, form.ff_hidden); + view.setFlags(flags); + view.setColumns(form.ff_columnList); + view.setFilter(form.ff_filter); + QueryManager.get().update(getUser(), view); + return true; + } + + @Override + public ActionURL getSuccessURL(InternalSourceViewForm form) + { + return new ActionURL(ManageViewsAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new ManageViewsAction(getViewContext()).addNavTrail(root); + root.addChild("Edit source of Grid View"); + } + } + + /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ + @RequiresPermission(AdminPermission.class) + public class InternalNewViewAction extends FormViewAction + { + int _customViewId = 0; + + @Override + public void validateCommand(InternalNewViewForm form, Errors errors) + { + if (StringUtils.trimToNull(form.ff_schemaName) == null) + { + errors.reject(ERROR_MSG, "Schema name cannot be blank."); + } + if (StringUtils.trimToNull(form.ff_queryName) == null) + { + errors.reject(ERROR_MSG, "Query name cannot be blank"); + } + } + + @Override + public ModelAndView getView(InternalNewViewForm form, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/query/view/internalNewView.jsp", form, errors); + } + + @Override + public boolean handlePost(InternalNewViewForm form, BindException errors) + { + if (form.ff_share) + { + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException(); + } + List existing = QueryManager.get().getCstmViews(getContainer(), form.ff_schemaName, form.ff_queryName, form.ff_viewName, form.ff_share ? null : getUser(), false, false); + CstmView view; + if (!existing.isEmpty()) + { + } + else + { + view = new CstmView(); + view.setSchema(form.ff_schemaName); + view.setQueryName(form.ff_queryName); + view.setName(form.ff_viewName); + view.setContainerId(getContainer().getId()); + if (form.ff_share) + { + view.setCustomViewOwner(null); + } + else + { + view.setCustomViewOwner(getUser().getUserId()); + } + if (form.ff_inherit) + { + view.setFlags(QueryManager.get().setCanInherit(view.getFlags(), form.ff_inherit)); + } + InternalViewForm.checkEdit(getViewContext(), view); + try + { + view = QueryManager.get().insert(getUser(), view); + } + catch (Exception e) + { + LogManager.getLogger(QueryController.class).error("Error", e); + errors.reject(ERROR_MSG, "An exception occurred: " + e); + return false; + } + _customViewId = view.getCustomViewId(); + } + return true; + } + + @Override + public ActionURL getSuccessURL(InternalNewViewForm form) + { + ActionURL forward = new ActionURL(InternalSourceViewAction.class, getContainer()); + forward.addParameter("customViewId", Integer.toString(_customViewId)); + return forward; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Create New Grid View"); + } + } + + + @ActionNames("clearSelected, selectNone") + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public static class SelectNoneAction extends MutatingApiAction + { + @Override + public void validateForm(SelectForm form, Errors errors) + { + if (form.getSchemaName().isEmpty() != (form.getQueryName() == null)) + { + errors.reject(ERROR_MSG, "Both schemaName and queryName are required"); + } + } + + @Override + public ApiResponse execute(final SelectForm form, BindException errors) throws Exception + { + if (form.getQueryName() == null) + { + DataRegionSelection.clearAll(getViewContext(), form.getKey()); + return new DataRegionSelection.SelectionResponse(0); + } + + int count = DataRegionSelection.setSelectedFromForm(form); + return new DataRegionSelection.SelectionResponse(count); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class SelectForm extends QueryForm + { + protected boolean clearSelected; + protected String key; + + public boolean isClearSelected() + { + return clearSelected; + } + + public void setClearSelected(boolean clearSelected) + { + this.clearSelected = clearSelected; + } + + public String getKey() + { + return key; + } + + public void setKey(String key) + { + this.key = key; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public static class SelectAllAction extends MutatingApiAction + { + @Override + public void validateForm(QueryForm form, Errors errors) + { + if (form.getSchemaName().isEmpty() || form.getQueryName() == null) + { + errors.reject(ERROR_MSG, "schemaName and queryName required"); + } + } + + @Override + public ApiResponse execute(final QueryForm form, BindException errors) throws Exception + { + int count = DataRegionSelection.setSelectionForAll(form, true); + return new DataRegionSelection.SelectionResponse(count); + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetSelectedAction extends ReadOnlyApiAction + { + @Override + public void validateForm(SelectForm form, Errors errors) + { + if (form.getSchemaName().isEmpty() != (form.getQueryName() == null)) + { + errors.reject(ERROR_MSG, "Both schemaName and queryName are required"); + } + } + + @Override + public ApiResponse execute(final SelectForm form, BindException errors) throws Exception + { + getViewContext().getResponse().setHeader("Content-Type", CONTENT_TYPE_JSON); + if (form.getQueryName() == null) + { + Set selected = DataRegionSelection.getSelected(getViewContext(), form.getKey(), form.isClearSelected()); + return new ApiSimpleResponse("selected", selected); + } + else + { + Set selected = DataRegionSelection.getSelected(form, form.isClearSelected()); + return new ApiSimpleResponse("selected", selected); + } + } + } + + @ActionNames("setSelected, setCheck") + @RequiresPermission(ReadPermission.class) + public static class SetCheckAction extends MutatingApiAction + { + @Override + public ApiResponse execute(final SetCheckForm form, BindException errors) throws Exception + { + String[] ids = form.getId(getViewContext().getRequest()); + Set selection = new LinkedHashSet<>(); + if (ids != null) + { + for (String id : ids) + { + if (StringUtils.isNotBlank(id)) + selection.add(id); + } + } + + int count; + if (form.getQueryName() != null && form.isValidateIds() && form.isChecked()) + { + selection = DataRegionSelection.getValidatedIds(selection, form); + } + + count = DataRegionSelection.setSelected( + getViewContext(), form.getKey(), + selection, form.isChecked()); + + return new DataRegionSelection.SelectionResponse(count); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class SetCheckForm extends SelectForm + { + protected String[] ids; + protected boolean checked; + protected boolean validateIds; + + public String[] getId(HttpServletRequest request) + { + // 5025 : DataRegion checkbox names may contain comma + // Beehive parses a single parameter value with commas into an array + // which is not what we want. + String[] paramIds = request.getParameterValues("id"); + return paramIds == null ? ids: paramIds; + } + + public void setId(String[] ids) + { + this.ids = ids; + } + + public boolean isChecked() + { + return checked; + } + + public void setChecked(boolean checked) + { + this.checked = checked; + } + + public boolean isValidateIds() + { + return validateIds; + } + + public void setValidateIds(boolean validateIds) + { + this.validateIds = validateIds; + } + } + + @RequiresPermission(ReadPermission.class) + public static class ReplaceSelectedAction extends MutatingApiAction + { + @Override + public ApiResponse execute(final SetCheckForm form, BindException errors) + { + String[] ids = form.getId(getViewContext().getRequest()); + List selection = new ArrayList<>(); + if (ids != null) + { + for (String id : ids) + { + if (StringUtils.isNotBlank(id)) + selection.add(id); + } + } + + + DataRegionSelection.clearAll(getViewContext(), form.getKey()); + int count = DataRegionSelection.setSelected( + getViewContext(), form.getKey(), + selection, true); + return new DataRegionSelection.SelectionResponse(count); + } + } + + @RequiresPermission(ReadPermission.class) + public static class SetSnapshotSelectionAction extends MutatingApiAction + { + @Override + public ApiResponse execute(final SetCheckForm form, BindException errors) + { + String[] ids = form.getId(getViewContext().getRequest()); + List selection = new ArrayList<>(); + if (ids != null) + { + for (String id : ids) + { + if (StringUtils.isNotBlank(id)) + selection.add(id); + } + } + + DataRegionSelection.clearAll(getViewContext(), form.getKey(), true); + int count = DataRegionSelection.setSelected( + getViewContext(), form.getKey(), + selection, true, true); + return new DataRegionSelection.SelectionResponse(count); + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetSnapshotSelectionAction extends ReadOnlyApiAction + { + @Override + public void validateForm(SelectForm form, Errors errors) + { + if (StringUtils.isEmpty(form.getKey())) + { + errors.reject(ERROR_MSG, "Selection key is required"); + } + } + + @Override + public ApiResponse execute(final SelectForm form, BindException errors) throws Exception + { + List selected = DataRegionSelection.getSnapshotSelected(getViewContext(), form.getKey()); + return new ApiSimpleResponse("selected", selected); + } + } + + public static String getMessage(SqlDialect d, SQLException x) + { + return x.getMessage(); + } + + + public static class GetSchemasForm + { + private boolean _includeHidden = true; + private SchemaKey _schemaName; + + public SchemaKey getSchemaName() + { + return _schemaName; + } + + @SuppressWarnings("unused") + public void setSchemaName(SchemaKey schemaName) + { + _schemaName = schemaName; + } + + public boolean isIncludeHidden() + { + return _includeHidden; + } + + @SuppressWarnings("unused") + public void setIncludeHidden(boolean includeHidden) + { + _includeHidden = includeHidden; + } + } + + + @RequiresPermission(ReadPermission.class) + @ApiVersion(12.3) + public static class GetSchemasAction extends ReadOnlyApiAction + { + @Override + protected long getLastModified(GetSchemasForm form) + { + return QueryService.get().metadataLastModified(); + } + + @Override + public ApiResponse execute(GetSchemasForm form, BindException errors) + { + final Container container = getContainer(); + final User user = getUser(); + + final boolean includeHidden = form.isIncludeHidden(); + if (getRequestedApiVersion() >= 9.3) + { + SimpleSchemaTreeVisitor visitor = new SimpleSchemaTreeVisitor<>(includeHidden) + { + @Override + public Void visitUserSchema(UserSchema schema, Path path, JSONObject json) + { + JSONObject schemaProps = new JSONObject(); + + schemaProps.put("schemaName", schema.getName()); + schemaProps.put("fullyQualifiedName", schema.getSchemaName()); + schemaProps.put("description", schema.getDescription()); + schemaProps.put("hidden", schema.isHidden()); + NavTree tree = schema.getSchemaBrowserLinks(user); + if (tree != null && tree.hasChildren()) + schemaProps.put("menu", tree.toJSON()); + + // Collect children schemas + JSONObject children = new JSONObject(); + visit(schema.getSchemas(_includeHidden), path, children); + if (!children.isEmpty()) + schemaProps.put("schemas", children); + + // Add node's schemaProps to the parent's json. + json.put(schema.getName(), schemaProps); + return null; + } + }; + + // By default, start from the root. + QuerySchema schema; + if (form.getSchemaName() != null) + schema = DefaultSchema.get(user, container, form.getSchemaName()); + else + schema = DefaultSchema.get(user, container); + + // Ensure consistent exception as other query actions + QueryForm.ensureSchemaNotNull(schema); + + // Create the JSON response by visiting the schema children. The parent schema information isn't included. + JSONObject ret = new JSONObject(); + visitor.visitTop(schema.getSchemas(includeHidden), ret); + + return new ApiSimpleResponse(ret); + } + else + { + return new ApiSimpleResponse("schemas", DefaultSchema.get(user, container).getUserSchemaPaths(includeHidden)); + } + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class GetQueriesForm + { + private String _schemaName; + private boolean _includeUserQueries = true; + private boolean _includeSystemQueries = true; + private boolean _includeColumns = true; + private boolean _includeViewDataUrl = true; + private boolean _includeTitle = true; + private boolean _queryDetailColumns = false; + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public boolean isIncludeUserQueries() + { + return _includeUserQueries; + } + + public void setIncludeUserQueries(boolean includeUserQueries) + { + _includeUserQueries = includeUserQueries; + } + + public boolean isIncludeSystemQueries() + { + return _includeSystemQueries; + } + + public void setIncludeSystemQueries(boolean includeSystemQueries) + { + _includeSystemQueries = includeSystemQueries; + } + + public boolean isIncludeColumns() + { + return _includeColumns; + } + + public void setIncludeColumns(boolean includeColumns) + { + _includeColumns = includeColumns; + } + + public boolean isQueryDetailColumns() + { + return _queryDetailColumns; + } + + public void setQueryDetailColumns(boolean queryDetailColumns) + { + _queryDetailColumns = queryDetailColumns; + } + + public boolean isIncludeViewDataUrl() + { + return _includeViewDataUrl; + } + + public void setIncludeViewDataUrl(boolean includeViewDataUrl) + { + _includeViewDataUrl = includeViewDataUrl; + } + + public boolean isIncludeTitle() + { + return _includeTitle; + } + + public void setIncludeTitle(boolean includeTitle) + { + _includeTitle = includeTitle; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) + public static class GetQueriesAction extends ReadOnlyApiAction + { + @Override + protected long getLastModified(GetQueriesForm form) + { + return QueryService.get().metadataLastModified(); + } + + @Override + public ApiResponse execute(GetQueriesForm form, BindException errors) + { + if (null == StringUtils.trimToNull(form.getSchemaName())) + throw new IllegalArgumentException("You must supply a value for the 'schemaName' parameter!"); + + ApiSimpleResponse response = new ApiSimpleResponse(); + UserSchema uschema = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()); + if (null == uschema) + throw new NotFoundException("The schema name '" + form.getSchemaName() + + "' was not found within the folder '" + getContainer().getPath() + "'"); + + response.put("schemaName", form.getSchemaName()); + + List> qinfos = new ArrayList<>(); + + //user-defined queries + if (form.isIncludeUserQueries()) + { + for (QueryDefinition qdef : uschema.getQueryDefs().values()) + { + if (!qdef.isTemporary()) + { + ActionURL viewDataUrl = form.isIncludeViewDataUrl() ? uschema.urlFor(QueryAction.executeQuery, qdef) : null; + qinfos.add(getQueryProps(qdef, viewDataUrl, true, uschema, form.isIncludeColumns(), form.isQueryDetailColumns(), form.isIncludeTitle())); + } + } + } + + //built-in tables + if (form.isIncludeSystemQueries()) + { + for (String qname : uschema.getVisibleTableNames()) + { + // Go direct against the UserSchema instead of calling into QueryService, which takes a schema and + // query name as strings and therefore has to create new instances + QueryDefinition qdef = uschema.getQueryDefForTable(qname); + if (qdef != null) + { + ActionURL viewDataUrl = form.isIncludeViewDataUrl() ? uschema.urlFor(QueryAction.executeQuery, qdef) : null; + qinfos.add(getQueryProps(qdef, viewDataUrl, false, uschema, form.isIncludeColumns(), form.isQueryDetailColumns(), form.isIncludeTitle())); + } + } + } + response.put("queries", qinfos); + + return response; + } + + protected Map getQueryProps(QueryDefinition qdef, ActionURL viewDataUrl, boolean isUserDefined, UserSchema schema, boolean includeColumns, boolean useQueryDetailColumns, boolean includeTitle) + { + Map qinfo = new HashMap<>(); + qinfo.put("hidden", qdef.isHidden()); + qinfo.put("snapshot", qdef.isSnapshot()); + qinfo.put("inherit", qdef.canInherit()); + qinfo.put("isUserDefined", isUserDefined); + boolean canEdit = qdef.canEdit(getUser()); + qinfo.put("canEdit", canEdit); + qinfo.put("canEditSharedViews", getContainer().hasPermission(getUser(), EditSharedViewPermission.class)); + // CONSIDER: do we want to separate the 'canEditMetadata' property and 'isMetadataOverridable' properties to differentiate between capability and the permission check? + qinfo.put("isMetadataOverrideable", qdef.isMetadataEditable() && qdef.canEditMetadata(getUser())); + + if (isUserDefined) + qinfo.put("moduleName", qdef.getModuleName()); + boolean isInherited = qdef.canInherit() && !getContainer().equals(qdef.getDefinitionContainer()); + qinfo.put("isInherited", isInherited); + if (isInherited) + qinfo.put("containerPath", qdef.getDefinitionContainer().getPath()); + qinfo.put("isIncludedForLookups", qdef.isIncludedForLookups()); + + if (null != qdef.getDescription()) + qinfo.put("description", qdef.getDescription()); + if (viewDataUrl != null) + qinfo.put("viewDataUrl", viewDataUrl); + + String title = qdef.getName(); + String name = qdef.getName(); + try + { + // get the TableInfo if the user requested column info or title, otherwise skip (it can be expensive) + if (includeColumns || includeTitle) + { + TableInfo table = qdef.getTable(schema, null, true); + + if (null != table) + { + if (includeColumns) + { + Collection> columns; + + if (useQueryDetailColumns) + { + columns = JsonWriter + .getNativeColProps(table, Collections.emptyList(), null, false, false) + .values(); + } + else + { + columns = new ArrayList<>(); + for (ColumnInfo col : table.getColumns()) + { + Map cinfo = new HashMap<>(); + cinfo.put("name", col.getName()); + if (null != col.getLabel()) + cinfo.put("caption", col.getLabel()); + if (null != col.getShortLabel()) + cinfo.put("shortCaption", col.getShortLabel()); + if (null != col.getDescription()) + cinfo.put("description", col.getDescription()); + + columns.add(cinfo); + } + } + + if (!columns.isEmpty()) + qinfo.put("columns", columns); + } + + if (includeTitle) + { + name = table.getPublicName(); + title = table.getTitle(); + } + } + } + } + catch(Exception e) + { + //may happen due to query failing parse + } + + qinfo.put("title", title); + qinfo.put("name", name); + return qinfo; + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class GetQueryViewsForm + { + private String _schemaName; + private String _queryName; + private String _viewName; + private boolean _metadata; + private boolean _excludeSessionView; + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public String getQueryName() + { + return _queryName; + } + + public void setQueryName(String queryName) + { + _queryName = queryName; + } + + public String getViewName() + { + return _viewName; + } + + public void setViewName(String viewName) + { + _viewName = viewName; + } + + public boolean isMetadata() + { + return _metadata; + } + + public void setMetadata(boolean metadata) + { + _metadata = metadata; + } + + public boolean isExcludeSessionView() + { + return _excludeSessionView; + } + + public void setExcludeSessionView(boolean excludeSessionView) + { + _excludeSessionView = excludeSessionView; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) + public static class GetQueryViewsAction extends ReadOnlyApiAction + { + @Override + protected long getLastModified(GetQueryViewsForm form) + { + return QueryService.get().metadataLastModified(); + } + + @Override + public ApiResponse execute(GetQueryViewsForm form, BindException errors) + { + if (null == StringUtils.trimToNull(form.getSchemaName())) + throw new IllegalArgumentException("You must pass a value for the 'schemaName' parameter!"); + if (null == StringUtils.trimToNull(form.getQueryName())) + throw new IllegalArgumentException("You must pass a value for the 'queryName' parameter!"); + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()); + if (null == schema) + throw new NotFoundException("The schema name '" + form.getSchemaName() + + "' was not found within the folder '" + getContainer().getPath() + "'"); + + QueryDefinition querydef = QueryService.get().createQueryDefForTable(schema, form.getQueryName()); + if (null == querydef || querydef.getTable(null, true) == null) + throw new NotFoundException("The query '" + form.getQueryName() + "' was not found within the '" + + form.getSchemaName() + "' schema in the container '" + + getContainer().getPath() + "'!"); + + Map views = querydef.getCustomViews(getUser(), getViewContext().getRequest(), true, false, form.isExcludeSessionView()); + if (null == views) + views = Collections.emptyMap(); + + Map> columnMetadata = new HashMap<>(); + + List> viewInfos = Collections.emptyList(); + if (getViewContext().getBindPropertyValues().contains("viewName")) + { + // Get info for a named view or the default view (null) + String viewName = StringUtils.trimToNull(form.getViewName()); + CustomView view = views.get(viewName); + if (view != null) + { + viewInfos = Collections.singletonList(CustomViewUtil.toMap(view, getUser(), form.isMetadata())); + } + else if (viewName == null) + { + // The default view was requested but it hasn't been customized yet. Create the 'default default' view. + viewInfos = Collections.singletonList(CustomViewUtil.toMap(getViewContext(), schema, form.getQueryName(), null, form.isMetadata(), true, columnMetadata)); + } + } + else + { + boolean foundDefault = false; + viewInfos = new ArrayList<>(views.size()); + for (CustomView view : views.values()) + { + if (view.getName() == null) + foundDefault = true; + viewInfos.add(CustomViewUtil.toMap(view, getUser(), form.isMetadata())); + } + + if (!foundDefault) + { + // The default view hasn't been customized yet. Create the 'default default' view. + viewInfos.add(CustomViewUtil.toMap(getViewContext(), schema, form.getQueryName(), null, form.isMetadata(), true, columnMetadata)); + } + } + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("schemaName", form.getSchemaName()); + response.put("queryName", form.getQueryName()); + response.put("views", viewInfos); + + return response; + } + } + + @RequiresNoPermission + public static class GetServerDateAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(Object o, BindException errors) + { + return new ApiSimpleResponse("date", new Date()); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + private static class SaveApiTestForm + { + private String _getUrl; + private String _postUrl; + private String _postData; + private String _response; + + public String getGetUrl() + { + return _getUrl; + } + + public void setGetUrl(String getUrl) + { + _getUrl = getUrl; + } + + public String getPostUrl() + { + return _postUrl; + } + + public void setPostUrl(String postUrl) + { + _postUrl = postUrl; + } + + public String getResponse() + { + return _response; + } + + public void setResponse(String response) + { + _response = response; + } + + public String getPostData() + { + return _postData; + } + + public void setPostData(String postData) + { + _postData = postData; + } + } + + + @RequiresPermission(ReadPermission.class) + public static class SaveApiTestAction extends MutatingApiAction + { + @Override + public ApiResponse execute(SaveApiTestForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + ApiTestsDocument doc = ApiTestsDocument.Factory.newInstance(); + + TestCaseType test = doc.addNewApiTests().addNewTest(); + test.setName("recorded test case"); + ActionURL url = null; + + if (!StringUtils.isEmpty(form.getGetUrl())) + { + test.setType("get"); + url = new ActionURL(form.getGetUrl()); + } + else if (!StringUtils.isEmpty(form.getPostUrl())) + { + test.setType("post"); + test.setFormData(form.getPostData()); + url = new ActionURL(form.getPostUrl()); + } + + if (url != null) + { + String uri = url.getLocalURIString(); + if (uri.startsWith(url.getContextPath())) + uri = uri.substring(url.getContextPath().length() + 1); + + test.setUrl(uri); + } + test.setResponse(form.getResponse()); + + XmlOptions opts = new XmlOptions(); + opts.setSaveCDataEntityCountThreshold(0); + opts.setSaveCDataLengthThreshold(0); + opts.setSavePrettyPrint(); + opts.setUseDefaultNamespace(); + + response.put("xml", doc.xmlText(opts)); + + return response; + } + } + + + private abstract static class ParseAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + List qpe = new ArrayList<>(); + String expr = getViewContext().getRequest().getParameter("q"); + ArrayList html = new ArrayList<>(); + PageConfig config = getPageConfig(); + var inputId = config.makeId("submit_"); + config.addHandler(inputId, "click", "Ext.getBody().mask();"); + html.add("
\n" + + "" + ); + + QNode e = null; + if (null != expr) + { + try + { + e = _parse(expr,qpe); + } + catch (RuntimeException x) + { + qpe.add(new QueryParseException(x.getMessage(),x, 0, 0)); + } + } + + Tree tree = null; + if (null != expr) + { + try + { + tree = _tree(expr); + } catch (Exception x) + { + qpe.add(new QueryParseException(x.getMessage(),x, 0, 0)); + } + } + + for (Throwable x : qpe) + { + if (null != x.getCause() && x != x.getCause()) + x = x.getCause(); + html.add("
" + PageFlowUtil.filter(x.toString())); + LogManager.getLogger(QueryController.class).debug(expr,x); + } + if (null != e) + { + String prefix = SqlParser.toPrefixString(e); + html.add("
"); + html.add(PageFlowUtil.filter(prefix)); + } + if (null != tree) + { + String prefix = SqlParser.toPrefixString(tree); + html.add("
"); + html.add(PageFlowUtil.filter(prefix)); + } + html.add(""); + return HtmlView.unsafe(StringUtils.join(html,"")); + } + + @Override + public void addNavTrail(NavTree root) + { + } + + abstract QNode _parse(String e, List errors); + abstract Tree _tree(String e) throws Exception; + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class ParseExpressionAction extends ParseAction + { + @Override + QNode _parse(String s, List errors) + { + return new SqlParser().parseExpr(s, true, errors); + } + + @Override + Tree _tree(String e) + { + return null; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class ParseQueryAction extends ParseAction + { + @Override + QNode _parse(String s, List errors) + { + return new SqlParser().parseQuery(s, errors, null); + } + + @Override + Tree _tree(String s) throws Exception + { + return new SqlParser().rawQuery(s); + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) + public static class ValidateQueryMetadataAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(QueryForm form, BindException errors) + { + UserSchema schema = form.getSchema(); + + if (null == schema) + { + errors.reject(ERROR_MSG, "could not resolve schema: " + form.getSchemaName()); + return null; + } + + List parseErrors = new ArrayList<>(); + List parseWarnings = new ArrayList<>(); + ApiSimpleResponse response = new ApiSimpleResponse(); + + try + { + TableInfo table = schema.getTable(form.getQueryName(), null); + + if (null == table) + { + errors.reject(ERROR_MSG, "could not resolve table: " + form.getQueryName()); + return null; + } + + if (!QueryManager.get().validateQuery(table, true, parseErrors, parseWarnings)) + { + for (QueryParseException e : parseErrors) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + return response; + } + + SchemaKey schemaKey = SchemaKey.fromString(form.getSchemaName()); + QueryManager.get().validateQueryMetadata(schemaKey, form.getQueryName(), getUser(), getContainer(), parseErrors, parseWarnings); + QueryManager.get().validateQueryViews(schemaKey, form.getQueryName(), getUser(), getContainer(), parseErrors, parseWarnings); + } + catch (QueryParseException e) + { + parseErrors.add(e); + } + + for (QueryParseException e : parseErrors) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + + for (QueryParseException e : parseWarnings) + { + errors.reject(ERROR_MSG, "WARNING: " + e.getMessage()); + } + + return response; + } + + @Override + protected ApiResponseWriter createResponseWriter() throws IOException + { + ApiResponseWriter result = super.createResponseWriter(); + // Issue 44875 - don't send a 400 or 500 response code when there's a bogus query or metadata + result.setErrorResponseStatus(HttpServletResponse.SC_OK); + return result; + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class QueryExportAuditForm + { + private int rowId; + + public int getRowId() + { + return rowId; + } + + public void setRowId(int rowId) + { + this.rowId = rowId; + } + } + + /** + * Action used to redirect QueryAuditProvider [details] column to the exported table's grid view. + */ + @RequiresPermission(AdminPermission.class) + public static class QueryExportAuditRedirectAction extends SimpleRedirectAction + { + @Override + public URLHelper getRedirectURL(QueryExportAuditForm form) + { + if (form.getRowId() == 0) + throw new NotFoundException("Query export audit rowid required"); + + UserSchema auditSchema = QueryService.get().getUserSchema(getUser(), getContainer(), AbstractAuditTypeProvider.QUERY_SCHEMA_NAME); + TableInfo queryExportAuditTable = auditSchema.getTable(QueryExportAuditProvider.QUERY_AUDIT_EVENT, null); + if (null == queryExportAuditTable) + throw new NotFoundException(); + + TableSelector selector = new TableSelector(queryExportAuditTable, + PageFlowUtil.set( + QueryExportAuditProvider.COLUMN_NAME_SCHEMA_NAME, + QueryExportAuditProvider.COLUMN_NAME_QUERY_NAME, + QueryExportAuditProvider.COLUMN_NAME_DETAILS_URL), + new SimpleFilter(FieldKey.fromParts(AbstractAuditTypeProvider.COLUMN_NAME_ROW_ID), form.getRowId()), null); + + Map result = selector.getMap(); + if (result == null) + throw new NotFoundException("Query export audit event not found for rowId"); + + String schemaName = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_SCHEMA_NAME); + String queryName = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_QUERY_NAME); + String detailsURL = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_DETAILS_URL); + + if (schemaName == null || queryName == null) + throw new NotFoundException("Query export audit event has not schemaName or queryName"); + + ActionURL url = new ActionURL(ExecuteQueryAction.class, getContainer()); + + // Apply the sorts and filters + if (detailsURL != null) + { + ActionURL sortFilterURL = new ActionURL(detailsURL); + url.setPropertyValues(sortFilterURL.getPropertyValues()); + } + + if (url.getParameter(QueryParam.schemaName) == null) + url.addParameter(QueryParam.schemaName, schemaName); + if (url.getParameter(QueryParam.queryName) == null && url.getParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName) == null) + url.addParameter(QueryParam.queryName, queryName); + + return url; + } + } + + @RequiresPermission(ReadPermission.class) + public static class AuditHistoryAction extends SimpleViewAction + { + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + return QueryUpdateAuditProvider.createHistoryQueryView(getViewContext(), form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Audit History"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class AuditDetailsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(QueryDetailsForm form, BindException errors) + { + return QueryUpdateAuditProvider.createDetailsQueryView(getViewContext(), form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Audit History"); + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class QueryDetailsForm extends QueryForm + { + String _keyValue; + + public String getKeyValue() + { + return _keyValue; + } + + public void setKeyValue(String keyValue) + { + _keyValue = keyValue; + } + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportTablesAction extends FormViewAction + { + private ActionURL _successUrl; + + @Override + public void validateCommand(ExportTablesForm form, Errors errors) + { + } + + @Override + public boolean handlePost(ExportTablesForm form, BindException errors) + { + HttpServletResponse httpResponse = getViewContext().getResponse(); + Container container = getContainer(); + QueryServiceImpl svc = (QueryServiceImpl)QueryService.get(); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); OutputStream outputStream = new BufferedOutputStream(baos)) + { + try (ZipFile zip = new ZipFile(outputStream, true)) + { + svc.writeTables(container, getUser(), zip, form.getSchemas(), form.getHeaderType()); + } + + PageFlowUtil.streamFileBytes(httpResponse, FileUtil.makeFileNameWithTimestamp(container.getName(), "tables.zip"), baos.toByteArray(), false); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, e.getMessage() != null ? e.getMessage() : e.getClass().getName()); + LOG.error("Errror exporting tables", e); + } + + if (errors.hasErrors()) + { + _successUrl = new ActionURL(ExportTablesAction.class, getContainer()); + } + + return !errors.hasErrors(); + } + + @Override + public ModelAndView getView(ExportTablesForm form, boolean reshow, BindException errors) + { + // When exporting the zip to the browser, the base action will attempt to reshow the view since we returned + // null as the success URL; returning null here causes the base action to stop pestering the action. + if (reshow && !errors.hasErrors()) + return null; + + return new JspView<>("/org/labkey/query/view/exportTables.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Export Tables"); + } + + @Override + public ActionURL getSuccessURL(ExportTablesForm form) + { + return _successUrl; + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportTablesForm implements HasBindParameters + { + ColumnHeaderType _headerType = ColumnHeaderType.DisplayFieldKey; + Map>> _schemas = new HashMap<>(); + + public ColumnHeaderType getHeaderType() + { + return _headerType; + } + + public void setHeaderType(ColumnHeaderType headerType) + { + _headerType = headerType; + } + + public Map>> getSchemas() + { + return _schemas; + } + + public void setSchemas(Map>> schemas) + { + _schemas = schemas; + } + + @Override + public @NotNull BindException bindParameters(PropertyValues values) + { + BindException errors = new NullSafeBindException(this, "form"); + + PropertyValue schemasProperty = values.getPropertyValue("schemas"); + if (schemasProperty != null && schemasProperty.getValue() != null) + { + try + { + _schemas = JsonUtil.DEFAULT_MAPPER.readValue((String)schemasProperty.getValue(), _schemas.getClass()); + } + catch (IOException e) + { + errors.rejectValue("schemas", ERROR_MSG, e.getMessage()); + } + } + + PropertyValue headerTypeProperty = values.getPropertyValue("headerType"); + if (headerTypeProperty != null && headerTypeProperty.getValue() != null) + { + try + { + _headerType = ColumnHeaderType.valueOf(String.valueOf(headerTypeProperty.getValue())); + } + catch (IllegalArgumentException ex) + { + // ignore + } + } + + return errors; + } + } + + + @RequiresPermission(ReadPermission.class) + public static class SaveNamedSetAction extends MutatingApiAction + { + @Override + public Object execute(NamedSetForm namedSetForm, BindException errors) + { + QueryService.get().saveNamedSet(namedSetForm.getSetName(), namedSetForm.parseSetList()); + return new ApiSimpleResponse("success", true); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class NamedSetForm + { + String setName; + String[] setList; + + public String getSetName() + { + return setName; + } + + public void setSetName(String setName) + { + this.setName = setName; + } + + public String[] getSetList() + { + return setList; + } + + public void setSetList(String[] setList) + { + this.setList = setList; + } + + public List parseSetList() + { + return Arrays.asList(setList); + } + } + + + @RequiresPermission(ReadPermission.class) + public static class DeleteNamedSetAction extends MutatingApiAction + { + + @Override + public Object execute(NamedSetForm namedSetForm, BindException errors) + { + QueryService.get().deleteNamedSet(namedSetForm.getSetName()); + return new ApiSimpleResponse("success", true); + } + } + + @RequiresPermission(ReadPermission.class) + public static class AnalyzeQueriesAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + JSONObject ret = new JSONObject(); + + try + { + QueryService.QueryAnalysisService analysisService = QueryService.get().getQueryAnalysisService(); + if (analysisService != null) + { + DefaultSchema start = DefaultSchema.get(getUser(), getContainer()); + var deps = new HashSetValuedHashMap(); + + analysisService.analyzeFolder(start, deps); + ret.put("success", true); + + JSONObject objects = new JSONObject(); + for (var from : deps.keySet()) + { + objects.put(from.getKey(), from.toJSON()); + for (var to : deps.get(from)) + objects.put(to.getKey(), to.toJSON()); + } + ret.put("objects", objects); + + JSONArray dependants = new JSONArray(); + for (var from : deps.keySet()) + { + for (var to : deps.get(from)) + dependants.put(new String[] {from.getKey(), to.getKey()}); + } + ret.put("graph", dependants); + } + else + { + ret.put("success", false); + } + return ret; + } + catch (Throwable e) + { + LOG.error(e); + throw UnexpectedException.wrap(e); + } + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetQueryEditorMetadataAction extends ReadOnlyApiAction + { + @Override + protected ObjectMapper createRequestObjectMapper() + { + PropertyService propertyService = PropertyService.get(); + if (null != propertyService) + { + ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy(); + mapper.addMixIn(GWTPropertyDescriptor.class, MetadataTableJSONMixin.class); + return mapper; + } + else + { + throw new RuntimeException("Could not serialize request object"); + } + } + + @Override + protected ObjectMapper createResponseObjectMapper() + { + return createRequestObjectMapper(); + } + + @Override + public Object execute(QueryForm queryForm, BindException errors) throws Exception + { + QueryDefinition queryDef = queryForm.getQueryDef(); + return MetadataTableJSON.getMetadata(queryDef.getSchema().getSchemaName(), queryDef.getName(), getUser(), getContainer()); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + public static class SaveQueryMetadataAction extends MutatingApiAction + { + @Override + protected ObjectMapper createRequestObjectMapper() + { + PropertyService propertyService = PropertyService.get(); + if (null != propertyService) + { + ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy(); + propertyService.configureObjectMapper(mapper, null); + return mapper; + } + else + { + throw new RuntimeException("Could not serialize request object"); + } + } + + @Override + protected ObjectMapper createResponseObjectMapper() + { + return createRequestObjectMapper(); + } + + @Override + public Object execute(QueryMetadataApiForm queryMetadataApiForm, BindException errors) throws Exception + { + String schemaName = queryMetadataApiForm.getSchemaName(); + MetadataTableJSON domain = queryMetadataApiForm.getDomain(); + MetadataTableJSON.saveMetadata(schemaName, domain.getName(), null, domain.getFields(true), queryMetadataApiForm.isUserDefinedQuery(), false, getUser(), getContainer()); + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", true); + resp.put("domain", MetadataTableJSON.getMetadata(schemaName, domain.getName(), getUser(), getContainer())); + return resp; + } + } + + @Marshal(Marshaller.Jackson) + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + public static class ResetQueryMetadataAction extends MutatingApiAction + { + @Override + public Object execute(QueryForm queryForm, BindException errors) throws Exception + { + QueryDefinition queryDef = queryForm.getQueryDef(); + return MetadataTableJSON.resetToDefault(queryDef.getSchema().getSchemaName(), queryDef.getName(), getUser(), getContainer()); + } + } + + private static class QueryMetadataApiForm + { + private MetadataTableJSON _domain; + private String _schemaName; + private boolean _userDefinedQuery; + + public MetadataTableJSON getDomain() + { + return _domain; + } + + @SuppressWarnings("unused") + public void setDomain(MetadataTableJSON domain) + { + _domain = domain; + } + + public String getSchemaName() + { + return _schemaName; + } + + @SuppressWarnings("unused") + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public boolean isUserDefinedQuery() + { + return _userDefinedQuery; + } + + @SuppressWarnings("unused") + public void setUserDefinedQuery(boolean userDefinedQuery) + { + _userDefinedQuery = userDefinedQuery; + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetDefaultVisibleColumnsAction extends ReadOnlyApiAction + { + @Override + public Object execute(GetQueryDetailsAction.Form form, BindException errors) throws Exception + { + ApiSimpleResponse resp = new ApiSimpleResponse(); + + Container container = getContainer(); + User user = getUser(); + + if (StringUtils.isEmpty(form.getSchemaName())) + throw new NotFoundException("SchemaName not specified"); + + QuerySchema querySchema = DefaultSchema.get(user, container, form.getSchemaName()); + if (!(querySchema instanceof UserSchema schema)) + throw new NotFoundException("Could not find the specified schema in the folder '" + container.getPath() + "'"); + + QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, form.getQueryName()); + QueryDefinition queryDef = settings.getQueryDef(schema); + if (null == queryDef) + // Don't echo the provided query name, but schema name is legit since it was found. See #44528. + throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'"); + + TableInfo tinfo = queryDef.getTable(null, true); + if (null == tinfo) + throw new NotFoundException("Could not find the specified query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); + + List fields = tinfo.getDefaultVisibleColumns(); + + List displayColumns = QueryService.get().getColumns(tinfo, fields) + .values() + .stream() + .filter(cinfo -> fields.contains(cinfo.getFieldKey())) + .map(cinfo -> cinfo.getDisplayColumnFactory().createRenderer(cinfo)) + .collect(Collectors.toList()); + + resp.put("columns", JsonWriter.getNativeColProps(displayColumns, null, false)); + + return resp; + } + } + + public static class ParseForm implements ApiJsonForm + { + String expression = ""; + Map columnMap = new HashMap<>(); + List phiColumns = new ArrayList<>(); + + Map getColumnMap() + { + return columnMap; + } + + public String getExpression() + { + return expression; + } + + public void setExpression(String expression) + { + this.expression = expression; + } + + public List getPhiColumns() + { + return phiColumns; + } + + public void setPhiColumns(List phiColumns) + { + this.phiColumns = phiColumns; + } + + @Override + public void bindJson(JSONObject json) + { + if (json.has("expression")) + setExpression(json.getString("expression")); + if (json.has("phiColumns")) + setPhiColumns(json.getJSONArray("phiColumns").toList().stream().map(s -> FieldKey.fromParts(s.toString())).collect(Collectors.toList())); + if (json.has("columnMap")) + { + JSONObject columnMap = json.getJSONObject("columnMap"); + for (String key : columnMap.keySet()) + { + try + { + getColumnMap().put(FieldKey.fromParts(key), JdbcType.valueOf(String.valueOf(columnMap.get(key)))); + } + catch (IllegalArgumentException iae) + { + getColumnMap().put(FieldKey.fromParts(key), JdbcType.OTHER); + } + } + } + } + } + + + /** + * Since this api purpose is to return parse errors, it does not generally return success:false. + *
+ * The API expects JSON like this, note that column names should be in FieldKey.toString() encoded to match the response JSON format. + *
+     *     { "expression": "A$ + B", "columnMap":{"A$D":"VARCHAR", "X":"VARCHAR"}}
+     * 
+ * and returns a response like this + *
+     *     {
+     *       "jdbcType" : "OTHER",
+     *       "success" : true,
+     *       "columnMap" : {"A$D":"VARCHAR", "B":"OTHER"}
+     *       "errors" : [ { "msg" : "\"B\" not found.", "type" : "sql" } ]
+     *     }
+     * 
+ * The columnMap object keys are the names of columns found in the expression. Names are returned + * in FieldKey.toString() formatting e.g. dollar-sign encoded. The object structure + * is compatible with the columnMap input parameter, so it can be used as a template to make a second request + * with types filled in. If provided, the type will be copied from the input columnMap, otherwise it will be "OTHER". + *
+ * Parse exceptions may contain a line (usually 1) and col location e.g. + *
+     * {
+     *     "msg" : "Error on line 1: Syntax error near 'error', expected 'EOF'
+     *     "col" : 2,
+     *     "line" : 1,
+     *     "type" : "sql",
+     *     "errorStr" : "A error B"
+     *   }
+     * 
+ */ + @RequiresNoPermission + @CSRF(CSRF.Method.NONE) + public static class ParseCalculatedColumnAction extends ReadOnlyApiAction + { + @Override + public Object execute(ParseForm form, BindException errors) throws Exception + { + if (errors.hasErrors()) + return errors; + JSONObject result = new JSONObject(Map.of("success",true)); + var requiredColumns = new HashSet(); + JdbcType jdbcType = JdbcType.OTHER; + try + { + var schema = DefaultSchema.get(getViewContext().getUser(), getViewContext().getContainer()).getUserSchema("core"); + var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; + ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), form.getExpression(), null); + Map columns = new HashMap<>(); + for (var entry : form.getColumnMap().entrySet()) + { + BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); + // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions + // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects + if (form.getPhiColumns().contains(entry.getKey())) + entryCol.setPHI(PHI.PHI); + columns.put(entry.getKey(), entryCol); + table.addColumn(entryCol); + } + // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() + QueryServiceImpl.get().bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); + jdbcType = calculatedCol.getJdbcType(); + } + catch (QueryException x) + { + JSONArray parseErrors = new JSONArray(); + parseErrors.put(x.toJSON(form.getExpression())); + result.put("errors", parseErrors); + } + finally + { + if (!requiredColumns.isEmpty()) + { + JSONObject columnMap = new JSONObject(); + for (FieldKey fk : requiredColumns) + { + JdbcType type = Objects.requireNonNullElse(form.getColumnMap().get(fk), JdbcType.OTHER); + columnMap.put(fk.toString(), type); + } + result.put("columnMap", columnMap); + } + } + result.put("jdbcType", jdbcType.name()); + return result; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class QueryImportTemplateForm + { + private String schemaName; + private String queryName; + private String auditUserComment; + private List templateLabels; + private List templateUrls; + private Long _lastKnownModified; + + public void setQueryName(String queryName) + { + this.queryName = queryName; + } + + public List getTemplateLabels() + { + return templateLabels == null ? Collections.emptyList() : templateLabels; + } + + public void setTemplateLabels(List templateLabels) + { + this.templateLabels = templateLabels; + } + + public List getTemplateUrls() + { + return templateUrls == null ? Collections.emptyList() : templateUrls; + } + + public void setTemplateUrls(List templateUrls) + { + this.templateUrls = templateUrls; + } + + public String getSchemaName() + { + return schemaName; + } + + @SuppressWarnings("unused") + public void setSchemaName(String schemaName) + { + this.schemaName = schemaName; + } + + public String getQueryName() + { + return queryName; + } + + public Long getLastKnownModified() + { + return _lastKnownModified; + } + + public void setLastKnownModified(Long lastKnownModified) + { + _lastKnownModified = lastKnownModified; + } + + public String getAuditUserComment() + { + return auditUserComment; + } + + public void setAuditUserComment(String auditUserComment) + { + this.auditUserComment = auditUserComment; + } + + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) //Real permissions will be enforced later on by the DomainKind + public static class UpdateQueryImportTemplateAction extends MutatingApiAction + { + private DomainKind _kind; + private UserSchema _schema; + private TableInfo _tInfo; + private QueryDefinition _queryDef; + private Domain _domain; + + @Override + protected ObjectMapper createResponseObjectMapper() + { + return this.createRequestObjectMapper(); + } + + @Override + public void validateForm(QueryImportTemplateForm form, Errors errors) + { + User user = getUser(); + Container container = getContainer(); + String domainURI = PropertyService.get().getDomainURI(form.getSchemaName(), form.getQueryName(), container, user); + _kind = PropertyService.get().getDomainKind(domainURI); + _domain = PropertyService.get().getDomain(container, domainURI); + if (_domain == null) + throw new IllegalArgumentException("Domain '" + domainURI + "' not found."); + + if (!_kind.canEditDefinition(user, _domain)) + throw new UnauthorizedException("You don't have permission to update import templates for this domain."); + + QuerySchema querySchema = DefaultSchema.get(user, container, form.getSchemaName()); + if (!(querySchema instanceof UserSchema _schema)) + throw new NotFoundException("Could not find the specified schema in the folder '" + container.getPath() + "'."); + QuerySettings settings = _schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, form.getQueryName()); + _queryDef = settings.getQueryDef(_schema); + if (null == _queryDef) + throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'."); + if (!_queryDef.isMetadataEditable()) + throw new UnsupportedOperationException("Query metadata is not editable."); + _tInfo = _queryDef.getTable(_schema, new ArrayList<>(), true, true); + if (_tInfo == null) + throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'."); + + } + + private Map getRowFiles() + { + Map rowFiles = new IntHashMap<>(); + if (getFileMap() != null) + { + for (Map.Entry fileEntry : getFileMap().entrySet()) + { + // allow for the fileMap key to include the row index for defining which row to attach this file to + // ex: "templateFile::0", "templateFile::1" + String fieldKey = fileEntry.getKey(); + int delimIndex = fieldKey.lastIndexOf("::"); + if (delimIndex > -1) + { + Integer fieldRowIndex = Integer.parseInt(fieldKey.substring(delimIndex + 2)); + SpringAttachmentFile file = new SpringAttachmentFile(fileEntry.getValue()); + rowFiles.put(fieldRowIndex, file.isEmpty() ? null : file); + } + } + } + return rowFiles; + } + + private List> getUploadedTemplates(QueryImportTemplateForm form, DomainKind kind) throws ValidationException, QueryUpdateServiceException, ExperimentException + { + FileContentService fcs = FileContentService.get(); + if (fcs == null) + throw new IllegalStateException("Unable to load file service."); + + User user = getUser(); + Container container = getContainer(); + + Map rowFiles = getRowFiles(); + List templateLabels = form.getTemplateLabels(); + Set labels = new HashSet<>(templateLabels); + if (labels.size() < templateLabels.size()) + throw new IllegalArgumentException("Duplicate template name is not allowed."); + + List templateUrls = form.getTemplateUrls(); + List> uploadedTemplates = new ArrayList<>(); + for (int rowIndex = 0; rowIndex < form.getTemplateLabels().size(); rowIndex++) + { + String templateLabel = templateLabels.get(rowIndex); + if (StringUtils.isBlank(templateLabel.trim())) + throw new IllegalArgumentException("Template name cannot be blank."); + String templateUrl = templateUrls.get(rowIndex); + Object file = rowFiles.get(rowIndex); + if (StringUtils.isEmpty(templateUrl) && file == null) + throw new IllegalArgumentException("Template file is not provided."); + + if (file instanceof MultipartFile || file instanceof SpringAttachmentFile) + { + String fileName; + if (file instanceof MultipartFile f) + fileName = f.getName(); + else + { + SpringAttachmentFile f = (SpringAttachmentFile) file; + fileName = f.getFilename(); + } + String fileNameValidation = FileUtil.validateFileName(fileName); + if (!StringUtils.isEmpty(fileNameValidation)) + throw new IllegalArgumentException(fileNameValidation); + + FileLike uploadDir = ensureUploadDirectory(container, kind.getDomainFileDirectory()); + uploadDir = uploadDir.resolveChild("_templates"); + Object savedFile = saveFile(user, container, "template file", file, uploadDir); + Path savedFilePath; + + if (savedFile instanceof File ioFile) + savedFilePath = ioFile.toPath(); + else if (savedFile instanceof FileLike fl) + savedFilePath = fl.toNioPathForRead(); + else + throw UnexpectedException.wrap(null,"Unable to upload template file."); + + templateUrl = fcs.getWebDavUrl(savedFilePath, container, FileContentService.PathType.serverRelative).toString(); + } + + uploadedTemplates.add(Pair.of(templateLabel, templateUrl)); + } + return uploadedTemplates; + } + + @Override + public Object execute(QueryImportTemplateForm form, BindException errors) throws ValidationException, QueryUpdateServiceException, SQLException, ExperimentException, MetadataUnavailableException + { + User user = getUser(); + Container container = getContainer(); + String schemaName = form.getSchemaName(); + String queryName = form.getQueryName(); + QueryDef queryDef = QueryManager.get().getQueryDef(container, schemaName, queryName, false); + if (queryDef != null && queryDef.getQueryDefId() != 0) + { + Long lastKnownModified = form.getLastKnownModified(); + if (lastKnownModified == null || lastKnownModified != queryDef.getModified().getTime()) + throw new ApiUsageException("Unable to save import templates. The templates appear out of date, reload the page and try again."); + } + + List> updatedTemplates = getUploadedTemplates(form, _kind); + + List> existingTemplates = _tInfo.getImportTemplates(getViewContext()); + List> existingCustomTemplates = new ArrayList<>(); + for (Pair template_ : existingTemplates) + { + if (!template_.second.toLowerCase().contains("exportexceltemplate")) + existingCustomTemplates.add(template_); + } + if (!updatedTemplates.equals(existingCustomTemplates)) + { + TablesDocument doc = null; + TableType xmlTable = null; + TableType.ImportTemplates xmlImportTemplates; + + if (queryDef != null) + { + try + { + doc = parseDocument(queryDef.getMetaData()); + } + catch (XmlException e) + { + throw new MetadataUnavailableException(e.getMessage()); + } + xmlTable = getTableType(form.getQueryName(), doc); + // when there is a queryDef but xmlTable is null it means the xmlMetaData contains tableName which does not + // match with actual queryName then reconstruct the xml table metadata : See Issue 43523 + if (xmlTable == null) + { + doc = null; + } + } + else + { + queryDef = new QueryDef(); + queryDef.setSchema(schemaName); + queryDef.setContainer(container.getId()); + queryDef.setName(queryName); + } + + if (doc == null) + { + doc = TablesDocument.Factory.newInstance(); + } + + if (xmlTable == null) + { + TablesType tables = doc.addNewTables(); + xmlTable = tables.addNewTable(); + xmlTable.setTableName(queryName); + } + + if (xmlTable.getTableDbType() == null) + { + xmlTable.setTableDbType("NOT_IN_DB"); + } + + // remove existing templates + if (xmlTable.isSetImportTemplates()) + xmlTable.unsetImportTemplates(); + xmlImportTemplates = xmlTable.addNewImportTemplates(); + + // set new templates + if (!updatedTemplates.isEmpty()) + { + for (Pair template_ : updatedTemplates) + { + ImportTemplateType importTemplateType = xmlImportTemplates.addNewTemplate(); + importTemplateType.setLabel(template_.first); + importTemplateType.setUrl(template_.second); + } + } + + XmlOptions xmlOptions = new XmlOptions(); + xmlOptions.setSavePrettyPrint(); + // Don't use an explicit namespace, making the XML much more readable + xmlOptions.setUseDefaultNamespace(); + queryDef.setMetaData(doc.xmlText(xmlOptions)); + if (queryDef.getQueryDefId() == 0) + { + QueryManager.get().insert(user, queryDef); + } + else + { + QueryManager.get().update(user, queryDef); + } + + DomainAuditProvider.DomainAuditEvent event = new DomainAuditProvider.DomainAuditEvent(getContainer(), "Import templates updated."); + event.setUserComment(form.getAuditUserComment()); + event.setDomainUri(_domain.getTypeURI()); + event.setDomainName(_domain.getName()); + AuditLogService.get().addEvent(user, event); + } + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", true); + return resp; + } + } + + + public static class TestCase extends AbstractActionPermissionTest + { + @Override + public void testActionPermissions() + { + User user = TestContext.get().getUser(); + assertTrue(user.hasSiteAdminPermission()); + + QueryController controller = new QueryController(); + + // @RequiresPermission(ReadPermission.class) + assertForReadPermission(user, false, + new BrowseAction(), + new BeginAction(), + controller.new SchemaAction(), + controller.new SourceQueryAction(), + controller.new ExecuteQueryAction(), + controller.new PrintRowsAction(), + new ExportScriptAction(), + new ExportRowsExcelAction(), + new ExportRowsXLSXAction(), + new ExportQueriesXLSXAction(), + new ExportExcelTemplateAction(), + new ExportRowsTsvAction(), + new ExcelWebQueryDefinitionAction(), + controller.new SaveQueryViewsAction(), + controller.new PropertiesQueryAction(), + controller.new SelectRowsAction(), + new GetDataAction(), + controller.new ExecuteSqlAction(), + controller.new SelectDistinctAction(), + controller.new GetColumnSummaryStatsAction(), + controller.new ImportAction(), + new ExportSqlAction(), + new UpdateRowsAction(), + new ImportRowsAction(), + new DeleteRowsAction(), + new TableInfoAction(), + new SaveSessionViewAction(), + new GetSchemasAction(), + new GetQueriesAction(), + new GetQueryViewsAction(), + new SaveApiTestAction(), + new ValidateQueryMetadataAction(), + new AuditHistoryAction(), + new AuditDetailsAction(), + new ExportTablesAction(), + new SaveNamedSetAction(), + new DeleteNamedSetAction(), + new ApiTestAction(), + new GetDefaultVisibleColumnsAction() + ); + + + // submitter should be allowed for InsertRows + assertForReadPermission(user, true, new InsertRowsAction()); + + // @RequiresPermission(DeletePermission.class) + assertForUpdateOrDeletePermission(user, + new DeleteQueryRowsAction() + ); + + // @RequiresPermission(AdminPermission.class) + assertForAdminPermission(user, + new DeleteQueryAction(), + controller.new MetadataQueryAction(), + controller.new NewQueryAction(), + new SaveSourceQueryAction(), + + new TruncateTableAction(), + new AdminAction(), + new ManageRemoteConnectionsAction(), + new ReloadExternalSchemaAction(), + new ReloadAllUserSchemas(), + controller.new ManageViewsAction(), + controller.new InternalDeleteView(), + controller.new InternalSourceViewAction(), + controller.new InternalNewViewAction(), + new QueryExportAuditRedirectAction() + ); + + // @RequiresPermission(AdminOperationsPermission.class) + assertForAdminOperationsPermission(user, + new EditRemoteConnectionAction(), + new DeleteRemoteConnectionAction(), + new TestRemoteConnectionAction(), + controller.new RawTableMetaDataAction(), + controller.new RawSchemaMetaDataAction(), + new InsertLinkedSchemaAction(), + new InsertExternalSchemaAction(), + new DeleteSchemaAction(), + new EditLinkedSchemaAction(), + new EditExternalSchemaAction(), + new GetTablesAction(), + new SchemaTemplateAction(), + new SchemaTemplatesAction(), + new ParseExpressionAction(), + new ParseQueryAction() + ); + + // @AdminConsoleAction + assertForAdminPermission(ContainerManager.getRoot(), user, + new DataSourceAdminAction() + ); + + // In addition to administrators (tested above), trusted analysts who are editors can create and edit queries + assertTrustedEditorPermission( + new DeleteQueryAction(), + controller.new MetadataQueryAction(), + controller.new NewQueryAction(), + new SaveSourceQueryAction() + ); + } + } + + public static class SaveRowsTestCase extends Assert + { + private static final String PROJECT_NAME1 = "SaveRowsTestProject1"; + private static final String PROJECT_NAME2 = "SaveRowsTestProject2"; + + private static final String USER_EMAIL = "saveRows@action.test"; + + private static final String LIST1 = "List1"; + private static final String LIST2 = "List2"; + + @Before + public void doSetup() throws Exception + { + doCleanup(); + + Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_NAME1, TestContext.get().getUser()); + Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_NAME2, TestContext.get().getUser()); + + //disable search so we dont get conflicts when deleting folder quickly + ContainerManager.updateSearchable(project1, false, TestContext.get().getUser()); + ContainerManager.updateSearchable(project2, false, TestContext.get().getUser()); + + ListDefinition ld1 = ListService.get().createList(project1, LIST1, ListDefinition.KeyType.Varchar); + ld1.getDomain().addProperty(new PropertyStorageSpec("TextField", JdbcType.VARCHAR)); + ld1.setKeyName("TextField"); + ld1.save(TestContext.get().getUser()); + + ListDefinition ld2 = ListService.get().createList(project2, LIST2, ListDefinition.KeyType.Varchar); + ld2.getDomain().addProperty(new PropertyStorageSpec("TextField", JdbcType.VARCHAR)); + ld2.setKeyName("TextField"); + ld2.save(TestContext.get().getUser()); + } + + @After + public void doCleanup() throws Exception + { + Container project = ContainerManager.getForPath(PROJECT_NAME1); + if (project != null) + { + ContainerManager.deleteAll(project, TestContext.get().getUser()); + } + + Container project2 = ContainerManager.getForPath(PROJECT_NAME2); + if (project2 != null) + { + ContainerManager.deleteAll(project2, TestContext.get().getUser()); + } + + User u = UserManager.getUser(new ValidEmail(USER_EMAIL)); + if (u != null) + { + UserManager.deleteUser(u.getUserId()); + } + } + + private JSONObject getCommand(String val1, String val2) + { + JSONObject command1 = new JSONObject(); + command1.put("containerPath", ContainerManager.getForPath(PROJECT_NAME1).getPath()); + command1.put("command", "insert"); + command1.put("schemaName", "lists"); + command1.put("queryName", LIST1); + command1.put("rows", getTestRows(val1)); + + JSONObject command2 = new JSONObject(); + command2.put("containerPath", ContainerManager.getForPath(PROJECT_NAME2).getPath()); + command2.put("command", "insert"); + command2.put("schemaName", "lists"); + command2.put("queryName", LIST2); + command2.put("rows", getTestRows(val2)); + + JSONObject json = new JSONObject(); + json.put("commands", Arrays.asList(command1, command2)); + + return json; + } + + private MockHttpServletResponse makeRequest(JSONObject json, User user) throws Exception + { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + + HttpServletRequest request = ViewServlet.mockRequest(RequestMethod.POST.name(), DetailsURL.fromString("/query/saveRows.view").copy(ContainerManager.getForPath(PROJECT_NAME1)).getActionURL(), user, headers, json.toString()); + return ViewServlet.mockDispatch(request, null); + } + + @Test + public void testCrossFolderSaveRows() throws Exception + { + User user = TestContext.get().getUser(); + assertTrue(user.hasSiteAdminPermission()); + + JSONObject json = getCommand(PROJECT_NAME1, PROJECT_NAME2); + MockHttpServletResponse response = makeRequest(json, TestContext.get().getUser()); + if (response.getStatus() != HttpServletResponse.SC_OK) + { + JSONObject responseJson = new JSONObject(response.getContentAsString()); + throw new RuntimeException("Problem saving rows across folders: " + responseJson.getString("exception")); + } + + Container project1 = ContainerManager.getForPath(PROJECT_NAME1); + Container project2 = ContainerManager.getForPath(PROJECT_NAME2); + + TableInfo list1 = ListService.get().getList(project1, LIST1).getTable(TestContext.get().getUser()); + TableInfo list2 = ListService.get().getList(project2, LIST2).getTable(TestContext.get().getUser()); + + assertEquals("Incorrect row count, list1", 1L, new TableSelector(list1).getRowCount()); + assertEquals("Incorrect row count, list2", 1L, new TableSelector(list2).getRowCount()); + + assertEquals("Incorrect value", PROJECT_NAME1, new TableSelector(list1, PageFlowUtil.set("TextField")).getObject(PROJECT_NAME1, String.class)); + assertEquals("Incorrect value", PROJECT_NAME2, new TableSelector(list2, PageFlowUtil.set("TextField")).getObject(PROJECT_NAME2, String.class)); + + list1.getUpdateService().truncateRows(TestContext.get().getUser(), project1, null, null); + list2.getUpdateService().truncateRows(TestContext.get().getUser(), project2, null, null); + } + + @Test + public void testWithoutPermissions() throws Exception + { + // Now test failure without appropriate permissions: + User withoutPermissions = SecurityManager.addUser(new ValidEmail(USER_EMAIL), TestContext.get().getUser()).getUser(); + + User user = TestContext.get().getUser(); + assertTrue(user.hasSiteAdminPermission()); + + Container project1 = ContainerManager.getForPath(PROJECT_NAME1); + Container project2 = ContainerManager.getForPath(PROJECT_NAME2); + + MutableSecurityPolicy securityPolicy = new MutableSecurityPolicy(SecurityPolicyManager.getPolicy(project1)); + securityPolicy.addRoleAssignment(withoutPermissions, EditorRole.class); + SecurityPolicyManager.savePolicyForTests(securityPolicy, TestContext.get().getUser()); + + assertTrue("Should have insert permission", project1.hasPermission(withoutPermissions, InsertPermission.class)); + assertFalse("Should not have insert permission", project2.hasPermission(withoutPermissions, InsertPermission.class)); + + // repeat insert: + JSONObject json = getCommand("ShouldFail1", "ShouldFail2"); + MockHttpServletResponse response = makeRequest(json, withoutPermissions); + if (response.getStatus() != HttpServletResponse.SC_FORBIDDEN) + { + JSONObject responseJson = new JSONObject(response.getContentAsString()); + throw new RuntimeException("Problem saving rows across folders: " + responseJson.getString("exception")); + } + + TableInfo list1 = ListService.get().getList(project1, LIST1).getTable(TestContext.get().getUser()); + TableInfo list2 = ListService.get().getList(project2, LIST2).getTable(TestContext.get().getUser()); + + // The insert should have failed + assertEquals("Incorrect row count, list1", 0L, new TableSelector(list1).getRowCount()); + assertEquals("Incorrect row count, list2", 0L, new TableSelector(list2).getRowCount()); + } + + private JSONArray getTestRows(String val) + { + JSONArray rows = new JSONArray(); + rows.put(Map.of("TextField", val)); + + return rows; + } + } +} From 5f4b688a9dbd16e1b8d9cd31a326dbbc3b23f883 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 7 Oct 2025 12:05:31 -0700 Subject: [PATCH 3/7] CRLF --- .../labkey/api/data/DataRegionSelection.java | 1168 +- api/webapp/clientapi/dom/DataRegion.js | 9996 ++++----- .../query/controllers/QueryController.java | 17544 ++++++++-------- 3 files changed, 14354 insertions(+), 14354 deletions(-) diff --git a/api/src/org/labkey/api/data/DataRegionSelection.java b/api/src/org/labkey/api/data/DataRegionSelection.java index 7495d0af2e6..192ece7a6bd 100644 --- a/api/src/org/labkey/api/data/DataRegionSelection.java +++ b/api/src/org/labkey/api/data/DataRegionSelection.java @@ -1,584 +1,584 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed 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. - */ - -package org.labkey.api.data; - -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.collections.LongArrayList; -import org.labkey.api.collections.ResultSetRowMapFactory; -import org.labkey.api.miniprofiler.MiniProfiler; -import org.labkey.api.miniprofiler.Timing; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QueryView; -import org.labkey.api.util.Formats; -import org.labkey.api.util.Pair; -import org.labkey.api.util.SessionHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.BadRequestException; -import org.labkey.api.view.DataView; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.ViewContext; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; -import java.io.IOException; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -/** - * Manages row selection states, scoped to schema/query and possibly a separate selection key. - * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers - * should do their own synchronization on the set itself if they are operating on it one element at a time - * and want to have a consistent view. This allows for the backing set to be a {@link LinkedHashSet}. - */ -public class DataRegionSelection -{ - public static final String SELECTED_VALUES = ".selectValues"; - public static final String SEPARATOR = "$"; - public static final String DATA_REGION_SELECTION_KEY = "dataRegionSelectionKey"; - - // Issue 53997: Establish a maximum number of selected items allowed for a query. - public static final int MAX_QUERY_SELECTION_SIZE = 100_000; - - // set/updated using query-setSnapshotSelection - // can be used to hold an arbitrary set of selections in session - // example usage: set a filtered set of selected values in session - public static final String SNAPSHOT_SELECTED_VALUES = ".snapshotSelectValues"; - - private static @NotNull String getSessionAttributeKey(@NotNull String path, @NotNull String key, boolean useSnapshot) - { - return path + key + (useSnapshot ? SNAPSHOT_SELECTED_VALUES : SELECTED_VALUES); - } - - private static @NotNull Set getSet(ViewContext context, @Nullable String key, boolean create) - { - return getSet(context, key, create, false); - } - - /** - * * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers - * * should do their own synchronization on the set itself if they are operating on it one element at a time - * * and want to have a consistent view - */ - private static @NotNull Set getSet(ViewContext context, @Nullable String key, boolean create, boolean useSnapshot) - { - if (key == null) - key = getSelectionKeyFromRequest(context); - - if (key != null) - { - key = getSessionAttributeKey(context.getContainer().getPath(), key, useSnapshot); - var request = context.getRequest(); - HttpSession session = request != null ? context.getRequest().getSession(false) : null; - if (session != null) - { - // Ensure that two different requests don't end up creating two different selection sets - // in the same session - synchronized (SessionHelper.getSessionLock(session)) - { - @SuppressWarnings("unchecked") Set result = (Set) session.getAttribute(key); - if (result == null) - { - result = Collections.synchronizedSet(new LinkedHashSet<>()); - - if (create) - session.setAttribute(key, result); - } - return result; - } - } - } - - return Collections.synchronizedSet(new LinkedHashSet<>()); - } - - /** - * Composes a selection key string used to uniquely identify the selected items - * of a given dataregion. Nulls are allowed. - */ - public static String getSelectionKey(String schemaName, String queryName, String viewName, String dataRegionName) - { - StringBuilder buf = new StringBuilder(); - - for (String s : new String[]{schemaName, queryName, viewName, dataRegionName}) - { - buf.append(SEPARATOR); - if (s != null) - buf.append(s); - } - - return buf.toString(); - } - - /** - * Get selected items from the request parameters including both current page's selection and session state - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelected(ViewContext context) - { - return getSelected(context, null, true); - } - - /** - * Get selected items from the request parameters including both current page's selection and session state - * @param context Used to get the selection key - * @param clearSelection Remove the request parameter selected items from session selection state - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelected(ViewContext context, boolean clearSelection) - { - return getSelected(context, null, clearSelection); - } - - /** - * Tests if selected items are in the request parameters or session state - * @param context Used to get the selection key - * @return true if there are selected item ids, false if not - */ - public static boolean hasSelected(ViewContext context) - { - return !getSelected(context, null, false).isEmpty(); - } - - /** - * Get selected items from the request parameters as integers including both current page's selection and session - * state and clears the state - * @param context Used to get the selection key - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelectedIntegers(ViewContext context) - { - return asLongs(getSelected(context, true)); - } - - /** - * Get selected items from the request parameters as integers including both current page's selection and session state - * @param context Used to get the selection key - * @param clearSelection Remove the request parameter selected items from session selection state - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelectedIntegers(ViewContext context, boolean clearSelection) - { - return asLongs(getSelected(context, null, clearSelection)); - } - - @Nullable - public static String getSelectionKeyFromRequest(ViewContext context) - { - HttpServletRequest request = context.getRequest(); - return request == null ? null : request.getParameter(DATA_REGION_SELECTION_KEY); - } - - /** - * Get the selected items from the request parameters (the current page of a data region) and session state. - * @param context Contains the session - * @param key The data region selection key; if null the DATA_REGION_SELECTION_KEY request parameter will be used - * @param clearSession Remove the request parameter selected items from session selection state - * @return an unmodifiable copy of the selected item ids - */ - public static @NotNull Set getSelected(ViewContext context, @Nullable String key, boolean clearSession) - { - String[] values = null; - var request = context.getRequest(); - if (request != null) - values = context.getRequest().getParameterValues(DataRegion.SELECT_CHECKBOX_NAME); - if (null != values && values.length == 1 && values[0].contains("\t")) - values = StringUtils.split(values[0],'\t'); - List parameterSelected = values == null ? new ArrayList<>() : Arrays.asList(values); - Set result = new LinkedHashSet<>(parameterSelected); - - Set sessionSelected = getSet(context, key, false); - synchronized (sessionSelected) - { - result.addAll(sessionSelected); - if (clearSession) - sessionSelected.removeAll(result); - } - - return Collections.unmodifiableSet(result); - } - - /** - * Get the selected items from the request parameters (the current page of a data region) and session state as integers. - */ - public static @NotNull Set getSelectedIntegers(ViewContext context, @Nullable String key, boolean clearSession) - { - return asLongs(getSelected(context, key, clearSession)); - } - - public static @NotNull ArrayList getSnapshotSelected(ViewContext context, @Nullable String key) - { - return new ArrayList<>(getSet(context, key, false, true)); - } - - public static @NotNull ArrayList getSnapshotSelectedIntegers(ViewContext context, @Nullable String key) - { - return new LongArrayList(asLongs(getSnapshotSelected(context, key))); - } - - private static @NotNull Set asLongs(Collection ids) - { - Set result = new LinkedHashSet<>(); - for (String s : ids) - { - try - { - result.add(Long.parseLong(s)); - } - catch (NumberFormatException nfe) - { - throw new BadRequestException("Unable to convert " + s + " to an int", nfe); - } - } - - return result; - } - - public static int setSelected(ViewContext context, String key, Collection selection, boolean checked) - { - return setSelected(context, key, selection, checked, false); - } - - /** - * Sets the checked state for the given ids in the session state. - */ - public static int setSelected(ViewContext context, String key, Collection selection, boolean checked, boolean useSnapshot) - { - if (checked && selection.size() > MAX_QUERY_SELECTION_SIZE) - throw new BadRequestException(selectionTooLargeMessage(selection.size())); - - Set selectedValues = getSet(context, key, true, useSnapshot); - if (checked) - { - // Verify that adding these selections will not result in a set that is too large - if (selectedValues.size() + selection.size() > MAX_QUERY_SELECTION_SIZE) - { - // Do not modify the actual selected values - int current = selectedValues.size(); - int distinctAdds = 0; - - for (String id : selection) - { - if (!selectedValues.contains(id)) - distinctAdds++; - } - - int prospective = current + distinctAdds; - if (prospective > MAX_QUERY_SELECTION_SIZE) - throw new BadRequestException(selectionTooLargeMessage(prospective)); - } - - selectedValues.addAll(selection); - } - else - selectedValues.removeAll(selection); - - return selectedValues.size(); - } - - public static int setSelectedFromForm(QueryForm form) - { - var view = getQueryView(form); - var viewContext = view.getViewContext(); - var selection = getSet(viewContext, form.getQuerySettings().getSelectionKey(), true); - var items = getSelectedItems(view, selection); - - return setSelected(viewContext, form.getQuerySettings().getSelectionKey(), items, false); - } - - private static String selectionTooLargeMessage(long size) - { - return String.format("Too many selected items: %s. Maximum number of selected items allowed is %s.", - Formats.commaf0.format(size), Formats.commaf0.format(MAX_QUERY_SELECTION_SIZE)); - } - - /** - * Clear any session attributes that match the given container path, as the prefix, and the selection key, as the suffix - */ - public static void clearRelatedByContainerPath(ViewContext context, String key) - { - if (key == null || context.getRequest() == null) - return; - - HttpSession session = context.getRequest().getSession(false); - String containerPath = context.getContainer().getPath(); - Collections.list(session.getAttributeNames()).stream() - .filter(name -> name.startsWith(containerPath) && (name.endsWith(key + SNAPSHOT_SELECTED_VALUES) || name.endsWith(key + SELECTED_VALUES))) - .forEach(session::removeAttribute); - } - - private static void clearAll(HttpSession session, String path, String key, boolean isSnapshot) - { - assert path != null : "DataRegion container path required"; - assert key != null : "DataRegion selection key required"; - if (session == null) - return; - session.removeAttribute(getSessionAttributeKey(path, key, isSnapshot)); - } - - /** - * Removes all selection state from the session for RenderContext.getSelectionKey(). - */ - public static void clearAll(RenderContext ctx) - { - clearAll(ctx.getRequest().getSession(false), - ctx.getContainer().getPath(), ctx.getCurrentRegion().getSelectionKey(), false); - } - - /** - * Removes all selection state from the session for the given key. If key is null, the request parameter DATA_REGION_SELECTION_KEY is used. - */ - public static void clearAll(ViewContext context, @Nullable String key) - { - clearAll(context, key, false); - } - - public static void clearAll(ViewContext context, @Nullable String key, boolean isSnapshot) - { - HttpServletRequest request = context.getRequest(); - if (key == null) - key = getSelectionKeyFromRequest(context); - if (key != null && request != null) - clearAll(request.getSession(false), - context.getContainer().getPath(), key, isSnapshot); - } - - /** - * Removes all selection state from the session for the key given by request parameter DATA_REGION_SELECTION_KEY. - */ - public static void clearAll(ViewContext context) - { - clearAll(context, null); - } - - /** - * Gets the ids of the selected items for all items in the given query form's view. That is, - * not just the items on the current page, but all selected items corresponding to the view's filters. - */ - public static Set getSelected(QueryForm form, boolean clearSelected) throws IOException - { - var view = getQueryView(form); - var selection = getSet(view.getViewContext(), form.getQuerySettings().getSelectionKey(), true); - var items = getSelectedItems(view, selection); - - if (clearSelected && !selection.isEmpty()) - { - //noinspection SynchronizationOnLocalVariableOrMethodParameter - synchronized (selection) - { - items.forEach(selection::remove); - } - } - - return items; - } - - private static Pair getDataRegionContext(QueryView view) - { - // Turn off features of QueryView - view.setPrintView(true); - view.setShowConfiguredButtons(false); - view.setShowPagination(false); - view.setShowPaginationCount(false); - view.setShowDetailsColumn(false); - view.setShowUpdateColumn(false); - - TableInfo table = view.getTable(); - if (table == null) - { - throw new NotFoundException("Could not find table"); - } - - DataView v = view.createDataView(); - DataRegion rgn = v.getDataRegion(); - - // Include all rows. If only selected rows are included, it does not - // respect filters. - view.getSettings().setShowRows(ShowRows.ALL); - view.getSettings().setOffset(Table.NO_OFFSET); - - RenderContext rc = v.getRenderContext(); - rc.setViewContext(view.getViewContext()); - rc.setCache(false); - - setDataRegionColumnsForSelection(rgn, rc, view, table); - - return Pair.of(rgn, rc); - } - - private static @NotNull QueryView getQueryView(QueryForm form) throws NotFoundException - { - var schema = form.getSchema(); - if (schema == null) - throw new NotFoundException(); - return schema.createView(form, null); - } - - public static Set getValidatedIds(@NotNull Collection selection, QueryForm form) - { - return getSelectedItems(getQueryView(form), selection); - } - - /** - * Sets the selection for all items in the given query form's view - */ - public static int setSelectionForAll(QueryForm form, boolean checked) throws IOException - { - return setSelectionForAll(getQueryView(form), form.getQuerySettings().getSelectionKey(), checked); - } - - private static void setDataRegionColumnsForSelection(DataRegion rgn, RenderContext rc, QueryView view, TableInfo table) - { - // force the pk column(s) into the default list of columns - List selectorColNames = rgn.getRecordSelectorValueColumns(); - if (selectorColNames == null) - selectorColNames = table.getPkColumnNames(); - List selectorColumns = new ArrayList<>(); - for (String colName : selectorColNames) - { - if (null == rgn.getDisplayColumn(colName)) { - selectorColumns.add(table.getColumn(colName)); - } - } - ActionURL url = view.getSettings().getSortFilterURL(); - - Sort sort = rc.buildSort(table, url, rgn.getName()); - SimpleFilter filter = rc.buildFilter(table, rc.getColumnInfos(rgn.getDisplayColumns()), url, rgn.getName(), Table.ALL_ROWS, 0, sort); - - // Issue 36600: remove unnecessary columns for performance purposes - rgn.clearColumns(); - // Issue 39011: then add back the columns needed by the filters, if any - Collection filterColumns = QueryService.get().ensureRequiredColumns(table, selectorColumns, filter, sort, null); - rgn.addColumns(selectorColumns); - rgn.addColumns(filterColumns); - } - - public static int setSelectionForAll(QueryView view, String key, boolean checked) throws IOException - { - var regionCtx = getDataRegionContext(view); - var rgn = regionCtx.first; - var rc = regionCtx.second; - - try (Timing ignored = MiniProfiler.step("selectAll"); ResultSet rs = rgn.getResults(rc)) - { - var selection = createSelectionSet(rc, rgn, rs, null); - return setSelected(view.getViewContext(), key, selection, checked); - } - catch (SQLException e) - { - throw new RuntimeSQLException(e); - } - } - - /** - * Returns all items in the given result set that are selected and selectable - * @param view the view from which to retrieve the data region context and session variable - * @param selectedValues optionally (nullable) specify a collection of selected values that will be matched - * against when selecting items. If null, then all items will be returned. - * @return Set of items from the result set that are in the selected session, or an empty list if none. - */ - private static Set getSelectedItems(QueryView view, @NotNull Collection selectedValues) - { - // Issue 48657: no need to query the region result set if we have no selectedValues - if (selectedValues.isEmpty()) - return new LinkedHashSet<>(); - - var dataRegionContext = getDataRegionContext(view); - var rgn = dataRegionContext.first; - var ctx = dataRegionContext.second; - - // Issue 48657: no need to query for all region results if we are only interested in a subset, filter for just those we want to verify - // Note: this only currently applies for tables with a single PK col. Consider altering this for multi-pk tables. - List pkCols = rgn.getTable().getPkColumns(); - if (pkCols.size() == 1) - { - ColumnInfo pkCol = pkCols.get(0); - ctx.setBaseFilter(new SimpleFilter(pkCol.getFieldKey(), pkCol.isNumericType() ? selectedValues.stream().map(Integer::parseInt).toList() : selectedValues, CompareType.IN)); - } - - try (Timing ignored = MiniProfiler.step("getSelected"); Results rs = rgn.getResults(ctx)) - { - //noinspection SynchronizationOnLocalVariableOrMethodParameter - synchronized (selectedValues) - { - return createSelectionSet(ctx, rgn, rs, selectedValues); - } - } - catch (SQLException e) - { - throw new RuntimeSQLException(e); - } - } - - private static Set createSelectionSet( - RenderContext ctx, - DataRegion rgn, - ResultSet rs, - @Nullable Collection selectedValues - ) throws SQLException - { - Set selected = new LinkedHashSet<>(); - - if (rs != null) - { - ResultSetRowMapFactory factory = ResultSetRowMapFactory.create(rs); - while (rs.next()) - { - ctx.setRow(factory.getRowMap(rs)); - - // Issue 35513: Don't select un-selectables - if (rgn.isRecordSelectorEnabled(ctx)) - { - var value = rgn.getRecordSelectorValue(ctx); - if (selectedValues == null || selectedValues.contains(value)) - { - selected.add(value); - if (selected.size() == MAX_QUERY_SELECTION_SIZE) - break; - } - } - } - } - - return selected; - } - - /** Response used from SelectAll, ClearAll, and similar APIs for bulk selecting/unselecting data rows */ - public static class SelectionResponse extends ApiSimpleResponse - { - public SelectionResponse(int count) - { - super("count", count); - } - } - - public interface DataSelectionKeyForm - { - String getDataRegionSelectionKey(); - void setDataRegionSelectionKey(String key); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed 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. + */ + +package org.labkey.api.data; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.collections.LongArrayList; +import org.labkey.api.collections.ResultSetRowMapFactory; +import org.labkey.api.miniprofiler.MiniProfiler; +import org.labkey.api.miniprofiler.Timing; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QueryView; +import org.labkey.api.util.Formats; +import org.labkey.api.util.Pair; +import org.labkey.api.util.SessionHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.BadRequestException; +import org.labkey.api.view.DataView; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.ViewContext; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.io.IOException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Manages row selection states, scoped to schema/query and possibly a separate selection key. + * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers + * should do their own synchronization on the set itself if they are operating on it one element at a time + * and want to have a consistent view. This allows for the backing set to be a {@link LinkedHashSet}. + */ +public class DataRegionSelection +{ + public static final String SELECTED_VALUES = ".selectValues"; + public static final String SEPARATOR = "$"; + public static final String DATA_REGION_SELECTION_KEY = "dataRegionSelectionKey"; + + // Issue 53997: Establish a maximum number of selected items allowed for a query. + public static final int MAX_QUERY_SELECTION_SIZE = 100_000; + + // set/updated using query-setSnapshotSelection + // can be used to hold an arbitrary set of selections in session + // example usage: set a filtered set of selected values in session + public static final String SNAPSHOT_SELECTED_VALUES = ".snapshotSelectValues"; + + private static @NotNull String getSessionAttributeKey(@NotNull String path, @NotNull String key, boolean useSnapshot) + { + return path + key + (useSnapshot ? SNAPSHOT_SELECTED_VALUES : SELECTED_VALUES); + } + + private static @NotNull Set getSet(ViewContext context, @Nullable String key, boolean create) + { + return getSet(context, key, create, false); + } + + /** + * * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers + * * should do their own synchronization on the set itself if they are operating on it one element at a time + * * and want to have a consistent view + */ + private static @NotNull Set getSet(ViewContext context, @Nullable String key, boolean create, boolean useSnapshot) + { + if (key == null) + key = getSelectionKeyFromRequest(context); + + if (key != null) + { + key = getSessionAttributeKey(context.getContainer().getPath(), key, useSnapshot); + var request = context.getRequest(); + HttpSession session = request != null ? context.getRequest().getSession(false) : null; + if (session != null) + { + // Ensure that two different requests don't end up creating two different selection sets + // in the same session + synchronized (SessionHelper.getSessionLock(session)) + { + @SuppressWarnings("unchecked") Set result = (Set) session.getAttribute(key); + if (result == null) + { + result = Collections.synchronizedSet(new LinkedHashSet<>()); + + if (create) + session.setAttribute(key, result); + } + return result; + } + } + } + + return Collections.synchronizedSet(new LinkedHashSet<>()); + } + + /** + * Composes a selection key string used to uniquely identify the selected items + * of a given dataregion. Nulls are allowed. + */ + public static String getSelectionKey(String schemaName, String queryName, String viewName, String dataRegionName) + { + StringBuilder buf = new StringBuilder(); + + for (String s : new String[]{schemaName, queryName, viewName, dataRegionName}) + { + buf.append(SEPARATOR); + if (s != null) + buf.append(s); + } + + return buf.toString(); + } + + /** + * Get selected items from the request parameters including both current page's selection and session state + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelected(ViewContext context) + { + return getSelected(context, null, true); + } + + /** + * Get selected items from the request parameters including both current page's selection and session state + * @param context Used to get the selection key + * @param clearSelection Remove the request parameter selected items from session selection state + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelected(ViewContext context, boolean clearSelection) + { + return getSelected(context, null, clearSelection); + } + + /** + * Tests if selected items are in the request parameters or session state + * @param context Used to get the selection key + * @return true if there are selected item ids, false if not + */ + public static boolean hasSelected(ViewContext context) + { + return !getSelected(context, null, false).isEmpty(); + } + + /** + * Get selected items from the request parameters as integers including both current page's selection and session + * state and clears the state + * @param context Used to get the selection key + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelectedIntegers(ViewContext context) + { + return asLongs(getSelected(context, true)); + } + + /** + * Get selected items from the request parameters as integers including both current page's selection and session state + * @param context Used to get the selection key + * @param clearSelection Remove the request parameter selected items from session selection state + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelectedIntegers(ViewContext context, boolean clearSelection) + { + return asLongs(getSelected(context, null, clearSelection)); + } + + @Nullable + public static String getSelectionKeyFromRequest(ViewContext context) + { + HttpServletRequest request = context.getRequest(); + return request == null ? null : request.getParameter(DATA_REGION_SELECTION_KEY); + } + + /** + * Get the selected items from the request parameters (the current page of a data region) and session state. + * @param context Contains the session + * @param key The data region selection key; if null the DATA_REGION_SELECTION_KEY request parameter will be used + * @param clearSession Remove the request parameter selected items from session selection state + * @return an unmodifiable copy of the selected item ids + */ + public static @NotNull Set getSelected(ViewContext context, @Nullable String key, boolean clearSession) + { + String[] values = null; + var request = context.getRequest(); + if (request != null) + values = context.getRequest().getParameterValues(DataRegion.SELECT_CHECKBOX_NAME); + if (null != values && values.length == 1 && values[0].contains("\t")) + values = StringUtils.split(values[0],'\t'); + List parameterSelected = values == null ? new ArrayList<>() : Arrays.asList(values); + Set result = new LinkedHashSet<>(parameterSelected); + + Set sessionSelected = getSet(context, key, false); + synchronized (sessionSelected) + { + result.addAll(sessionSelected); + if (clearSession) + sessionSelected.removeAll(result); + } + + return Collections.unmodifiableSet(result); + } + + /** + * Get the selected items from the request parameters (the current page of a data region) and session state as integers. + */ + public static @NotNull Set getSelectedIntegers(ViewContext context, @Nullable String key, boolean clearSession) + { + return asLongs(getSelected(context, key, clearSession)); + } + + public static @NotNull ArrayList getSnapshotSelected(ViewContext context, @Nullable String key) + { + return new ArrayList<>(getSet(context, key, false, true)); + } + + public static @NotNull ArrayList getSnapshotSelectedIntegers(ViewContext context, @Nullable String key) + { + return new LongArrayList(asLongs(getSnapshotSelected(context, key))); + } + + private static @NotNull Set asLongs(Collection ids) + { + Set result = new LinkedHashSet<>(); + for (String s : ids) + { + try + { + result.add(Long.parseLong(s)); + } + catch (NumberFormatException nfe) + { + throw new BadRequestException("Unable to convert " + s + " to an int", nfe); + } + } + + return result; + } + + public static int setSelected(ViewContext context, String key, Collection selection, boolean checked) + { + return setSelected(context, key, selection, checked, false); + } + + /** + * Sets the checked state for the given ids in the session state. + */ + public static int setSelected(ViewContext context, String key, Collection selection, boolean checked, boolean useSnapshot) + { + if (checked && selection.size() > MAX_QUERY_SELECTION_SIZE) + throw new BadRequestException(selectionTooLargeMessage(selection.size())); + + Set selectedValues = getSet(context, key, true, useSnapshot); + if (checked) + { + // Verify that adding these selections will not result in a set that is too large + if (selectedValues.size() + selection.size() > MAX_QUERY_SELECTION_SIZE) + { + // Do not modify the actual selected values + int current = selectedValues.size(); + int distinctAdds = 0; + + for (String id : selection) + { + if (!selectedValues.contains(id)) + distinctAdds++; + } + + int prospective = current + distinctAdds; + if (prospective > MAX_QUERY_SELECTION_SIZE) + throw new BadRequestException(selectionTooLargeMessage(prospective)); + } + + selectedValues.addAll(selection); + } + else + selectedValues.removeAll(selection); + + return selectedValues.size(); + } + + public static int setSelectedFromForm(QueryForm form) + { + var view = getQueryView(form); + var viewContext = view.getViewContext(); + var selection = getSet(viewContext, form.getQuerySettings().getSelectionKey(), true); + var items = getSelectedItems(view, selection); + + return setSelected(viewContext, form.getQuerySettings().getSelectionKey(), items, false); + } + + private static String selectionTooLargeMessage(long size) + { + return String.format("Too many selected items: %s. Maximum number of selected items allowed is %s.", + Formats.commaf0.format(size), Formats.commaf0.format(MAX_QUERY_SELECTION_SIZE)); + } + + /** + * Clear any session attributes that match the given container path, as the prefix, and the selection key, as the suffix + */ + public static void clearRelatedByContainerPath(ViewContext context, String key) + { + if (key == null || context.getRequest() == null) + return; + + HttpSession session = context.getRequest().getSession(false); + String containerPath = context.getContainer().getPath(); + Collections.list(session.getAttributeNames()).stream() + .filter(name -> name.startsWith(containerPath) && (name.endsWith(key + SNAPSHOT_SELECTED_VALUES) || name.endsWith(key + SELECTED_VALUES))) + .forEach(session::removeAttribute); + } + + private static void clearAll(HttpSession session, String path, String key, boolean isSnapshot) + { + assert path != null : "DataRegion container path required"; + assert key != null : "DataRegion selection key required"; + if (session == null) + return; + session.removeAttribute(getSessionAttributeKey(path, key, isSnapshot)); + } + + /** + * Removes all selection state from the session for RenderContext.getSelectionKey(). + */ + public static void clearAll(RenderContext ctx) + { + clearAll(ctx.getRequest().getSession(false), + ctx.getContainer().getPath(), ctx.getCurrentRegion().getSelectionKey(), false); + } + + /** + * Removes all selection state from the session for the given key. If key is null, the request parameter DATA_REGION_SELECTION_KEY is used. + */ + public static void clearAll(ViewContext context, @Nullable String key) + { + clearAll(context, key, false); + } + + public static void clearAll(ViewContext context, @Nullable String key, boolean isSnapshot) + { + HttpServletRequest request = context.getRequest(); + if (key == null) + key = getSelectionKeyFromRequest(context); + if (key != null && request != null) + clearAll(request.getSession(false), + context.getContainer().getPath(), key, isSnapshot); + } + + /** + * Removes all selection state from the session for the key given by request parameter DATA_REGION_SELECTION_KEY. + */ + public static void clearAll(ViewContext context) + { + clearAll(context, null); + } + + /** + * Gets the ids of the selected items for all items in the given query form's view. That is, + * not just the items on the current page, but all selected items corresponding to the view's filters. + */ + public static Set getSelected(QueryForm form, boolean clearSelected) throws IOException + { + var view = getQueryView(form); + var selection = getSet(view.getViewContext(), form.getQuerySettings().getSelectionKey(), true); + var items = getSelectedItems(view, selection); + + if (clearSelected && !selection.isEmpty()) + { + //noinspection SynchronizationOnLocalVariableOrMethodParameter + synchronized (selection) + { + items.forEach(selection::remove); + } + } + + return items; + } + + private static Pair getDataRegionContext(QueryView view) + { + // Turn off features of QueryView + view.setPrintView(true); + view.setShowConfiguredButtons(false); + view.setShowPagination(false); + view.setShowPaginationCount(false); + view.setShowDetailsColumn(false); + view.setShowUpdateColumn(false); + + TableInfo table = view.getTable(); + if (table == null) + { + throw new NotFoundException("Could not find table"); + } + + DataView v = view.createDataView(); + DataRegion rgn = v.getDataRegion(); + + // Include all rows. If only selected rows are included, it does not + // respect filters. + view.getSettings().setShowRows(ShowRows.ALL); + view.getSettings().setOffset(Table.NO_OFFSET); + + RenderContext rc = v.getRenderContext(); + rc.setViewContext(view.getViewContext()); + rc.setCache(false); + + setDataRegionColumnsForSelection(rgn, rc, view, table); + + return Pair.of(rgn, rc); + } + + private static @NotNull QueryView getQueryView(QueryForm form) throws NotFoundException + { + var schema = form.getSchema(); + if (schema == null) + throw new NotFoundException(); + return schema.createView(form, null); + } + + public static Set getValidatedIds(@NotNull Collection selection, QueryForm form) + { + return getSelectedItems(getQueryView(form), selection); + } + + /** + * Sets the selection for all items in the given query form's view + */ + public static int setSelectionForAll(QueryForm form, boolean checked) throws IOException + { + return setSelectionForAll(getQueryView(form), form.getQuerySettings().getSelectionKey(), checked); + } + + private static void setDataRegionColumnsForSelection(DataRegion rgn, RenderContext rc, QueryView view, TableInfo table) + { + // force the pk column(s) into the default list of columns + List selectorColNames = rgn.getRecordSelectorValueColumns(); + if (selectorColNames == null) + selectorColNames = table.getPkColumnNames(); + List selectorColumns = new ArrayList<>(); + for (String colName : selectorColNames) + { + if (null == rgn.getDisplayColumn(colName)) { + selectorColumns.add(table.getColumn(colName)); + } + } + ActionURL url = view.getSettings().getSortFilterURL(); + + Sort sort = rc.buildSort(table, url, rgn.getName()); + SimpleFilter filter = rc.buildFilter(table, rc.getColumnInfos(rgn.getDisplayColumns()), url, rgn.getName(), Table.ALL_ROWS, 0, sort); + + // Issue 36600: remove unnecessary columns for performance purposes + rgn.clearColumns(); + // Issue 39011: then add back the columns needed by the filters, if any + Collection filterColumns = QueryService.get().ensureRequiredColumns(table, selectorColumns, filter, sort, null); + rgn.addColumns(selectorColumns); + rgn.addColumns(filterColumns); + } + + public static int setSelectionForAll(QueryView view, String key, boolean checked) throws IOException + { + var regionCtx = getDataRegionContext(view); + var rgn = regionCtx.first; + var rc = regionCtx.second; + + try (Timing ignored = MiniProfiler.step("selectAll"); ResultSet rs = rgn.getResults(rc)) + { + var selection = createSelectionSet(rc, rgn, rs, null); + return setSelected(view.getViewContext(), key, selection, checked); + } + catch (SQLException e) + { + throw new RuntimeSQLException(e); + } + } + + /** + * Returns all items in the given result set that are selected and selectable + * @param view the view from which to retrieve the data region context and session variable + * @param selectedValues optionally (nullable) specify a collection of selected values that will be matched + * against when selecting items. If null, then all items will be returned. + * @return Set of items from the result set that are in the selected session, or an empty list if none. + */ + private static Set getSelectedItems(QueryView view, @NotNull Collection selectedValues) + { + // Issue 48657: no need to query the region result set if we have no selectedValues + if (selectedValues.isEmpty()) + return new LinkedHashSet<>(); + + var dataRegionContext = getDataRegionContext(view); + var rgn = dataRegionContext.first; + var ctx = dataRegionContext.second; + + // Issue 48657: no need to query for all region results if we are only interested in a subset, filter for just those we want to verify + // Note: this only currently applies for tables with a single PK col. Consider altering this for multi-pk tables. + List pkCols = rgn.getTable().getPkColumns(); + if (pkCols.size() == 1) + { + ColumnInfo pkCol = pkCols.get(0); + ctx.setBaseFilter(new SimpleFilter(pkCol.getFieldKey(), pkCol.isNumericType() ? selectedValues.stream().map(Integer::parseInt).toList() : selectedValues, CompareType.IN)); + } + + try (Timing ignored = MiniProfiler.step("getSelected"); Results rs = rgn.getResults(ctx)) + { + //noinspection SynchronizationOnLocalVariableOrMethodParameter + synchronized (selectedValues) + { + return createSelectionSet(ctx, rgn, rs, selectedValues); + } + } + catch (SQLException e) + { + throw new RuntimeSQLException(e); + } + } + + private static Set createSelectionSet( + RenderContext ctx, + DataRegion rgn, + ResultSet rs, + @Nullable Collection selectedValues + ) throws SQLException + { + Set selected = new LinkedHashSet<>(); + + if (rs != null) + { + ResultSetRowMapFactory factory = ResultSetRowMapFactory.create(rs); + while (rs.next()) + { + ctx.setRow(factory.getRowMap(rs)); + + // Issue 35513: Don't select un-selectables + if (rgn.isRecordSelectorEnabled(ctx)) + { + var value = rgn.getRecordSelectorValue(ctx); + if (selectedValues == null || selectedValues.contains(value)) + { + selected.add(value); + if (selected.size() == MAX_QUERY_SELECTION_SIZE) + break; + } + } + } + } + + return selected; + } + + /** Response used from SelectAll, ClearAll, and similar APIs for bulk selecting/unselecting data rows */ + public static class SelectionResponse extends ApiSimpleResponse + { + public SelectionResponse(int count) + { + super("count", count); + } + } + + public interface DataSelectionKeyForm + { + String getDataRegionSelectionKey(); + void setDataRegionSelectionKey(String key); + } +} diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index 76f7145dacf..f8ce7b85b86 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -1,4998 +1,4998 @@ -/* - * Copyright (c) 2015-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -if (!LABKEY.DataRegions) { - LABKEY.DataRegions = {}; -} - -(function($) { - - // - // CONSTANTS - // - // Issue 48715: Limit the number of rows that can be displayed in a data region - var ALL_ROWS_MAX = 5_000; - var CUSTOM_VIEW_PANELID = '~~customizeView~~'; - var DEFAULT_TIMEOUT = 30_000; - const MAX_SELECTION_SIZE = 1_000; - var PARAM_PREFIX = '.param.'; - var SORT_ASC = '+'; - var SORT_DESC = '-'; - - // - // URL PREFIXES - // - var ALL_FILTERS_SKIP_PREFIX = '.~'; - var COLUMNS_PREFIX = '.columns'; - var CONTAINER_FILTER_NAME = '.containerFilterName'; - var MAX_ROWS_PREFIX = '.maxRows'; - var OFFSET_PREFIX = '.offset'; - var REPORTID_PREFIX = '.reportId'; - var SORT_PREFIX = '.sort'; - var SHOW_ROWS_PREFIX = '.showRows'; - var VIEWNAME_PREFIX = '.viewName'; - - // Issue 33536: These prefixes should match the URL parameter key exactly - var EXACT_MATCH_PREFIXES = [ - COLUMNS_PREFIX, - CONTAINER_FILTER_NAME, - MAX_ROWS_PREFIX, - OFFSET_PREFIX, - REPORTID_PREFIX, - SORT_PREFIX, - SHOW_ROWS_PREFIX, - VIEWNAME_PREFIX - ]; - - var VALID_LISTENERS = [ - /** - * @memberOf LABKEY.DataRegion.prototype - * @name afterpanelhide - * @event LABKEY.DataRegion.prototype#hidePanel - * @description Fires after hiding a visible 'Customize Grid' panel. - */ - 'afterpanelhide', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name afterpanelshow - * @event LABKEY.DataRegion.prototype.showPanel - * @description Fires after showing 'Customize Grid' panel. - */ - 'afterpanelshow', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforechangeview - * @event - * @description Fires before changing grid/view/report. - * @see LABKEY.DataRegion#changeView - */ - 'beforechangeview', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforeclearsort - * @event - * @description Fires before clearing sort applied to grid. - * @see LABKEY.DataRegion#clearSort - */ - 'beforeclearsort', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforemaxrowschange - * @event - * @description Fires before change page size. - * @see LABKEY.DataRegion#setMaxRows - */ - 'beforemaxrowschange', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforeoffsetchange - * @event - * @description Fires before change page number. - * @see LABKEY.DataRegion#setPageOffset - */ - 'beforeoffsetchange', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforerefresh - * @event - * @description Fires before refresh grid. - * @see LABKEY.DataRegion#refresh - */ - 'beforerefresh', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforesetparameters - * @event - * @description Fires before setting the parameterized query values for this query. - * @see LABKEY.DataRegion#setParameters - */ - 'beforesetparameters', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name beforesortchange - * @event - * @description Fires before change sorting on the grid. - * @see LABKEY.DataRegion#changeSort - */ - 'beforesortchange', - /** - * @memberOf LABKEY.DataRegion.prototype - * @member - * @name render - * @event - * @description Fires when data region renders. - */ - 'render', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name selectchange - * @event - * @description Fires when data region selection changes. - */ - 'selectchange', - /** - * @memberOf LABKEY.DataRegion.prototype - * @name success - * @event - * @description Fires when data region loads successfully. - */ - 'success']; - - // TODO: Update constants to not include '.' so mapping can be used easier - var REQUIRE_NAME_PREFIX = { - '~': true, - 'columns': true, - 'param': true, - 'reportId': true, - 'sort': true, - 'offset': true, - 'maxRows': true, - 'showRows': true, - 'containerFilterName': true, - 'viewName': true, - 'disableAnalytics': true - }; - - // - // PRIVATE VARIABLES - // - var _paneCache = {}; - - /** - * The DataRegion constructor is private - to get a LABKEY.DataRegion object, use LABKEY.DataRegions['dataregionname']. - * @class LABKEY.DataRegion - * The DataRegion class allows you to interact with LabKey grids, including querying and modifying selection state, filters, and more. - * @constructor - */ - LABKEY.DataRegion = function(config) { - _init.call(this, config, true); - }; - - LABKEY.DataRegion.prototype.toJSON = function() { - return { - name: this.name, - schemaName: this.schemaName, - queryName: this.queryName, - viewName: this.viewName, - offset: this.offset, - maxRows: this.maxRows, - messages: this.msgbox.toJSON() // hmm, unsure exactly how this works - }; - }; - - /** - * - * @param {Object} config - * @param {Boolean} [applyDefaults=false] - * @private - */ - var _init = function(config, applyDefaults) { - - // ensure name - if (!config.dataRegionName) { - if (!config.name) { - this.name = LABKEY.Utils.id('aqwp'); - } - else { - this.name = config.name; - } - } - else if (!config.name) { - this.name = config.dataRegionName; - } - else { - this.name = config.name; - } - - if (!this.name) { - throw '"name" is required to initialize a LABKEY.DataRegion'; - } - - // _useQWPDefaults is only used on initial construction - var isQWP = config._useQWPDefaults === true; - delete config._useQWPDefaults; - - if (config.buttonBar && config.buttonBar.items && LABKEY.Utils.isArray(config.buttonBar.items)) { - // Be tolerant of the caller passing in undefined items, as pageSize has been removed as an option. Strip - // them out so they don't cause problems downstream. See Issue 34562. - config.buttonBar.items = config.buttonBar.items.filter(function (value, index, arr) { - return value; - }); - } - - var settings; - - if (applyDefaults) { - - // defensively remove, not allowed to be set - delete config._userSort; - - /** - * Config Options - */ - var defaults = { - - _allowHeaderLock: isQWP, - - _failure: isQWP ? LABKEY.Utils.getOnFailure(config) : undefined, - - _success: isQWP ? LABKEY.Utils.getOnSuccess(config) : undefined, - - aggregates: undefined, - - allowChooseQuery: undefined, - - allowChooseView: undefined, - - async: isQWP, - - bodyClass: undefined, - - buttonBar: undefined, - - buttonBarPosition: undefined, - - chartWizardURL: undefined, - - /** - * All rows visible on the current page. - */ - complete: false, - - /** - * The currently applied container filter. Note, this is only if it is set on the URL, otherwise - * the containerFilter could come from the view configuration. Use getContainerFilter() - * on this object to get the right value. - */ - containerFilter: undefined, - - containerPath: undefined, - - /** - * @deprecated use region.name instead - */ - dataRegionName: this.name, - - detailsURL: undefined, - - domId: undefined, - - /** - * The faceted filter pane as been loaded - * @private - */ - facetLoaded: false, - - filters: undefined, - - frame: isQWP ? undefined : 'none', - - errorType: 'html', - - /** - * Id of the DataRegion. Same as name property. - */ - id: this.name, - - deleteURL: undefined, - - importURL: undefined, - - insertURL: undefined, - - linkTarget: undefined, - - /** - * Maximum number of rows to be displayed. 0 if the count is not limited. Read-only. - */ - maxRows: 0, - - metadata: undefined, - - /** - * Name of the DataRegion. Should be unique within a given page. Read-only. This will also be used as the id. - */ - name: this.name, - - /** - * The index of the first row to return from the server (defaults to 0). Use this along with the maxRows config property to request pages of data. - */ - offset: 0, - - parameters: undefined, - - /** - * Name of the query to which this DataRegion is bound. Read-only. - */ - queryName: '', - - disableAnalytics: false, - - removeableContainerFilter: undefined, - - removeableFilters: undefined, - - removeableSort: undefined, - - renderTo: undefined, - - reportId: undefined, - - requestURL: isQWP ? window.location.href : (document.location.search.substring(1) /* strip the ? */ || ''), - - returnUrl: isQWP ? window.location.href : undefined, - - /** - * Schema name of the query to which this DataRegion is bound. Read-only. - */ - schemaName: '', - - /** - * An object to use as the callback function's scope. Defaults to this. - */ - scope: this, - - /** - * URL to use when selecting all rows in the grid. May be null. Read-only. - */ - selectAllURL: undefined, - - selectedCount: 0, - - shadeAlternatingRows: undefined, - - showBorders: undefined, - - showDeleteButton: undefined, - - showDetailsColumn: undefined, - - showExportButtons: undefined, - - showRStudioButton: undefined, - - showImportDataButton: undefined, - - showInsertNewButton: undefined, - - showPagination: undefined, - - showPaginationCount: undefined, - - showPaginationCountAsync: false, - - showRecordSelectors: false, - - showFilterDescription: true, - - showReports: undefined, - - /** - * An enum declaring which set of rows to show. all | selected | unselected | paginated - */ - showRows: 'paginated', - - showSurroundingBorder: undefined, - - showUpdateColumn: undefined, - - /** - * Open the customize view panel after rendering. The value of this option can be "true" or one of "ColumnsTab", "FilterTab", or "SortTab". - */ - showViewPanel: undefined, - - sort: undefined, - - sql: undefined, - - /** - * If true, no alert will appear if there is a problem rendering the QueryWebpart. This is most often encountered if page configuration changes between the time when a request was made and the content loads. Defaults to false. - */ - suppressRenderErrors: false, - - /** - * A timeout for the AJAX call, in milliseconds. - */ - timeout: undefined, - - title: undefined, - - titleHref: undefined, - - totalRows: undefined, // totalRows isn't available when showing all rows. - - updateURL: undefined, - - userContainerFilter: undefined, // TODO: Incorporate this with the standard containerFilter - - userFilters: {}, - - /** - * Name of the custom view to which this DataRegion is bound, may be blank. Read-only. - */ - viewName: null - }; - - settings = $.extend({}, defaults, config); - } - else { - settings = $.extend({}, config); - } - - // if showPaginationCountAsync is set to true, make sure that showPaginationCount is false - if (settings.showPaginationCountAsync && settings.showPaginationCount) { - settings.showPaginationCount = false; - } - - // if 'filters' is not specified and 'filterArray' is, use 'filterArray' - if (!LABKEY.Utils.isArray(settings.filters) && LABKEY.Utils.isArray(config.filterArray)) { - settings.filters = config.filterArray; - } - - // Any 'key' of this object will not be copied from settings to the region instance - var blackList = { - failure: true, - success: true - }; - - for (var s in settings) { - if (settings.hasOwnProperty(s) && !blackList[s]) { - this[s] = settings[s]; - } - } - - if (config.renderTo) { - _convertRenderTo(this, config.renderTo); - } - - if (LABKEY.Utils.isArray(this.removeableFilters)) { - LABKEY.Filter.appendFilterParams(this.userFilters, this.removeableFilters, this.name); - delete this.removeableFilters; // they've been applied - } - - // initialize sorting - if (this._userSort === undefined) { - this._userSort = _getUserSort(this, true /* asString */); - } - - if (LABKEY.Utils.isString(this.removeableSort)) { - this._userSort = this.removeableSort + (this._userSort ? this._userSort : ''); - delete this.removeableSort; - } - - this._allowHeaderLock = this.allowHeaderLock === true; - - if (!config.messages) { - this.messages = {}; - } - - /** - * @ignore - * Non-configurable Options - */ - this.selectionModified = false; - - if (this.panelConfigurations === undefined) { - this.panelConfigurations = {}; - } - - if (isQWP && this.renderTo) { - _load(this); - } - else if (!isQWP) { - _initContexts.call(this); - _initMessaging.call(this); - _initSelection.call(this); - _initPaging.call(this); - _initHeaderLocking.call(this); - _initCustomViews.call(this); - _initPanes.call(this); - _initReport.call(this); - } - // else the user needs to call render - - // bind supported listeners - if (isQWP) { - var me = this; - if (config.listeners) { - var scope = config.listeners.scope || me; - $.each(config.listeners, function(event, handler) { - if ($.inArray(event, VALID_LISTENERS) > -1) { - - // support either "event: function" or "event: { fn: function }" - var callback; - if ($.isFunction(handler)) { - callback = handler; - } - else if ($.isFunction(handler.fn)) { - callback = handler.fn; - } - else { - throw 'Unsupported listener configuration: ' + event; - } - - $(me).bind(event, function() { - callback.apply(scope, $(arguments).slice(1)); - }); - } - else if (event != 'scope') { - throw 'Unsupported listener: ' + event; - } - }); - } - } - }; - - LABKEY.DataRegion.prototype.destroy = function() { - // clean-up panel configurations because we preserve this in init - this.panelConfigurations = {}; - - // currently a no-op, but should be used to clean-up after ourselves - this.disableHeaderLock(); - }; - - /** - * Refreshes the grid, via AJAX region is in async mode (loaded through a QueryWebPart), - * and via a page reload otherwise. Can be prevented with a listener - * on the 'beforerefresh' - * event. - */ - LABKEY.DataRegion.prototype.refresh = function() { - $(this).trigger('beforerefresh', this); - - if (this.async) { - _load(this); - } - else { - window.location.reload(); - } - }; - - // - // Filtering - // - - /** - * Add a filter to this Data Region. - * @param {LABKEY.Filter} filter - * @see LABKEY.DataRegion.addFilter static method. - */ - LABKEY.DataRegion.prototype.addFilter = function(filter) { - this.clearSelected({quiet: true}); - _updateFilter(this, filter); - }; - - /** - * Removes all filters from the DataRegion - */ - LABKEY.DataRegion.prototype.clearAllFilters = function() { - this.clearSelected({quiet: true}); - if (this.async) { - this.offset = 0; - this.userFilters = {}; - } - - _removeParameters(this, [ALL_FILTERS_SKIP_PREFIX, OFFSET_PREFIX]); - }; - - /** - * Removes all the filters for a particular field - * @param {string|FieldKey} fieldKey the name of the field from which all filters should be removed - */ - LABKEY.DataRegion.prototype.clearFilter = function(fieldKey) { - this.clearSelected({quiet: true}); - var fk = _resolveFieldKey(this, fieldKey); - - if (fk) { - var columnPrefix = '.' + fk.toString() + '~'; - - if (this.async) { - this.offset = 0; - - if (this.userFilters) { - var namePrefix = this.name + columnPrefix, - me = this; - - $.each(this.userFilters, function(name, v) { - if (name.indexOf(namePrefix) >= 0) { - delete me.userFilters[name]; - } - }); - } - } - - _removeParameters(this, [columnPrefix, OFFSET_PREFIX]); - } - }; - - /** - * Returns an Array of LABKEY.Filter instances applied when creating this DataRegion. These cannot be removed through the UI. - * @returns {Array} Array of {@link LABKEY.Filter} objects that represent currently applied base filters. - */ - LABKEY.DataRegion.prototype.getBaseFilters = function() { - if (this.filters) { - return this.filters.slice(); - } - - return []; - }; - - /** - * Returns the {@link LABKEY.Query.containerFilter} currently applied to the DataRegion. Defaults to LABKEY.Query.containerFilter.current. - * @returns {String} The container filter currently applied to this DataRegion. Defaults to 'undefined' if a container filter is not specified by the configuration. - * @see LABKEY.DataRegion#getUserContainerFilter to get the containerFilter value from the URL. - */ - LABKEY.DataRegion.prototype.getContainerFilter = function() { - var cf; - - if (LABKEY.Utils.isString(this.containerFilter) && this.containerFilter.length > 0) { - cf = this.containerFilter; - } - else if (LABKEY.Utils.isObject(this.view) && LABKEY.Utils.isString(this.view.containerFilter) && this.view.containerFilter.length > 0) { - cf = this.view.containerFilter; - } - - return cf; - }; - - LABKEY.DataRegion.prototype.getDataRegion = function() { - return this; - }; - - /** - * Returns the user {@link LABKEY.Query.containerFilter} parameter from the URL. - * @returns {LABKEY.Query.containerFilter} The user container filter. - */ - LABKEY.DataRegion.prototype.getUserContainerFilter = function() { - return this.getParameter(this.name + CONTAINER_FILTER_NAME); - }; - - /** - * Returns the user filter from the URL. The filter is represented as an Array of objects of the form: - *
    - *
  • fieldKey: {String} The field key of the filter. - *
  • op: {String} The filter operator (eg. "eq" or "in") - *
  • value: {String} Optional value to filter by. - *
- * @returns {Object} Object representing the user filter. - * @deprecated 12.2 Use getUserFilterArray instead - */ - LABKEY.DataRegion.prototype.getUserFilter = function() { - - if (LABKEY.devMode) { - console.warn([ - 'LABKEY.DataRegion.getUserFilter() is deprecated since release 12.2.', - 'Consider using getUserFilterArray() instead.' - ].join(' ')); - } - - return this.getUserFilterArray().map(function(filter) { - return { - fieldKey: filter.getColumnName(), - op: filter.getFilterType().getURLSuffix(), - value: filter.getValue() - }; - }); - }; - - /** - * Returns an Array of LABKEY.Filter instances constructed from the URL. - * @returns {Array} Array of {@link LABKEY.Filter} objects that represent currently applied filters. - */ - LABKEY.DataRegion.prototype.getUserFilterArray = function() { - var userFilter = [], me = this; - - _getParameters(this).forEach(function(pair) { - if (pair[0].indexOf(me.name + '.') == 0 && pair[0].indexOf('~') > -1) { - var tilde = pair[0].indexOf('~'); - var fieldKey = pair[0].substring(me.name.length + 1, tilde); - var op = pair[0].substring(tilde + 1); - userFilter.push(LABKEY.Filter.create(fieldKey, pair[1], LABKEY.Filter.getFilterTypeForURLSuffix(op))); - } - }); - - return userFilter; - }; - - /** - * Remove a filter on this DataRegion. - * @param {LABKEY.Filter} filter - */ - LABKEY.DataRegion.prototype.removeFilter = function(filter) { - this.clearSelected({quiet: true}); - if (LABKEY.Utils.isObject(filter) && LABKEY.Utils.isFunction(filter.getColumnName)) { - _updateFilter(this, null, [this.name + '.' + filter.getColumnName() + '~']); - } - }; - - /** - * Replace a filter on this Data Region. Optionally, supply another filter to replace for cases when the filter - * columns don't match exactly. - * @param {LABKEY.Filter} filter - * @param {LABKEY.Filter} [filterToReplace] - */ - LABKEY.DataRegion.prototype.replaceFilter = function(filter, filterToReplace) { - this.clearSelected({quiet: true}); - var target = filterToReplace ? filterToReplace : filter; - _updateFilter(this, filter, [this.name + '.' + target.getColumnName() + '~']); - }; - - /** - * @ignore - * @param filters - * @param columnNames - */ - LABKEY.DataRegion.prototype.replaceFilters = function(filters, columnNames) { - this.clearSelected({quiet: true}); - var filterPrefixes = [], - filterParams = [], - me = this; - - if (LABKEY.Utils.isArray(filters)) { - filters.forEach(function(filter) { - filterPrefixes.push(me.name + '.' + filter.getColumnName() + '~'); - filterParams.push([filter.getURLParameterName(me.name), filter.getURLParameterValue()]); - }); - } - - var fieldKeys = []; - - if (LABKEY.Utils.isArray(columnNames)) { - fieldKeys = fieldKeys.concat(columnNames); - } - else if ($.isPlainObject(columnNames) && columnNames.fieldKey) { - fieldKeys.push(columnNames.fieldKey.toString()); - } - - // support fieldKeys (e.g. ["ColumnA", "ColumnA/Sub1"]) - // A special case of fieldKey is "SUBJECT_PREFIX/", used by participant group facet - if (fieldKeys.length > 0) { - _getParameters(this).forEach(function(param) { - var p = param[0]; - if (p.indexOf(me.name + '.') === 0 && p.indexOf('~') > -1) { - $.each(fieldKeys, function(j, name) { - var postfix = name && name.length && name[name.length - 1] == '/' ? '' : '~'; - if (p.indexOf(me.name + '.' + name + postfix) > -1) { - filterPrefixes.push(p); - } - }); - } - }); - } - - _setParameters(this, filterParams, [OFFSET_PREFIX].concat($.unique(filterPrefixes))); - }; - - /** - * @private - * @param filter - * @param filterMatch - */ - LABKEY.DataRegion.prototype.replaceFilterMatch = function(filter, filterMatch) { - this.clearSelected({quiet: true}); - var skips = [], me = this; - - _getParameters(this).forEach(function(param) { - if (param[0].indexOf(me.name + '.') === 0 && param[0].indexOf(filterMatch) > -1) { - skips.push(param[0]); - } - }); - - _updateFilter(this, filter, skips); - }; - - // - // Selection - // - - /** - * @private - */ - var _initSelection = function() { - - var me = this, - form = _getFormSelector(this); - - if (form && form.length) { - // backwards compatibility -- some references use this directly - // if you're looking to use this internally to the region use _getFormSelector() instead - this.form = form[0]; - } - - if (form && this.showRecordSelectors) { - _onSelectionChange(this); - } - - // Bind Events - _getAllRowSelectors(this).on('click', function(evt) { - evt.stopPropagation(); - me.selectPage.call(me, this.checked); - }); - _getRowSelectors(this).on('click', function() { me.selectRow.call(me, this); }); - - // click row highlight - var rows = form.find('.labkey-data-region > tbody > tr'); - rows.on('click', function(e) { - if (e.target && e.target.tagName.toLowerCase() === 'td') { - $(this).siblings('tr').removeClass('lk-row-hl'); - $(this).addClass('lk-row-hl'); - _selClickLock = me; - } - }); - rows.on('mouseenter', function() { - $(this).siblings('tr').removeClass('lk-row-over'); - $(this).addClass('lk-row-over'); - }); - rows.on('mouseleave', function() { - $(this).removeClass('lk-row-over'); - }); - - if (!_selDocClick) { - _selDocClick = $(document).on('click', _onDocumentClick); - } - }; - - var _selClickLock; // lock to prevent removing a row highlight that was just applied - var _selDocClick; // global (shared across all Data Region instances) click event handler instance - - // Issue 32898: Clear row highlights on document click - var _onDocumentClick = function() { - if (_selClickLock) { - var form = _getFormSelector(_selClickLock); - _selClickLock = undefined; - - $('.lk-row-hl').each(function() { - if (!form.has($(this)).length) { - $(this).removeClass('lk-row-hl'); - } - }); - } - else { - $('.lk-row-hl').removeClass('lk-row-hl'); - } - }; - - /** - * Clear all selected items for the current DataRegion. - * - * @param config A configuration object with the following properties: - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'count' of 0 to indicate an empty selection. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion#selectPage - * @see LABKEY.DataRegion.clearSelected static method. - */ - LABKEY.DataRegion.prototype.clearSelected = function(config) { - config = config || {}; - config.selectionKey = this.selectionKey; - config.scope = config.scope || this; - - this.selectedCount = 0; - if (!config.quiet) - { - _onSelectionChange(this); - } - - if (config.selectionKey) { - LABKEY.DataRegion.clearSelected(config); - } - - if (this.showRows == 'selected') { - _removeParameters(this, [SHOW_ROWS_PREFIX]); - } - else if (this.showRows == 'unselected') { - // keep "SHOW_ROWS_PREFIX=unselected" parameter - window.location.reload(true); - } - else { - _toggleAllRows(this, false); - this.removeMessage('selection'); - } - }; - - /** - * Get selected items on the current page of the DataRegion, based on the current state of the checkboxes in the - * browser's DOM. Note, if the region is paginated, selected items may exist on other pages which will not be - * included in the results of this function. - * @see LABKEY.DataRegion#getSelected - */ - LABKEY.DataRegion.prototype.getChecked = function() { - var values = []; - _getRowSelectors(this).each(function() { - if (this.checked) { - values.push(this.value); - } - }); - return values; - }; - - /** - * Get all selected items for this DataRegion, as maintained in server-state. This will include rows on any - * pages of a paginated grid, and may not correspond directly with the state of the checkboxes in the current - * browser window's DOM if the server-side state has been modified. - * - * @param config A configuration object with the following properties: - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'selected' that is an array of the primary keys for the selected rows. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion.getSelected static method. - */ - LABKEY.DataRegion.prototype.getSelected = function(config) { - if (!this.selectionKey) - return; - - config = config || {}; - config.selectionKey = this.selectionKey; - LABKEY.DataRegion.getSelected(config); - }; - - /** - * Returns the number of selected rows on the current page of the DataRegion. Selected items may exist on other pages. - * @returns {Integer} the number of selected rows on the current page of the DataRegion. - * @see LABKEY.DataRegion#getSelected to get all selected rows. - */ - LABKEY.DataRegion.prototype.getSelectionCount = function() { - if (!$('#' + this.domId)) { - return 0; - } - - var count = 0; - _getRowSelectors(this).each(function() { - if (this.checked === true) { - count++; - } - }); - - return count; - }; - - /** - * Returns true if any row is checked on the current page of the DataRegion. Selected items may exist on other pages. - * @returns {Boolean} true if any row is checked on the current page of the DataRegion. - * @see LABKEY.DataRegion#getSelected to get all selected rows. - */ - LABKEY.DataRegion.prototype.hasSelected = function() { - return this.getSelectionCount() > 0; - }; - - /** - * Returns true if all rows are checked on the current page of the DataRegion and at least one row is present. - * @returns {Boolean} true if all rows are checked on the current page of the DataRegion and at least one row is present. - * @see LABKEY.DataRegion#getSelected to get all selected rows. - */ - LABKEY.DataRegion.prototype.isPageSelected = function() { - var checkboxes = _getRowSelectors(this); - var i=0; - - for (; i < checkboxes.length; i++) { - if (!checkboxes[i].checked) { - return false; - } - } - return i > 0; - }; - - LABKEY.DataRegion.prototype.selectAll = function(config) { - if (this.selectionKey) { - config = config || {}; - config.scope = config.scope || this; - - // Either use the selectAllURL provided or create a query config - // object that can be used with the generic query/selectAll.api action. - if (this.selectAllURL) { - config.url = this.selectAllURL; - } - else { - config = LABKEY.Utils.apply(config, this.getQueryConfig()); - } - - config = _chainSelectionCountCallback(this, config); - - LABKEY.DataRegion.selectAll(config); - - if (this.showRows === "selected") { - // keep "SHOW_ROWS_PREFIX=selected" parameter - window.location.reload(true); - } - else if (this.showRows === "unselected") { - _removeParameters(this, [SHOW_ROWS_PREFIX]); - } - else { - _toggleAllRows(this, true); - } - } - }; - - /** - * @deprecated use clearSelected instead - * @function - * @see LABKEY.DataRegion#clearSelected - */ - LABKEY.DataRegion.prototype.selectNone = LABKEY.DataRegion.prototype.clearSelected; - - /** - * Set the selection state for all checkboxes on the current page of the DataRegion. - * @param checked whether all of the rows on the current page should be selected or unselected - * @returns {Array} Array of ids that were selected or unselected. - * - * @see LABKEY.DataRegion#setSelected to set selected items on the current page of the DataRegion. - * @see LABKEY.DataRegion#clearSelected to clear all selected. - */ - LABKEY.DataRegion.prototype.selectPage = function(checked) { - var _check = (checked === true); - var ids = _toggleAllRows(this, _check); - var me = this; - - if (ids.length > 0) { - _getAllRowSelectors(this).each(function() { this.checked = _check}); - this.setSelected({ - ids: ids, - checked: _check, - success: function(data) { - if (data && data.count > 0 && !this.complete) { - var count = data.count; - var msg; - if (me.totalRows) { - if (count == me.totalRows) { - msg = 'All ' + this.totalRows + ' rows selected.'; - } - else { - msg = 'Selected ' + count + ' of ' + this.totalRows + ' rows.'; - } - } - else { - // totalRows isn't available when showing all rows. - msg = 'Selected ' + count + ' rows.'; - } - _showSelectMessage(me, msg); - } - else { - this.removeMessage('selection'); - } - } - }); - } - - return ids; - }; - - /** - * @ignore - * @param el - */ - LABKEY.DataRegion.prototype.selectRow = function(el) { - this.setSelected({ - ids: [el.value], - checked: el.checked - }); - - if (!el.checked) { - this.removeMessage('selection'); - } - }; - - /** - * Add or remove items from the selection associated with this DataRegion. - * - * @param config A configuration object with the following properties: - * @param {Array} config.ids Array of primary key ids for each row to select/unselect. - * @param {Boolean} config.checked If true, the ids will be selected, otherwise unselected. - * @param {Function} [config.success] The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'count' to indicate the updated selection count. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion#getSelected to get the selected items for this DataRegion. - * @see LABKEY.DataRegion#clearSelected to clear all selected items for this DataRegion. - */ - LABKEY.DataRegion.prototype.setSelected = function(config) { - if (!config || !LABKEY.Utils.isArray(config.ids) || config.ids.length === 0) { - return; - } - - var me = this; - config = config || {}; - config.selectionKey = this.selectionKey; - config.scope = config.scope || me; - - config = _chainSelectionCountCallback(this, config); - - var failure = LABKEY.Utils.getOnFailure(config); - if ($.isFunction(failure)) { - config.failure = failure; - } - else { - config.failure = function(error) { - let msg = 'Error setting selection'; - if (error && error.exception) msg += ': ' + error.exception; - me.addMessage(msg, 'selection'); - }; - } - - if (config.selectionKey) { - LABKEY.DataRegion.setSelected(config); - } - else if ($.isFunction(config.success)) { - // Don't send the selection change to the server if there is no selectionKey. - // Call the success callback directly. - config.success.call(config.scope, {count: this.getSelectionCount()}); - } - }; - - // - // Parameters - // - - /** - * Removes all parameters from the DataRegion - */ - LABKEY.DataRegion.prototype.clearAllParameters = function() { - if (this.async) { - this.offset = 0; - this.parameters = undefined; - } - - _removeParameters(this, [PARAM_PREFIX, OFFSET_PREFIX]); - }; - - /** - * Returns the specified parameter from the URL. Note, this is not related specifically - * to parameterized query values (e.g. setParameters()/getParameters()) - * @param {String} paramName - * @returns {*} - */ - LABKEY.DataRegion.prototype.getParameter = function(paramName) { - var param = null; - - $.each(_getParameters(this), function(i, pair) { - if (pair.length > 0 && pair[0] === paramName) { - param = pair.length > 1 ? pair[1] : ''; - return false; - } - }); - - return param; - }; - - /** - * Get the parameterized query values for this query. These parameters - * are named by the query itself. - * @param {boolean} toLowercase If true, all parameter names will be converted to lowercase - * returns params An Object of key/val pairs. - */ - LABKEY.DataRegion.prototype.getParameters = function(toLowercase) { - - var params = this.parameters ? this.parameters : {}, - re = new RegExp('^' + LABKEY.Utils.escapeRe(this.name) + LABKEY.Utils.escapeRe(PARAM_PREFIX), 'i'), - name; - - _getParameters(this).forEach(function(pair) { - if (pair.length > 0 && pair[0].match(re)) { - name = pair[0].replace(re, ''); - if (toLowercase === true) { - name = name.toLowerCase(); - } - - // URL parameters will override this.parameters values - params[name] = pair[1]; - } - }); - - return params; - }; - - /** - * Set the parameterized query values for this query. These parameters - * are named by the query itself. - * @param {Mixed} params An Object or Array of Array key/val pairs. - */ - LABKEY.DataRegion.prototype.setParameters = function(params) { - var event = $.Event('beforesetparameters'); - - $(this).trigger(event); - - if (event.isDefaultPrevented()) { - return; - } - - var paramPrefix = this.name + PARAM_PREFIX, _params = []; - var newParameters = this.parameters ? this.parameters : {}; - - function applyParameters(pKey, pValue) { - var key = pKey; - if (pKey.indexOf(paramPrefix) !== 0) { - key = paramPrefix + pKey; - } - newParameters[key.replace(paramPrefix, '')] = pValue; - _params.push([key, pValue]); - } - - // convert Object into Array of Array pairs and prefix the parameter name if necessary. - if (LABKEY.Utils.isObject(params)) { - $.each(params, applyParameters); - } - else if (LABKEY.Utils.isArray(params)) { - params.forEach(function(pair) { - if (LABKEY.Utils.isArray(pair) && pair.length > 1) { - applyParameters(pair[0], pair[1]); - } - }); - } - else { - return; // invalid argument shape - } - - this.parameters = newParameters; - - _setParameters(this, _params, [PARAM_PREFIX, OFFSET_PREFIX]); - }; - - /** - * @ignore - * @Deprecated - */ - LABKEY.DataRegion.prototype.setSearchString = function(regionName, search) { - this.savedSearchString = search || ""; - // If the search string doesn't change and there is a hash on the url, the page won't reload. - // Remove the hash by setting the full path plus search string. - window.location.assign(window.location.pathname + (this.savedSearchString.length > 0 ? "?" + this.savedSearchString : "")); - }; - - // - // Messaging - // - - /** - * @private - */ - var _initMessaging = function() { - if (!this.msgbox) { - this.msgbox = new MessageArea(this); - this.msgbox.on('rendermsg', function(evt, msgArea, parts) { _onRenderMessageArea(this, parts); }, this); - } - else { - this.msgbox.bindRegion(this); - } - - if (this.messages) { - this.msgbox.setMessages(this.messages); - this.msgbox.render(); - } - }; - - /** - * Show a message in the header of this DataRegion. - * @param {String / Object} config the HTML source of the message to be shown or a config object with the following properties: - *
    - *
  • html: {String} the HTML source of the message to be shown.
  • - *
  • part: {String} The part of the message area to render the message to.
  • - *
  • duration: {Integer} The amount of time (in milliseconds) the message will stay visible.
  • - *
  • hideButtonPanel: {Boolean} If true the button panel (customize view, export, etc.) will be hidden if visible.
  • - *
  • append: {Boolean} If true the msg is appended to any existing content for the given part.
  • - *
- * @param part The part of the message area to render the message to. Used to scope messages so they can be added - * and removed without clearing other messages. - */ - LABKEY.DataRegion.prototype.addMessage = function(config, part) { - this.hidePanel(); - - if (LABKEY.Utils.isString(config)) { - this.msgbox.addMessage(config, part); - } - else if (LABKEY.Utils.isObject(config)) { - this.msgbox.addMessage(config.html, config.part || part, config.append); - - if (config.hideButtonPanel) { - this.hideButtonPanel(); - } - - if (config.duration) { - var dr = this; - setTimeout(function() { - dr.removeMessage(config.part || part); - _getHeaderSelector(dr).trigger('resize'); - }, config.duration); - } - } - }; - - /** - * Clear the message box contents. - */ - LABKEY.DataRegion.prototype.clearMessage = function() { - if (this.msgbox) this.msgbox.removeAll(); - }; - - /** - * @param part The part of the message area to render the message to. Used to scope messages so they can be added - * and removed without clearing other messages. - * @return {String} The message for 'part'. Could be undefined. - */ - LABKEY.DataRegion.prototype.getMessage = function(part) { - if (this.msgbox) { return this.msgbox.getMessage(part); } // else undefined - }; - - /** - * @param part The part of the message area to render the message to. Used to scope messages so they can be added - * and removed without clearing other messages. - * @return {Boolean} true iff there is a message area for this region and it has the message keyed by 'part'. - */ - LABKEY.DataRegion.prototype.hasMessage = function(part) { - return this.msgbox && this.msgbox.hasMessage(part); - }; - - LABKEY.DataRegion.prototype.hideContext = function() { - _getContextBarSelector(this).hide(); - _getViewBarSelector(this).hide(); - }; - - /** - * If a message is currently showing, hide it and clear out its contents - * @param keepContent If true don't remove the message area content - */ - LABKEY.DataRegion.prototype.hideMessage = function(keepContent) { - if (this.msgbox) { - this.msgbox.hide(); - - if (!keepContent) - this.removeAllMessages(); - } - }; - - /** - * Returns true if a message is currently being shown for this DataRegion. Messages are shown as a header. - * @return {Boolean} true if a message is showing. - */ - LABKEY.DataRegion.prototype.isMessageShowing = function() { - return this.msgbox && this.msgbox.isVisible(); - }; - - /** - * Removes all messages from this Data Region. - */ - LABKEY.DataRegion.prototype.removeAllMessages = function() { - if (this.msgbox) { this.msgbox.removeAll(); } - }; - - /** - * If a message is currently showing, remove the specified part - */ - LABKEY.DataRegion.prototype.removeMessage = function(part) { - if (this.msgbox) { this.msgbox.removeMessage(part); } - }; - - /** - * Show a message in the header of this DataRegion with a loading indicator. - * @param html the HTML source of the message to be shown - */ - LABKEY.DataRegion.prototype.showLoadingMessage = function(html) { - html = html || "Loading..."; - this.addMessage('
 ' + html + '
', 'drloading'); - }; - - LABKEY.DataRegion.prototype.hideLoadingMessage = function() { - this.removeMessage('drloading'); - }; - - /** - * Show a success message in the header of this DataRegion. - * @param html the HTML source of the message to be shown - */ - LABKEY.DataRegion.prototype.showSuccessMessage = function(html) { - html = html || "Completed successfully."; - this.addMessage('
' + html + '
'); - }; - - /** - * Show an error message in the header of this DataRegion. - * @param html the HTML source of the message to be shown - */ - LABKEY.DataRegion.prototype.showErrorMessage = function(html) { - html = html || "An error occurred."; - this.addMessage('
' + html + '
'); - }; - - LABKEY.DataRegion.prototype.showContext = function() { - _initContexts(); - - var contexts = [ - _getContextBarSelector(this), - _getViewBarSelector(this) - ]; - - for (var i = 0; i < contexts.length; i++) { - var ctx = contexts[i]; - var html = ctx.html(); - - if (html && html.trim() !== '') { - ctx.show(); - } - } - }; - - /** - * Show a message in the header of this DataRegion. - * @param msg the HTML source of the message to be shown - * @deprecated use addMessage(msg, part) instead. - */ - LABKEY.DataRegion.prototype.showMessage = function(msg) { - if (this.msgbox) { - this.msgbox.addMessage(msg); - } - }; - - LABKEY.DataRegion.prototype.showMessageArea = function() { - if (this.msgbox && this.msgbox.hasContent()) { - this.msgbox.show(); - } - }; - - // - // Sections - // - - LABKEY.DataRegion.prototype.displaySection = function(options) { - var dir = options && options.dir ? options.dir : 'n'; - - var sec = _getSectionSelector(this, dir); - if (options && options.html) { - options.append === true ? sec.append(options.html) : sec.html(options.html); - } - sec.show(); - }; - - LABKEY.DataRegion.prototype.hideSection = function(options) { - var dir = options && options.dir ? options.dir : 'n'; - var sec = _getSectionSelector(this, dir); - - sec.hide(); - - if (options && options.clear === true) { - sec.html(''); - } - }; - - LABKEY.DataRegion.prototype.writeSection = function(content, options) { - var append = options && options.append === true; - var dir = options && options.dir ? options.dir : 'n'; - - var sec = _getSectionSelector(this, dir); - append ? sec.append(content) : sec.html(content); - }; - - // - // Sorting - // - - /** - * Replaces the sort on the given column, if present, or sets a brand new sort - * @param {string or LABKEY.FieldKey} fieldKey name of the column to be sorted - * @param {string} [sortDir=+] Set to '+' for ascending or '-' for descending - */ - LABKEY.DataRegion.prototype.changeSort = function(fieldKey, sortDir) { - if (!fieldKey) - return; - - fieldKey = _resolveFieldKey(this, fieldKey); - - var columnName = fieldKey.toString(); - - var event = $.Event("beforesortchange"); - - $(this).trigger(event, [this, columnName, sortDir]); - - if (event.isDefaultPrevented()) { - return; - } - - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - this._userSort = _alterSortString(this, this._userSort, fieldKey, sortDir); - _setParameter(this, SORT_PREFIX, this._userSort, [SORT_PREFIX, OFFSET_PREFIX]); - }; - - /** - * Removes the sort on a specified column - * @param {string or LABKEY.FieldKey} fieldKey name of the column - */ - LABKEY.DataRegion.prototype.clearSort = function(fieldKey) { - if (!fieldKey) - return; - - fieldKey = _resolveFieldKey(this, fieldKey); - - var columnName = fieldKey.toString(); - - var event = $.Event("beforeclearsort"); - - $(this).trigger(event, [this, columnName]); - - if (event.isDefaultPrevented()) { - return; - } - - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - this._userSort = _alterSortString(this, this._userSort, fieldKey); - if (this._userSort.length > 0) { - _setParameter(this, SORT_PREFIX, this._userSort, [SORT_PREFIX, OFFSET_PREFIX]); - } - else { - _removeParameters(this, [SORT_PREFIX, OFFSET_PREFIX]); - } - }; - - /** - * Returns the user sort from the URL. The sort is represented as an Array of objects of the form: - *
    - *
  • fieldKey: {String} The field key of the sort. - *
  • dir: {String} The sort direction, either "+" or "-". - *
- * @returns {Object} Object representing the user sort. - */ - LABKEY.DataRegion.prototype.getUserSort = function() { - return _getUserSort(this); - }; - - // - // Paging - // - - var _initPaging = function() { - if (this.showPagination) { - // Issue 51036: load totalRows count async for DataRegions - if (!this.complete && this.showPaginationCountAsync && !this.skipTotalRowCount && this.loadingTotalRows === undefined) { - var params = _getAsyncParams(this, _getParameters(this), false); - var jsonData = _getAsyncBody(this, params); - _loadAsyncTotalRowCount(this, params, jsonData); - } - - var ct = _getBarSelector(this).find('.labkey-pagination'); - - if (ct && ct.length) { - var hasOffset = $.isNumeric(this.offset); - var hasTotal = $.isNumeric(this.totalRows); - - // display the counts - if (hasOffset) { - - // small result set - if (hasTotal && this.totalRows < 5) { - return; - } - - var low = this.offset + 1; - var high = this.offset + this.rowCount; - - // user has opted to show all rows - if (hasTotal && (this.rowCount === null || this.rowCount < 1)) { - high = this.totalRows; - } - - var showFirst = _showFirstEnabled(this); - var showLast = _showLastEnabled(this); - var showAll = _showAllEnabled(this); - this.showFirstID = LABKEY.Utils.id(); - this.showLastID = LABKEY.Utils.id(); - this.showAllID = LABKEY.Utils.id(); - - // If modifying this ensure it is consistent with DOM generated by PopupMenu.java - var elems = [ - ''); - ct.append(elems.join('')); - - //bind functions to menu items - _getShowFirstSelector(this).click(_firstPage.bind(this)); - _getShowLastSelector(this).click(_lastPage.bind(this)); - _getShowAllSelector(this).click(this.showAllRows.bind(this)); - - if (_isMaxRowsAllRows(this) && this.totalRows > this.maxRows) { - this.addMessage('Show all: Displaying the first ' + ALL_ROWS_MAX.toLocaleString() + ' rows. Use paging to see more results.'); - } - - for (var key in offsetIds) { - if (offsetIds.hasOwnProperty(key)) { - $('#' + key).click(_setMaxRows.bind(this, offsetIds[key])); - } - } - - // only display buttons if all the results are not shown - if (low === 1 && high === this.totalRows) { - _getBarSelector(this).find('.paging-widget').css("top", "4px"); - return; - } - - var canNext = this.maxRows > 0 && high !== this.totalRows, - canPrev = this.maxRows > 0 && low > 1, - prevId = LABKEY.Utils.id(), - nextId = LABKEY.Utils.id(); - - ct.append([ - '
', - '', - '', - '
' - ].join('')); - - var prev = $('#' + prevId); - prev.click(_page.bind(this, this.offset - this.maxRows, canPrev)); - if (!canPrev) { - prev.addClass('disabled'); - } - - var next = $('#' + nextId); - next.click(_page.bind(this, this.offset + this.maxRows, canNext)); - if (!canNext) { - next.addClass('disabled'); - } - } - } - } - else { - _getHeaderSelector(this).find('div.labkey-pagination').css('visibility', 'visible'); - } - }; - - var _showFirstEnabled = function(region) { - return region.offset && region.offset > 0; - }; - - var _showLastEnabled = function(region) { - var low = region.offset + 1; - var high = region.offset + region.rowCount; - return !(low === 1 && high === region.totalRows) && (region.offset + region.maxRows <= region.totalRows); - }; - - var _showAllEnabled = function(region) { - return (_showFirstEnabled(region) || _showLastEnabled(region)) && !_isMaxRowsAllRows(region); - }; - - var _getPaginationText = function(region) { - var hasTotal = $.isNumeric(region.totalRows); - var low = region.offset + 1; - var high = region.offset + region.rowCount; - - var paginationText = low.toLocaleString() + ' - ' + high.toLocaleString(); - if (region.showPaginationCount || region.showPaginationCountAsync) { - if (hasTotal) { - paginationText += ' of ' + region.totalRows.toLocaleString(); - } else if (region.loadingTotalRows) { - paginationText += ' of '; - } - } - - return paginationText; - }; - - var _page = function(offset, enabled) { - if (enabled) { - this.setPageOffset(offset); - } - return false; - }; - - var _firstPage = function() { - if (_showFirstEnabled(this)) { - this.setPageOffset(0); - } - return false; - }; - - var _lastPage = function() { - if (_showLastEnabled(this)) { - var lastPageSize = this.totalRows % this.maxRows === 0 ? this.maxRows : this.totalRows % this.maxRows; - this.setPageOffset(this.totalRows - lastPageSize); - } - return false; - }; - - var _setMaxRows = function(rows) { - if (this.maxRows !== rows) { - this.setMaxRows(rows); - } - return false; - }; - - var _isMaxRowsAllRows = function(region) { - return region.maxRows === ALL_ROWS_MAX; - }; - - /** - * Forces the grid to show all rows, up to ALL_ROWS_MAX, without any paging - */ - LABKEY.DataRegion.prototype.showAllRows = function() { - _setMaxRows.bind(this, ALL_ROWS_MAX)(); - }; - - /** - * @deprecated use showAllRows instead - * @function - * @see LABKEY.DataRegion#showAllRows - */ - LABKEY.DataRegion.prototype.showAll = LABKEY.DataRegion.prototype.showAllRows; - - /** - * Forces the grid to show only rows that have been selected - */ - LABKEY.DataRegion.prototype.showSelectedRows = function() { - _showRows(this, 'selected'); - }; - /** - * @deprecated use showSelectedRows instead - * @function - * @see LABKEY.DataRegion#showSelectedRows - */ - LABKEY.DataRegion.prototype.showSelected = LABKEY.DataRegion.prototype.showSelectedRows; - - /** - * Forces the grid to show only rows that have not been selected - */ - LABKEY.DataRegion.prototype.showUnselectedRows = function() { - _showRows(this, 'unselected'); - }; - /** - * @deprecated use showUnselectedRows instead - * @function - * @see LABKEY.DataRegion#showUnselectedRows - */ - LABKEY.DataRegion.prototype.showUnselected = LABKEY.DataRegion.prototype.showUnselectedRows; - - /** - * Forces the grid to do paging based on the current maximum number of rows - */ - LABKEY.DataRegion.prototype.showPaged = function() { - _removeParameters(this, [SHOW_ROWS_PREFIX]); - }; - - /** - * Displays the first page of the grid - */ - LABKEY.DataRegion.prototype.showFirstPage = function() { - this.setPageOffset(0); - }; - /** - * @deprecated use showFirstPage instead - * @function - * @see LABKEY.DataRegion#showFirstPage - */ - LABKEY.DataRegion.prototype.pageFirst = LABKEY.DataRegion.prototype.showFirstPage; - - /** - * Changes the current row offset for paged content - * @param rowOffset row index that should be at the top of the grid - */ - LABKEY.DataRegion.prototype.setPageOffset = function(rowOffset) { - var event = $.Event('beforeoffsetchange'); - - $(this).trigger(event, [this, rowOffset]); - - if (event.isDefaultPrevented()) { - return; - } - - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - // clear sibling parameters - this.showRows = undefined; - - if ($.isNumeric(rowOffset)) { - _setParameter(this, OFFSET_PREFIX, rowOffset, [OFFSET_PREFIX, SHOW_ROWS_PREFIX]); - } - else { - _removeParameters(this, [OFFSET_PREFIX, SHOW_ROWS_PREFIX]); - } - }; - /** - * @deprecated use setPageOffset instead - * @function - * @see LABKEY.DataRegion#setPageOffset - */ - LABKEY.DataRegion.prototype.setOffset = LABKEY.DataRegion.prototype.setPageOffset; - - /** - * Changes the maximum number of rows that the grid will display at one time - * @param newmax the maximum number of rows to be shown - */ - LABKEY.DataRegion.prototype.setMaxRows = function(newmax) { - var event = $.Event('beforemaxrowschange'); - $(this).trigger(event, [this, newmax]); - if (event.isDefaultPrevented()) { - return; - } - - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - // clear sibling parameters - this.showRows = undefined; - this.offset = 0; - - _setParameter(this, MAX_ROWS_PREFIX, newmax, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); - }; - - var _initContexts = function() { - // clear old contents - var ctxBar = _getContextBarSelector(this); - ctxBar.find('.labkey-button-bar').remove(); - - var numFilters = ctxBar.find('.fa-filter').length; - var numParams = ctxBar.find('.fa-question').length; - - var html = []; - - if (numParams > 0) { - html = html.concat([ - '
', - 'Clear Variables', - '
' - ]) - } - - if (numFilters >= 2) { - html = html.concat([ - '
', - '' + - (numParams > 0 ? 'Clear Filters' : 'Clear All') + - '', - '
' - ]); - } - - if (html.length) { - ctxBar.append(html.join('')); - ctxBar.find('.ctx-clear-var').off('click').on('click', $.proxy(this.clearAllParameters, this)); - ctxBar.find('.ctx-clear-all').off('click').on('click', $.proxy(this.clearAllFilters, this)); - } - - // Issue 35396: Support ButtonBarOptions - if (LABKEY.Utils.isArray(this.buttonBarOnRenders)) { - for (var i=0; i < this.buttonBarOnRenders.length; i++) { - var scriptFnName = this.buttonBarOnRenders[i]; - var fnParts = scriptFnName.split('.'); - var scope = window; - var called = false; - - for (var j=0; j < fnParts.length; j++) { - scope = scope[fnParts[j]]; - if (!scope) break; - if (j === fnParts.length - 1 && LABKEY.Utils.isFunction(scope)) { - scope(this); - called = true; - } - } - - if (!called) { - console.warn('Unable to call "' + scriptFnName + '" for DataRegion.ButtonBar.onRender.'); - } - } - } - }; - - // - // Customize View - // - var _initCustomViews = function() { - if (this.view && this.view.session) { - // clear old contents - _getViewBarSelector(this).find('.labkey-button-bar').remove(); - - _getViewBarSelector(this).append([ - '
', - 'This grid view has been modified.', - 'Revert', - 'Edit', - 'Save', - '
' - ].join('')); - _getViewBarSelector(this).find('.unsavedview-revert').off('click').on('click', $.proxy(function() { - _revertCustomView(this); - }, this)); - _getViewBarSelector(this).find('.unsavedview-edit').off('click').on('click', $.proxy(function() { - this.showCustomizeView(undefined); - }, this)); - _getViewBarSelector(this).find('.unsavedview-save').off('click').on('click', $.proxy(function() { - _saveSessionCustomView(this); - }, this)); - } - }; - - /** - * Change the currently selected view to the named view - * @param {Object} view An object which contains the following properties. - * @param {String} [view.type] the type of view, either a 'view' or a 'report'. - * @param {String} [view.viewName] If the type is 'view', then the name of the view. - * @param {String} [view.reportId] If the type is 'report', then the report id. - * @param {Object} urlParameters NOTE: Experimental parameter; may change without warning. A set of filter and sorts to apply as URL parameters when changing the view. - */ - LABKEY.DataRegion.prototype.changeView = function(view, urlParameters) { - var event = $.Event('beforechangeview'); - $(this).trigger(event, [this, view, urlParameters]); - if (event.isDefaultPrevented()) { - return; - } - - var paramValPairs = [], - newSort = [], - skipPrefixes = [OFFSET_PREFIX, SHOW_ROWS_PREFIX, VIEWNAME_PREFIX, REPORTID_PREFIX]; - - // clear sibling parameters - this.viewName = undefined; - this.reportId = undefined; - - if (view) { - if (LABKEY.Utils.isString(view)) { - paramValPairs.push([VIEWNAME_PREFIX, view]); - this.viewName = view; - } - else if (view.type === 'report') { - paramValPairs.push([REPORTID_PREFIX, view.reportId]); - this.reportId = view.reportId; - } - else if (view.type === 'view' && view.viewName) { - paramValPairs.push([VIEWNAME_PREFIX, view.viewName]); - this.viewName = view.viewName; - } - } - - if (urlParameters) { - $.each(urlParameters.filter, function(i, filter) { - paramValPairs.push(['.' + filter.fieldKey + '~' + filter.op, filter.value]); - }); - - if (urlParameters.sort && urlParameters.sort.length > 0) { - $.each(urlParameters.sort, function(i, sort) { - newSort.push((sort.dir === '+' ? '' : sort.dir) + sort.fieldKey); - }); - paramValPairs.push([SORT_PREFIX, newSort.join(',')]); - } - - if (urlParameters.containerFilter) { - paramValPairs.push([CONTAINER_FILTER_NAME, urlParameters.containerFilter]); - } - - // removes all filter, sort, and container filter parameters - skipPrefixes = skipPrefixes.concat([ - ALL_FILTERS_SKIP_PREFIX, SORT_PREFIX, COLUMNS_PREFIX, CONTAINER_FILTER_NAME - ]); - } - - // removes all filter, sort, and container filter parameters - _setParameters(this, paramValPairs, skipPrefixes); - }; - - LABKEY.DataRegion.prototype.getQueryDetails = function(success, failure, scope) { - - var userSort = this.getUserSort(), - userColumns = this.getParameter(this.name + COLUMNS_PREFIX), - fields = [], - viewName = (this.view && this.view.name) || this.viewName || ''; - - var userFilter = this.getUserFilterArray().map(function(filter) { - var fieldKey = filter.getColumnName(); - fields.push(fieldKey); - - return { - fieldKey: fieldKey, - op: filter.getFilterType().getURLSuffix(), - value: filter.getValue() - }; - }); - - userSort.forEach(function(sort) { - fields.push(sort.fieldKey); - }); - - LABKEY.Query.getQueryDetails({ - containerPath: this.containerPath, - schemaName: this.schemaName, - queryName: this.queryName, - viewName: viewName, - fields: fields, - initializeMissingView: true, - success: function(queryDetails) { - success.call(scope || this, queryDetails, viewName, userColumns, userFilter, userSort); - }, - failure: failure, - scope: scope - }); - }; - - /** - * Hides the customize view interface if it is visible. - */ - LABKEY.DataRegion.prototype.hideCustomizeView = function() { - if (this.activePanelId === CUSTOM_VIEW_PANELID) { - this.hideButtonPanel(); - } - }; - - /** - * Show the customize view interface. - * @param activeTab {[String]} Optional. One of "ColumnsTab", "FilterTab", or "SortTab". If no value is specified (or undefined), the ColumnsTab will be shown. - */ - LABKEY.DataRegion.prototype.showCustomizeView = function(activeTab) { - var region = this; - - var panelConfig = this.getPanelConfiguration(CUSTOM_VIEW_PANELID); - - if (!panelConfig) { - - // whistle while we wait - var timerId = setTimeout(function() { - timerId = 0; - region.showLoadingMessage("Opening custom view designer..."); - }, 500); - - LABKEY.DataRegion.loadViewDesigner(function() { - - var success = function(queryDetails, viewName, userColumns, userFilter, userSort) { - timerId > 0 ? clearTimeout(timerId) : this.hideLoadingMessage(); - - // If there was an error parsing the query, we won't be able to render the customize view panel. - if (queryDetails.exception) { - var viewSourceUrl = LABKEY.ActionURL.buildURL('query', 'viewQuerySource.view', this.containerPath, { - schemaName: this.schemaName, - 'query.queryName': this.queryName - }); - var msg = LABKEY.Utils.encodeHtml(queryDetails.exception) + - "  View Source"; - - this.showErrorMessage(msg); - return; - } - - this.customizeView = Ext4.create('LABKEY.internal.ViewDesigner.Designer', { - renderTo: Ext4.getBody().createChild({tag: 'div', customizeView: true, style: {display: 'none'}}), - activeTab: activeTab, - dataRegion: this, - containerPath : this.containerPath, - schemaName: this.schemaName, - queryName: this.queryName, - viewName: viewName, - query: queryDetails, - userFilter: userFilter, - userSort: userSort, - userColumns: userColumns, - userContainerFilter: this.getUserContainerFilter(), - allowableContainerFilters: this.allowableContainerFilters - }); - - this.customizeView.on('viewsave', function(designer, savedViewsInfo, urlParameters) { - _onViewSave.apply(this, [this, designer, savedViewsInfo, urlParameters]); - }, this); - - this.customizeView.on({ - beforedeleteview: function(cv, revert) { - _beforeViewDelete(region, revert); - }, - deleteview: function(cv, success, json) { - _onViewDelete(region, success, json); - } - }); - - var first = true; - - // Called when customize view needs to be shown - var showFn = function(id, panel, element, callback, scope) { - if (first) { - panel.hide(); - panel.getEl().appendTo(Ext4.get(element[0])); - first = false; - } - panel.doLayout(); - $(panel.getEl().dom).slideDown(undefined, function() { - panel.show(); - callback.call(scope); - }); - }; - - // Called when customize view needs to be hidden - var hideFn = function(id, panel, element, callback, scope) { - $(panel.getEl().dom).slideUp(undefined, function() { - panel.hide(); - callback.call(scope); - }); - }; - - this.publishPanel(CUSTOM_VIEW_PANELID, this.customizeView, showFn, hideFn, this); - this.showPanel(CUSTOM_VIEW_PANELID); - }; - var failure = function() { - timerId > 0 ? clearTimeout(timerId) : this.hideLoadingMessage(); - }; - - this.getQueryDetails(success, failure, this); - }, region); - } - else { - if (activeTab) { - panelConfig.panel.setActiveDesignerTab(activeTab); - } - this.showPanel(CUSTOM_VIEW_PANELID); - } - }; - - /** - * @ignore - * @private - * Shows/Hides customize view depending on if it is currently shown - */ - LABKEY.DataRegion.prototype.toggleShowCustomizeView = function() { - if (this.activePanelId === CUSTOM_VIEW_PANELID) { - this.hideCustomizeView(); - } - else { - this.showCustomizeView(undefined); - } - }; - - var _defaultShow = function(panelId, panel, ribbon, cb, cbScope) { - $('#' + panelId).slideDown(undefined, function() { - cb.call(cbScope); - }); - }; - - var _defaultHide = function(panelId, panel, ribbon, cb, cbScope) { - $('#' + panelId).slideUp(undefined, function() { - cb.call(cbScope); - }); - }; - - // TODO this is a pretty bad prototype, consider using config parameter with backward compat option - LABKEY.DataRegion.prototype.publishPanel = function(panelId, panel, showFn, hideFn, scope, friendlyName) { - this.panelConfigurations[panelId] = { - panelId: panelId, - panel: panel, - show: $.isFunction(showFn) ? showFn : _defaultShow, - hide: $.isFunction(hideFn) ? hideFn : _defaultHide, - scope: scope - }; - if (friendlyName && friendlyName !== panelId) - this.panelConfigurations[friendlyName] = this.panelConfigurations[panelId]; - return this; - }; - - LABKEY.DataRegion.prototype.getPanelConfiguration = function(panelId) { - return this.panelConfigurations[panelId]; - }; - - /** - * @ignore - * Hides any panel that is currently visible. Returns a callback once the panel is hidden. - */ - LABKEY.DataRegion.prototype.hidePanel = function(callback, scope) { - if (this.activePanelId) { - var config = this.getPanelConfiguration(this.activePanelId); - if (config) { - - // find the ribbon container - var ribbon = _getDrawerSelector(this); - - config.hide.call(config.scope || this, this.activePanelId, config.panel, ribbon, function() { - this.activePanelId = undefined; - ribbon.hide(); - if ($.isFunction(callback)) { - callback.call(scope || this); - } - LABKEY.Utils.signalWebDriverTest("dataRegionPanelHide"); - $(this).trigger($.Event('afterpanelhide'), [this]); - }, this); - } - } - else { - if ($.isFunction(callback)) { - callback.call(scope || this); - } - } - }; - - LABKEY.DataRegion.prototype.showPanel = function(panelId, callback, scope) { - - var config = this.getPanelConfiguration(panelId); - - if (!config) { - console.error('Unable to find panel for id (' + panelId + '). Use publishPanel() to register a panel to be shown.'); - return; - } - - this.hideContext(); - this.hideMessage(true); - - this.hidePanel(function() { - this.activePanelId = config.panelId; - - // ensure the ribbon is visible - var ribbon = _getDrawerSelector(this); - ribbon.show(); - - config.show.call(config.scope || this, this.activePanelId, config.panel, ribbon, function() { - if ($.isFunction(callback)) { - callback.call(scope || this); - } - LABKEY.Utils.signalWebDriverTest("dataRegionPanelShow"); - $(this).trigger($.Event('afterpanelshow'), [this]); - }, this); - }, this); - }; - - function _hasPanelOpen(dr) { - return dr.activePanelId !== undefined; - } - - function _hasButtonBarMenuOpen(dr) { - return _getBarSelector(dr).find(".lk-menu-drop.open").length > 0; - } - - /** - * Returns true if the user has interacted with the DataRegion by changing - * the selection, opening a button menu, or opening a panel. - * @return {boolean} - * @private - */ - LABKEY.DataRegion.prototype.isUserInteracting = function () { - return this.selectionModified || _hasPanelOpen(this) || _hasButtonBarMenuOpen(this); - }; - - // - // Misc - // - - /** - * @private - */ - var _initHeaderLocking = function() { - if (this._allowHeaderLock === true) { - this.hLock = new HeaderLock(this); - } - }; - - /** - * @private - */ - var _initPanes = function() { - var callbacks = _paneCache[this.name]; - if (callbacks) { - var me = this; - callbacks.forEach(function(config) { - config.cb.call(config.scope || me, me); - }); - delete _paneCache[this.name]; - } - }; - - /** - * @private - */ - var _initReport = function() { - if (LABKEY.Utils.isObject(this.report)) { - this.addMessage({ - html: [ - 'Name:', - LABKEY.Utils.encodeHtml(this.report.name), - 'Source:', - LABKEY.Utils.encodeHtml(this.report.source) - ].join(' '), - part: 'report', - }); - } - }; - - // These study specific functions/constants should be moved out of Data Region - // and into their own dependency. - - var COHORT_LABEL = '/Cohort/Label'; - var ADV_COHORT_LABEL = '/InitialCohort/Label'; - var COHORT_ENROLLED = '/Cohort/Enrolled'; - var ADV_COHORT_ENROLLED = '/InitialCohort/Enrolled'; - - /** - * DO NOT CALL DIRECTLY. This method is private and only available for removing cohort/group filters - * for this Data Region. - * @param subjectColumn - * @param groupNames - * @private - */ - LABKEY.DataRegion.prototype._removeCohortGroupFilters = function(subjectColumn, groupNames) { - this.clearSelected({quiet: true}); - var params = _getParameters(this); - var skips = [], i, p, k; - - var keys = [ - subjectColumn + COHORT_LABEL, - subjectColumn + ADV_COHORT_LABEL, - subjectColumn + COHORT_ENROLLED, - subjectColumn + ADV_COHORT_ENROLLED - ]; - - if (LABKEY.Utils.isArray(groupNames)) { - for (k=0; k < groupNames.length; k++) { - keys.push(subjectColumn + '/' + groupNames[k]); - } - } - - for (i = 0; i < params.length; i++) { - p = params[i][0]; - if (p.indexOf(this.name + '.') === 0) { - for (k=0; k < keys.length; k++) { - if (p.indexOf(keys[k] + '~') > -1) { - skips.push(p); - k = keys.length; // break loop - } - } - } - } - - _updateFilter(this, undefined, skips); - }; - - /** - * DO NOT CALL DIRECTLY. This method is private and only available for replacing advanced cohort filters - * for this Data Region. Remove if advanced cohorts are removed. - * @param filter - * @private - */ - LABKEY.DataRegion.prototype._replaceAdvCohortFilter = function(filter) { - this.clearSelected({quiet: true}); - var params = _getParameters(this); - var skips = [], i, p; - - for (i = 0; i < params.length; i++) { - p = params[i][0]; - if (p.indexOf(this.name + '.') === 0) { - if (p.indexOf(COHORT_LABEL) > -1 || p.indexOf(ADV_COHORT_LABEL) > -1 || p.indexOf(COHORT_ENROLLED) > -1 || p.indexOf(ADV_COHORT_ENROLLED)) { - skips.push(p); - } - } - } - - _updateFilter(this, filter, skips); - }; - - /** - * Looks for a column based on fieldKey, name, displayField, or caption (in that order) - * @param columnIdentifier - * @returns {*} - */ - LABKEY.DataRegion.prototype.getColumn = function(columnIdentifier) { - - var column = null, // backwards compat - isString = LABKEY.Utils.isString, - cols = this.columns; - - if (isString(columnIdentifier) && LABKEY.Utils.isArray(cols)) { - $.each(['fieldKey', 'name', 'displayField', 'caption'], function(i, key) { - $.each(cols, function(c, col) { - if (isString(col[key]) && col[key] == columnIdentifier) { - column = col; - return false; - } - }); - if (column) { - return false; - } - }); - } - - return column; - }; - - /** - * Returns a query config object suitable for passing into LABKEY.Query.selectRows() or other LABKEY.Query APIs. - * @returns {Object} Object representing the query configuration that generated this grid. - */ - LABKEY.DataRegion.prototype.getQueryConfig = function() { - var config = { - dataRegionName: this.name, - dataRegionSelectionKey: this.selectionKey, - schemaName: this.schemaName, - viewName: this.viewName, - sort: this.getParameter(this.name + SORT_PREFIX), - // NOTE: The parameterized query values from QWP are included - parameters: this.getParameters(false), - containerFilter: this.containerFilter - }; - - if (this.queryName) { - config.queryName = this.queryName; - } - else if (this.sql) { - config.sql = this.sql; - } - - var filters = this.getUserFilterArray(); - if (filters.length > 0) { - config.filters = filters; - } - - return config; - }; - - /** - * Hide the ribbon panel. If visible the ribbon panel will be hidden. - */ - LABKEY.DataRegion.prototype.hideButtonPanel = function() { - this.hidePanel(); - this.showContext(); - this.showMessageArea(); - }; - - /** - * Allows for asynchronous rendering of the Data Region. This region must be in "async" mode for - * this to do anything. - * @function - * @param {String} [renderTo] - The element ID where to render the data region. If not given it will default to - * the current renderTo target is. - */ - LABKEY.DataRegion.prototype.render = function(renderTo) { - if (!this.RENDER_LOCK && this.async) { - _convertRenderTo(this, renderTo); - this.refresh(); - } - }; - - /** - * Show a ribbon panel. - * - * first arg can be button on the button bar or target panel id/configuration - */ - - LABKEY.DataRegion.prototype.toggleButtonPanelHandler = function(panelButton) { - _toggleButtonPanel( this, $(panelButton).attr('data-labkey-panel-toggle'), null, true); - }; - - LABKEY.DataRegion.prototype.showButtonPanel = function(panel, optionalTab) { - _toggleButtonPanel(this, panel, optionalTab, false); - }; - - LABKEY.DataRegion.prototype.toggleButtonPanel = function(panel, optionalTab) { - _toggleButtonPanel(this, panel, optionalTab, true); - }; - - var _toggleButtonPanel = function(dr, panel, optionalTab, toggle) { - var ribbon = _getDrawerSelector(dr); - // first check if this is a named panel instead of a button element - var panelId, panelSel; - if (typeof panel === 'string' && dr.getPanelConfiguration(panel)) - panelId = dr.getPanelConfiguration(panel).panelId; - else - panelId = panel; - - if (panelId) { - - panelSel = $('#' + panelId); - - // allow for toggling the state - if (panelId === dr.activePanelId) { - if (toggle) { - dr.hideButtonPanel(); - return; - } - } - else { - // determine if the content needs to be moved to the ribbon - if (ribbon.has(panelSel).length === 0) { - panelSel.detach().appendTo(ribbon); - } - - // determine if this panel has been registered - if (!dr.getPanelConfiguration(panelId) && panelSel.length > 0) { - dr.publishPanel(panelId, panelId); - } - - dr.showPanel(panelId); - } - if (optionalTab) - { - var t = panelSel.find('a[data-toggle="tab"][href="#' + optionalTab + '"]'); - if (!t.length) - t = panelSel.find('a[data-toggle="tab"][data-tabName="' + optionalTab + '"]'); - t.tab('show'); - } - } - }; - - LABKEY.DataRegion.prototype.loadFaceting = function(cb, scope) { - - var region = this; - - var onLoad = function() { - region.facetLoaded = true; - if ($.isFunction(cb)) { - cb.call(scope || this); - } - }; - - LABKEY.requiresExt4ClientAPI(function() { - if (LABKEY.devMode) { - // should match study/ParticipantFilter.lib.xml - LABKEY.requiresScript([ - '/study/ReportFilterPanel.js', - '/study/ParticipantFilterPanel.js' - ], function() { - LABKEY.requiresScript('/dataregion/panel/Facet.js', onLoad); - }); - } - else { - LABKEY.requiresScript('/study/ParticipantFilter.min.js', function() { - LABKEY.requiresScript('/dataregion/panel/Facet.js', onLoad); - }); - } - }, this); - }; - - LABKEY.DataRegion.prototype.showFaceting = function() { - if (this.facetLoaded) { - if (!this.facet) { - this.facet = LABKEY.dataregion.panel.Facet.display(this); - } - this.facet.toggleCollapse(); - } - else { - this.loadFaceting(this.showFaceting, this); - } - }; - - LABKEY.DataRegion.prototype.on = function(evt, callback, scope) { - // Prevent from handing back the jQuery event itself. - $(this).bind(evt, function() { callback.apply(scope || this, $(arguments).slice(1)); }); - }; - - LABKEY.DataRegion.prototype.one = function(evt, callback, scope) { - $(this).one(evt, function() { callback.apply(scope || this, $(arguments).slice(1)); }); - }; - - LABKEY.DataRegion.prototype._onButtonClick = function(buttonId) { - var item = this.findButtonById(this.buttonBar.items, buttonId); - if (item && $.isFunction(item.handler)) { - try { - return item.handler.call(item.scope || this, this); - } - catch(ignore) {} - } - return false; - }; - - LABKEY.DataRegion.prototype.findButtonById = function(items, id) { - if (!items || !items.length || items.length <= 0) { - return null; - } - - var ret; - for (var i = 0; i < items.length; i++) { - if (items[i].id == id) { - return items[i]; - } - ret = this.findButtonById(items[i].items, id); - if (null != ret) { - return ret; - } - } - - return null; - }; - - LABKEY.DataRegion.prototype.headerLock = function() { return this._allowHeaderLock === true; }; - - LABKEY.DataRegion.prototype.disableHeaderLock = function() { - if (this.headerLock() && this.hLock) { - this.hLock.disable(); - this.hLock = undefined; - } - }; - - /** - * Add or remove a summary statistic for a given column in the DataRegion query view. - * @param viewName - * @param colFieldKey - * @param summaryStatName - */ - LABKEY.DataRegion.prototype.toggleSummaryStatForCustomView = function(viewName, colFieldKey, summaryStatName) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - var colProviderNames = []; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey === colFieldKey) - colProviderNames.push(existingProvider.name); - }); - - if (colProviderNames.indexOf(summaryStatName) === -1) { - _addAnalyticsProviderToView.call(this, view, colFieldKey, summaryStatName, true); - } - else { - _removeAnalyticsProviderFromView.call(this, view, colFieldKey, summaryStatName, true); - } - } - }, null, this); - }; - - /** - * Get the array of selected ColumnAnalyticsProviders for the given column FieldKey in a view. - * @param viewName - * @param colFieldKey - * @param callback - * @param callbackScope - */ - LABKEY.DataRegion.prototype.getColumnAnalyticsProviders = function(viewName, colFieldKey, callback, callbackScope) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - var colProviderNames = []; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey === colFieldKey) { - colProviderNames.push(existingProvider.name); - } - }); - - if ($.isFunction(callback)) { - callback.call(callbackScope, colProviderNames); - } - } - }, null, this); - }; - - /** - * Set the summary statistic ColumnAnalyticsProviders for the given column FieldKey in the view. - * @param viewName - * @param colFieldKey - * @param summaryStatProviderNames - */ - LABKEY.DataRegion.prototype.setColumnSummaryStatistics = function(viewName, colFieldKey, summaryStatProviderNames) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - var newAnalyticsProviders = []; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey !== colFieldKey || existingProvider.name.indexOf('AGG_') != 0) { - newAnalyticsProviders.push(existingProvider); - } - }); - - $.each(summaryStatProviderNames, function(index, providerName) { - newAnalyticsProviders.push({ - fieldKey: colFieldKey, - name: providerName, - isSummaryStatistic: true - }); - }); - - view.analyticsProviders = newAnalyticsProviders; - _updateSessionCustomView.call(this, view, true); - } - }, null, this); - }; - - /** - * Used via SummaryStatisticsAnalyticsProvider to show a dialog of the applicable summary statistics for a column in the view. - * @param colFieldKey - */ - LABKEY.DataRegion.prototype.showColumnStatisticsDialog = function(colFieldKey) { - LABKEY.requiresScript('query/ColumnSummaryStatistics', function() { - var regionViewName = this.viewName || "", - column = this.getColumn(colFieldKey); - - if (column) { - this.getColumnAnalyticsProviders(regionViewName, colFieldKey, function(colSummaryStats) { - Ext4.create('LABKEY.ext4.ColumnSummaryStatisticsDialog', { - queryConfig: this.getQueryConfig(), - filterArray: LABKEY.Filter.getFiltersFromUrl(this.selectAllURL, 'query'), //Issue 26594 - containerPath: this.containerPath, - column: column, - initSelection: colSummaryStats, - listeners: { - scope: this, - applySelection: function(win, colSummaryStatsNames) { - win.getEl().mask("Applying selection..."); - this.setColumnSummaryStatistics(regionViewName, colFieldKey, colSummaryStatsNames); - win.close(); - } - } - }).show(); - }, this); - } - }, this); - }; - - /** - * Remove a column from the given DataRegion query view. - * @param viewName - * @param colFieldKey - */ - LABKEY.DataRegion.prototype.removeColumn = function(viewName, colFieldKey) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - var colFieldKeys = $.map(view.columns, function (c) { - return c.fieldKey; - }), - fieldKeyIndex = colFieldKeys.indexOf(colFieldKey); - - if (fieldKeyIndex > -1) { - view.columns.splice(fieldKeyIndex, 1); - _updateSessionCustomView.call(this, view, true); - } - } - }, null, this); - }; - - /** - * Add the enabled analytics provider to the custom view definition based on the column fieldKey and provider name. - * In addition, disable the column menu item if the column is visible in the grid. - * @param viewName - * @param colFieldKey - * @param providerName - */ - LABKEY.DataRegion.prototype.addAnalyticsProviderForCustomView = function(viewName, colFieldKey, providerName) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - _addAnalyticsProviderToView.call(this, view, colFieldKey, providerName, false); - _updateAnalyticsProviderMenuItem(this.name + ':' + colFieldKey, providerName, true); - } - }, null, this); - }; - - /** - * Remove an enabled analytics provider from the custom view definition based on the column fieldKey and provider name. - * In addition, enable the column menu item if the column is visible in the grid. - * @param viewName - * @param colFieldKey - * @param providerName - */ - LABKEY.DataRegion.prototype.removeAnalyticsProviderForCustomView = function(viewName, colFieldKey, providerName) { - this.getQueryDetails(function(queryDetails) { - var view = _getViewFromQueryDetails(queryDetails, viewName); - if (view && _viewContainsColumn(view, colFieldKey)) { - _removeAnalyticsProviderFromView.call(this, view, colFieldKey, providerName, false); - _updateAnalyticsProviderMenuItem(this.name + ':' + colFieldKey, providerName, false); - } - }, null, this); - }; - - /** - * @private - */ - LABKEY.DataRegion.prototype._openFilter = function(columnName, evt) { - if (evt && $(evt.target).hasClass('fa-close')) { - return; - } - - var column = this.getColumn(columnName); - - if (column) { - var show = function() { - this._dialogLoaded = true; - new LABKEY.FilterDialog({ - dataRegionName: this.name, - column: this.getColumn(columnName), - cacheFacetResults: false // could have changed on Ajax - }).show(); - }.bind(this); - - this._dialogLoaded ? show() : LABKEY.requiresExt3ClientAPI(show); - } - else { - LABKEY.Utils.alert('Column not available', 'Unable to find column "' + columnName + '" in this view.'); - } - }; - - var _updateSessionCustomView = function(customView, requiresRefresh) { - var viewConfig = $.extend({}, customView, { - shared: false, - inherit: false, - hidden: false, - session: true - }); - - LABKEY.Query.saveQueryViews({ - containerPath: this.containerPath, - schemaName: this.schemaName, - queryName: this.queryName, - views: [viewConfig], - scope: this, - success: function(info) { - if (requiresRefresh) { - this.refresh(); - } - else if (info.views.length === 1) { - this.view = info.views[0]; - _initCustomViews.call(this); - this.showContext(); - } - } - }); - }; - - var _addAnalyticsProviderToView = function(view, colFieldKey, providerName, isSummaryStatistic) { - var colProviderNames = []; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey === colFieldKey) - colProviderNames.push(existingProvider.name); - }); - - if (colProviderNames.indexOf(providerName) === -1) { - view.analyticsProviders.push({ - fieldKey: colFieldKey, - name: providerName, - isSummaryStatistic: isSummaryStatistic - }); - - _updateSessionCustomView.call(this, view, isSummaryStatistic); - } - }; - - var _removeAnalyticsProviderFromView = function(view, colFieldKey, providerName, isSummaryStatistic) { - var indexToRemove = null; - $.each(view.analyticsProviders, function(index, existingProvider) { - if (existingProvider.fieldKey === colFieldKey && existingProvider.name === providerName) { - indexToRemove = index; - return false; - } - }); - - if (indexToRemove != null) { - view.analyticsProviders.splice(indexToRemove, 1); - _updateSessionCustomView.call(this, view, isSummaryStatistic); - } - }; - - /** - * Attempt to find a DataRegion analytics provider column menu item so that it can be either enabled to allow - * it to once again be selected after removal or disabled so that it can't be selected a second time. - * @param columnName the DataRegion column th element column-name attribute - * @param providerName the analytics provider name - * @param disable - * @private - */ - var _updateAnalyticsProviderMenuItem = function(columnName, providerName, disable) { - var menuItemEl = $("th[column-name|='" + columnName + "']").find("a[onclick*='" + providerName + "']").parent(); - if (menuItemEl) { - if (disable) { - menuItemEl.addClass('disabled'); - } - else { - menuItemEl.removeClass('disabled'); - } - } - }; - - // - // PRIVATE FUNCTIONS - // - var _applyOptionalParameters = function(region, params, optionalParams) { - optionalParams.forEach(function(p) { - if (LABKEY.Utils.isObject(p)) { - if (region[p.name] !== undefined) { - if (p.check && !p.check.call(region, region[p.name])) { - return; - } - if (p.prefix) { - params[region.name + '.' + p.name] = region[p.name]; - } - else { - params[p.name] = region[p.name]; - } - } - } - else if (p && region[p] !== undefined) { - params[p] = region[p]; - } - }); - }; - - var _alterSortString = function(region, current, fieldKey, direction /* optional */) { - fieldKey = _resolveFieldKey(region, fieldKey); - - var columnName = fieldKey.toString(), - newSorts = []; - - if (current != null) { - current.split(',').forEach(function(sort) { - if (sort.length > 0 && (sort != columnName) && (sort != SORT_ASC + columnName) && (sort != SORT_DESC + columnName)) { - newSorts.push(sort); - } - }); - } - - if (direction === SORT_ASC) { // Easier to read without the encoded + on the URL... - direction = ''; - } - - if (LABKEY.Utils.isString(direction)) { - newSorts = [direction + columnName].concat(newSorts); - } - - return newSorts.join(','); - }; - - var _ensureFilterDateFormat = function(value) { - if (LABKEY.Utils.isDate(value)) { - value = $.format.date(value, 'yyyy-MM-dd'); - if (LABKEY.Utils.endsWith(value, 'Z')) { - value = value.substring(0, value.length - 1); - } - } - - return value; - } - - var _buildQueryString = function(region, pairs) { - if (!LABKEY.Utils.isArray(pairs)) { - return ''; - } - - var queryParts = [], key, value; - - pairs.forEach(function(pair) { - key = pair[0]; - value = pair.length > 1 ? pair[1] : undefined; - - queryParts.push(encodeURIComponent(key)); - if (LABKEY.Utils.isDefined(value)) { - - value = _ensureFilterDateFormat(value); - queryParts.push('='); - queryParts.push(encodeURIComponent(value)); - } - queryParts.push('&'); - }); - - if (queryParts.length > 0) { - queryParts.pop(); - } - - return queryParts.join(""); - }; - - var _chainSelectionCountCallback = function(region, config) { - - var success = LABKEY.Utils.getOnSuccess(config); - - // On success, update the current selectedCount on this DataRegion and fire the 'selectchange' event - config.success = function(data) { - region.removeMessage('selection'); - region.selectionModified = true; - region.selectedCount = data.count; - _onSelectionChange(region); - - // Chain updateSelected with the user-provided success callback - if ($.isFunction(success)) { - success.call(config.scope, data); - } - }; - - return config; - }; - - var _convertRenderTo = function(region, renderTo) { - if (renderTo) { - if (LABKEY.Utils.isString(renderTo)) { - region.renderTo = renderTo; - } - else if (LABKEY.Utils.isString(renderTo.id)) { - region.renderTo = renderTo.id; // support 'Ext' elements - } - else { - throw 'Unsupported "renderTo"'; - } - } - - return region; - }; - - var _deleteTimer; - - var _beforeViewDelete = function(region, revert) { - _deleteTimer = setTimeout(function() { - _deleteTimer = 0; - region.showLoadingMessage(revert ? 'Reverting view...' : 'Deleting view...'); - }, 500); - }; - - var _onViewDelete = function(region, success, json) { - if (_deleteTimer) { - clearTimeout(_deleteTimer); - } - - if (success) { - region.removeMessage.call(region, 'customizeview'); - region.showSuccessMessage.call(region); - - // change view to either a shadowed view or the default view - var config = { type: 'view' }; - if (json.viewName) { - config.viewName = json.viewName; - } - region.changeView.call(region, config); - } - else { - region.removeMessage.call(region, 'customizeview'); - region.showErrorMessage.call(region, json.exception); - } - }; - - // The view can be reverted without ViewDesigner present - var _revertCustomView = function(region) { - _beforeViewDelete(region, true); - - var config = { - schemaName: region.schemaName, - queryName: region.queryName, - containerPath: region.containerPath, - revert: true, - success: function(json) { - _onViewDelete(region, true /* success */, json); - }, - failure: function(json) { - _onViewDelete(region, false /* success */, json); - } - }; - - if (region.viewName) { - config.viewName = region.viewName; - } - - LABKEY.Query.deleteQueryView(config); - }; - - var _getViewFromQueryDetails = function(queryDetails, viewName) { - var matchingView; - - $.each(queryDetails.views, function(index, view) { - if (view.name === viewName) { - matchingView = view; - return false; - } - }); - - return matchingView; - }; - - var _viewContainsColumn = function(view, colFieldKey) { - var keys = $.map(view.columns, function(c) { - return c.fieldKey.toLowerCase(); - }); - var exists = colFieldKey && keys.indexOf(colFieldKey.toLowerCase()) > -1; - - if (!exists) { - console.warn('Unable to find column in view: ' + colFieldKey); - } - - return exists; - }; - - var _getAllRowSelectors = function(region) { - return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".toggle"]'); - }; - - var _getBarSelector = function(region) { - return $('#' + region.domId + '-headerbar'); - }; - - var _getContextBarSelector = function(region) { - return $('#' + region.domId + '-ctxbar'); - }; - - var _getDrawerSelector = function(region) { - return $('#' + region.domId + '-drawer'); - }; - - var _getFormSelector = function(region) { - var form = $('form#' + region.domId + '-form'); - - // derived DataRegion's may not include the form id - if (form.length === 0) { - form = $('#' + region.domId).closest('form'); - } - - return form; - }; - - var _getHeaderSelector = function(region) { - return $('#' + region.domId + '-header'); - }; - - var _getRowSelectors = function(region) { - return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".select"]'); - }; - - var _getSectionSelector = function(region, dir) { - return $('#' + region.domId + '-section-' + dir); - }; - - var _getShowFirstSelector = function(region) { - return $('#' + region.showFirstID); - }; - - var _getShowLastSelector = function(region) { - return $('#' + region.showLastID); - }; - - var _getShowAllSelector = function(region) { - return $('#' + region.showAllID); - }; - - // Formerly, LABKEY.DataRegion.getParamValPairsFromString / LABKEY.DataRegion.getParamValPairs - var _getParameters = function(region, skipPrefixSet /* optional */) { - - var params = []; - var qString = region.requestURL; - - if (LABKEY.Utils.isString(qString) && qString.length > 0) { - - var qmIdx = qString.indexOf('?'); - if (qmIdx > -1) { - qString = qString.substring(qmIdx + 1); - - var poundIdx = qString.indexOf('#'); - if (poundIdx > -1) - qString = qString.substr(0, poundIdx); - - if (qString.length > 1) { - var pairs = qString.split('&'), p, key, - LAST = '.lastFilter', lastIdx, skip = LABKEY.Utils.isArray(skipPrefixSet); - - var exactMatches = EXACT_MATCH_PREFIXES.map(function (prefix) { - return region.name + prefix; - }); - - $.each(pairs, function (i, pair) { - p = pair.split('=', 2); - key = p[0] = decodeURIComponent(p[0]); - lastIdx = key.indexOf(LAST); - - if (lastIdx > -1 && lastIdx === (key.length - LAST.length)) { - return; - } - else if (REQUIRE_NAME_PREFIX.hasOwnProperty(key)) { - // Issue 26686: Block known parameters, should be prefixed by region name - return; - } - - var stop = false; - if (skip) { - $.each(skipPrefixSet, function (j, skipPrefix) { - if (LABKEY.Utils.isString(skipPrefix)) { - - // Special prefix that should remove all filters, but no other parameters for the current grid - if (skipPrefix.indexOf(ALL_FILTERS_SKIP_PREFIX) === (skipPrefix.length - 2)) { - if (key.indexOf(region.name + '.') === 0 && key.indexOf('~') > 0) { - - stop = true; - return false; - } - } - else { - if (exactMatches.indexOf(skipPrefix) > -1) { - if (key === skipPrefix) { - stop = true; - return false; - } - } - else if (key.toLowerCase().indexOf(skipPrefix.toLowerCase()) === 0) { - // only skip filters, parameters, and sorts for the current grid - if (key.indexOf(region.name + '.') === 0 && - - (key === skipPrefix || - key.indexOf('~') > 0 || - key.indexOf(PARAM_PREFIX) > 0 || - key === (skipPrefix + 'sort'))) { - stop = true; - return false; - } - } - } - } - }); - } - - if (!stop) { - if (p.length > 1) { - p[1] = decodeURIComponent(p[1]); - } - params.push(p); - } - }); - } - } - } - - return params; - }; - - /** - * - * @param region - * @param {boolean} [asString=false] - * @private - */ - var _getUserSort = function(region, asString) { - var userSort = [], - sortParam = region.getParameter(region.name + SORT_PREFIX); - - if (asString) { - userSort = sortParam || ''; - } - else { - if (sortParam) { - var fieldKey, dir; - sortParam.split(',').forEach(function(sort) { - fieldKey = sort; - dir = SORT_ASC; - if (sort.charAt(0) === SORT_DESC) { - fieldKey = fieldKey.substring(1); - dir = SORT_DESC; - } - else if (sort.charAt(0) === SORT_ASC) { - fieldKey = fieldKey.substring(1); - } - userSort.push({fieldKey: fieldKey, dir: dir}); - }); - } - } - - return userSort; - }; - - var _getViewBarSelector = function(region) { - return $('#' + region.domId + '-viewbar'); - }; - - var _buttonSelectionBind = function(region, cls, fn) { - var partEl = region.msgbox.getParent().find('div[data-msgpart="selection"]'); - partEl.find('.labkey-button' + cls).off('click').on('click', $.proxy(function() { - fn.call(this); - }, region)); - }; - - var _onRenderMessageArea = function(region, parts) { - var msgArea = region.msgbox; - if (msgArea) { - if (region.showRecordSelectors && parts['selection']) { - _buttonSelectionBind(region, '.select-all', region.selectAll); - _buttonSelectionBind(region, '.select-none', region.clearSelected); - _buttonSelectionBind(region, '.show-all', region.showAll); - _buttonSelectionBind(region, '.show-selected', region.showSelectedRows); - _buttonSelectionBind(region, '.show-unselected', region.showUnselectedRows); - } - else if (parts['customizeview']) { - _buttonSelectionBind(region, '.unsavedview-revert', function() { _revertCustomView(this); }); - _buttonSelectionBind(region, '.unsavedview-edit', function() { this.showCustomizeView(undefined); }); - _buttonSelectionBind(region, '.unsavedview-save', function() { _saveSessionCustomView(this); }); - } - } - }; - - var _onSelectionChange = function(region) { - $(region).trigger('selectchange', [region, region.selectedCount]); - _updateRequiresSelectionButtons(region, region.selectedCount); - LABKEY.Utils.signalWebDriverTest('dataRegionUpdate', region.selectedCount); - LABKEY.Utils.signalWebDriverTest('dataRegionUpdate-' + region.name, region.selectedCount); - }; - - var _onViewSave = function(region, designer, savedViewsInfo, urlParameters) { - if (savedViewsInfo && savedViewsInfo.views.length > 0) { - region.hideCustomizeView.call(region); - region.changeView.call(region, { - type: 'view', - viewName: savedViewsInfo.views[0].name - }, urlParameters); - } - }; - - var _removeParameters = function(region, skipPrefixes /* optional */) { - return _setParameters(region, null, skipPrefixes); - }; - - var _resolveFieldKey = function(region, fieldKey) { - var fk = fieldKey; - if (!(fk instanceof LABKEY.FieldKey)) { - fk = LABKEY.FieldKey.fromString('' + fk); - } - return fk; - }; - - var _saveSessionCustomView = function(region) { - // Note: currently only will save session views. Future version could create a new view using url sort/filters. - if (!(region.view && region.view.session)) { - return; - } - - // Get the canEditSharedViews permission and candidate targetContainers. - var viewName = (region.view && region.view.name) || region.viewName || ''; - - LABKEY.Query.getQueryDetails({ - schemaName: region.schemaName, - queryName: region.queryName, - viewName: viewName, - initializeMissingView: false, - containerPath: region.containerPath, - success: function (json) { - // Display an error if there was an issue error getting the query details - if (json.exception) { - var viewSourceUrl = LABKEY.ActionURL.buildURL('query', 'viewQuerySource.view', null, {schemaName: this.schemaName, "query.queryName": this.queryName}); - var msg = LABKEY.Utils.encodeHtml(json.exception) + "  View Source"; - - this.showErrorMessage.call(this, msg); - return; - } - - _saveSessionShowPrompt(this, json); - }, - scope: region - }); - }; - - var _saveSessionView = function(o, region, win) { - var timerId = setTimeout(function() { - timerId = 0; - Ext4.Msg.progress("Saving...", "Saving custom view..."); - }, 500); - - var jsonData = { - schemaName: region.schemaName, - "query.queryName": region.queryName, - "query.viewName": region.viewName, - newName: o.name, - inherit: o.inherit, - shared: o.shared, - hidden: o.hidden, - replace: o.replace, - }; - - if (o.inherit) { - jsonData.containerPath = o.containerPath; - } - - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'saveSessionView', region.containerPath), - method: 'POST', - jsonData: jsonData, - callback: function() { - if (timerId > 0) - clearTimeout(timerId); - win.close(); - }, - success: function() { - region.showSuccessMessage.call(region); - region.changeView.call(region, {type: 'view', viewName: o.name}); - }, - failure: function(resp) { - var json = resp.responseText ? Ext4.decode(resp.responseText) : resp; - if (json.exception && json.exception.indexOf('A saved view by the name') === 0) { - - Ext4.Msg.show({ - title : "Duplicate View Name", - msg : json.exception + " Would you like to replace it?", - cls : 'data-window', - icon : Ext4.Msg.QUESTION, - buttons : Ext4.Msg.YESNO, - fn : function(btn) { - if (btn === 'yes') { - o.replace = true; - _saveSessionView(o, region, win); - } - }, - scope : this - }); - } - else - Ext4.Msg.alert('Error saving view', json.exception || json.statusText || Ext4.decode(json.responseText).exception); - }, - scope: region - }); - }; - - var _saveSessionShowPrompt = function(region, queryDetails) { - LABKEY.DataRegion.loadViewDesigner(function() { - var config = Ext4.applyIf({ - allowableContainerFilters: region.allowableContainerFilters, - targetContainers: queryDetails.targetContainers, - canEditSharedViews: queryDetails.canEditSharedViews, - canEdit: LABKEY.DataRegion.getCustomViewEditableErrors(config).length == 0, - success: function (win, o) { - _saveSessionView(o, region, win); - }, - scope: region - }, region.view); - - LABKEY.internal.ViewDesigner.Designer.saveCustomizeViewPrompt(config); - }); - }; - - var _setParameter = function(region, param, value, skipPrefixes /* optional */) { - _setParameters(region, [[param, value]], skipPrefixes); - }; - - var _setParameters = function(region, newParamValPairs, skipPrefixes /* optional */) { - // prepend region name - // e.g. ['.hello', '.goodbye'] becomes ['aqwp19.hello', 'aqwp19.goodbye'] - if (LABKEY.Utils.isArray(skipPrefixes)) { - skipPrefixes.forEach(function(skip, i) { - if (skip && skip.indexOf(region.name + '.') !== 0) { - skipPrefixes[i] = region.name + skip; - } - }); - } - - var param, value, - params = _getParameters(region, skipPrefixes); - - if (LABKEY.Utils.isArray(newParamValPairs)) { - newParamValPairs.forEach(function(newPair) { - if (!LABKEY.Utils.isArray(newPair)) { - throw new Error("DataRegion: _setParameters newParamValPairs improperly initialized. It is an array of arrays. You most likely passed in an array of strings."); - } - param = newPair[0]; - value = newPair[1]; - - // Allow value to be null/undefined to support no-value filter types (Is Blank, etc) - if (LABKEY.Utils.isString(param) && param.length > 1) { - if (param.indexOf(region.name) !== 0) { - param = region.name + param; - } - - params.push([param, value]); - } - }); - } - - if (region.async) { - _load(region, params, skipPrefixes); - } - else { - region.setSearchString.call(region, region.name, _buildQueryString(region, params)); - } - }; - - var _showRows = function(region, showRowsEnum) { - // no need to re-query for totalRowCount, if async - this.skipTotalRowCount = true; - - // clear sibling parameters, could we do this with events? - this.maxRows = undefined; - this.offset = 0; - - _setParameter(region, SHOW_ROWS_PREFIX, showRowsEnum, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); - }; - - var _showSelectMessage = function(region, msg) { - if (region.showRecordSelectors) { - if (region.totalRows && region.totalRows !== region.selectedCount && region.selectedCount < MAX_SELECTION_SIZE) { - let text = 'Select All Rows'; - if (region.totalRows > MAX_SELECTION_SIZE) { - text = `Select First ${MAX_SELECTION_SIZE.toLocaleString()} Rows`; - } - msg += " " + text + ""; - } - - msg += " " + "Select None"; - var showOpts = []; - if (region.showRows !== 'all' && !_isMaxRowsAllRows(region)) - showOpts.push("Show All"); - if (region.showRows !== 'selected') - showOpts.push("Show Selected"); - if (region.showRows !== 'unselected') - showOpts.push("Show Unselected"); - msg += "  " + showOpts.join(" "); - } - - // add the record selector message, the link handlers will get added after render in _onRenderMessageArea - region.addMessage.call(region, msg, 'selection'); - }; - - var _toggleAllRows = function(region, checked) { - var ids = []; - - _getRowSelectors(region).each(function() { - if (!this.disabled) { - this.checked = checked; - ids.push(this.value); - } - }); - - _getAllRowSelectors(region).each(function() { this.checked = checked === true; }); - return ids; - }; - - /** - * Asynchronous loader for a DataRegion - * @param region {DataRegion} - * @param [newParams] {string} - * @param [skipPrefixes] {string[]} - * @param [callback] {Function} - * @param [scope] - * @private - */ - var _load = function(region, newParams, skipPrefixes, callback, scope) { - - var params = _getAsyncParams(region, newParams ? newParams : _getParameters(region), skipPrefixes); - var jsonData = _getAsyncBody(region, params); - - // TODO: This should be done in _getAsyncParams, but is not since _getAsyncBody relies on it. Refactor it. - // ensure SQL is not on the URL -- we allow any property to be pulled through when creating parameters. - if (params.sql) { - delete params.sql; - } - - /** - * The target jQuery element that will be either written to or replaced - */ - var target; - - /** - * Flag used to determine if we should replace target element (default) or write to the target contents - * (used during QWP render for example) - * @type {boolean} - */ - var useReplace = true; - - /** - * The string identifier for where the region will render. Mainly used to display useful messaging upon failure. - * @type {string} - */ - var renderEl; - - if (region.renderTo) { - useReplace = false; - renderEl = region.renderTo; - target = $('#' + region.renderTo); - } - else if (!region.domId) { - throw '"renderTo" must be specified either upon construction or when calling render()'; - } - else { - renderEl = region.domId; - target = $('#' + region.domId); - - // attempt to find the correct node to render to... - var form = _getFormSelector(region); - if (form.length && form.parent('div').length) { - target = form.parent('div'); - } - else { - // next best render target - throw 'unable to find a good target element. Perhaps this region is not using the standard renderer?' - } - } - var timerId = setTimeout(function() { - timerId = 0; - if (target) { - target.html("
" + - "
loading...
" + - "
"); - } - }, 500); - - LABKEY.Ajax.request({ - timeout: region.timeout === undefined ? DEFAULT_TIMEOUT : region.timeout, - url: LABKEY.ActionURL.buildURL('project', 'getWebPart.api', region.containerPath), - method: 'POST', - params: params, - jsonData: jsonData, - success: function(response) { - if (timerId > 0) { - clearTimeout(timerId);//load mask task no longer needed - } - this.hidePanel(function() { - if (target.length) { - - this.destroy(); - - LABKEY.Utils.loadAjaxContent(response, target, function() { - - if ($.isFunction(callback)) { - callback.call(scope); - } - - if ($.isFunction(this._success)) { - this._success.call(this.scope || this, this, response); - } - - $(this).trigger('success', [this, response]); - - this.RENDER_LOCK = true; - $(this).trigger('render', this); - this.RENDER_LOCK = false; - }, this, useReplace); - } - else { - // not finding element considered a failure - if ($.isFunction(this._failure)) { - this._failure.call(this.scope || this, response /* json */, response, undefined /* options */, target); - } - else if (!this.suppressRenderErrors) { - LABKEY.Utils.alert('Rendering Error', 'The element "' + renderEl + '" does not exist in the document. You may need to specify "renderTo".'); - } - } - }, this); - }, - failure: LABKEY.Utils.getCallbackWrapper(function(json, response, options) { - - if (target.length) { - if ($.isFunction(this._failure)) { - this._failure.call(this.scope || this, json, response, options); - } - else if (this.errorType === 'html') { - if (useReplace) { - target.replaceWith('
' + LABKEY.Utils.encodeHtml(json.exception) + '
'); - } - else { - target.html('
' + LABKEY.Utils.encodeHtml(json.exception) + '
'); - } - } - } - else if (!this.suppressRenderErrors) { - LABKEY.Utils.alert('Rendering Error', 'The element "' + renderEl + '" does not exist in the document. You may need to specify "renderTo".'); - } - }, region, true), - scope: region - }); - - if (region.async && !region.complete && region.showPaginationCountAsync && !region.skipTotalRowCount) { - _loadAsyncTotalRowCount(region, params, jsonData); - } - region.skipTotalRowCount = false; - }; - - var totalRowCountRequests = {}; // track the request per region name so that we cancel the correct request when necessary - var _loadAsyncTotalRowCount = function(region, params, jsonData) { - // if there is a previous request pending, abort it before starting a new one - var totalRowCountRequest = totalRowCountRequests[region.name]; - if (totalRowCountRequest !== undefined) { - totalRowCountRequest.abort(); - } - - region.totalRows = undefined; - region.loadingTotalRows = true; - - totalRowCountRequests[region.name] = LABKEY.Query.selectRows({ - ...region.getQueryConfig(), - method: 'POST', - containerPath: region.containerPath, - filterArray: LABKEY.Filter.getFiltersFromParameters({ ...params, ...jsonData.filters }, params.dataRegionName), - sort: undefined, - maxRows: 1, - offset: 0, - includeMetadata: false, - includeDetailsColumn: false, - includeUpdateColumn: false, - includeTotalCount: true, - success: function(json) { - totalRowCountRequests[region.name] = undefined; - region.loadingTotalRows = false; - - if (json !== undefined && json.rowCount !== undefined) { - region.totalRows = json.rowCount; - - // update the pagination button disabled state for 'Show Last' and 'Show All' since they include the totalRows count in their calc - var showLast = _showLastEnabled(region); - if (showLast) { - _getShowLastSelector(region).parent('li').removeClass('disabled'); - _getShowAllSelector(region).parent('li').removeClass('disabled'); - } - } - // note: use _getFormSelector instead of _getBarSelector so that we get the floating header as well - _getFormSelector(region).find('a.labkey-paginationText').html(_getPaginationText(region)); - }, - failure: function(error, request) { - var aborted = request.status === 0; - if (!aborted) { - console.error(error); - totalRowCountRequests[region.name] = undefined; - region.loadingTotalRows = false; - _getFormSelector(region).find('a.labkey-paginationText').html(_getPaginationText(region)); - } - } - }); - }; - - var _getAsyncBody = function(region, params) { - var json = {}; - - if (params.sql) { - json.sql = LABKEY.Utils.wafEncode(params.sql); - } - - _processButtonBar(region, json); - - // Issue 10505: add non-removable sorts and filters to json (not url params). - if (region.sort || region.filters || region.aggregates) { - json.filters = {}; - - if (region.filters) { - LABKEY.Filter.appendFilterParams(json.filters, region.filters, region.name); - } - - if (region.sort) { - json.filters[region.dataRegionName + SORT_PREFIX] = region.sort; - } - - if (region.aggregates) { - LABKEY.Filter.appendAggregateParams(json.filters, region.aggregates, region.name); - } - } - - if (region.metadata) { - json.metadata = { - type: region.metadata.type, - value: LABKEY.Utils.wafEncode(region.metadata.value) - }; - } - - return json; - }; - - var _processButtonBar = function(region, json) { - - var bar = region.buttonBar; - - if (bar && (bar.position || (bar.items && bar.items.length > 0))) { - _processButtonBarItems(region, bar.items); - - // only attach if valid - json.buttonBar = bar; - } - }; - - var _processButtonBarItems = function(region, items) { - if (LABKEY.Utils.isArray(items) && items.length > 0) { - for (var i = 0; i < items.length; i++) { - var item = items[i]; - - if (item && $.isFunction(item.handler)) { - item.id = item.id || LABKEY.Utils.id(); - // TODO: A better way? This exposed _onButtonClick isn't very awesome - item.onClick = "return LABKEY.DataRegions['" + region.name + "']._onButtonClick('" + item.id + "');"; - } - - if (item.items) { - _processButtonBarItems(region, item.items); - } - } - } - }; - - var _isFilter = function(region, parameter) { - return parameter && parameter.indexOf(region.name + '.') === 0 && parameter.indexOf('~') > 0; - }; - - var _getAsyncParams = function(region, newParams, skipPrefixes) { - - var params = {}; - var name = region.name; - - // - // Certain parameters are only included if the region is 'async'. These - // were formerly a part of Query Web Part. - // - if (region.async) { - params[name + '.async'] = true; - - if (LABKEY.Utils.isString(region.frame)) { - params['webpart.frame'] = region.frame; - } - - if (LABKEY.Utils.isString(region.bodyClass)) { - params['webpart.bodyClass'] = region.bodyClass; - } - - if (LABKEY.Utils.isString(region.title)) { - params['webpart.title'] = region.title; - } - - if (LABKEY.Utils.isString(region.titleHref)) { - params['webpart.titleHref'] = region.titleHref; - } - - if (LABKEY.Utils.isString(region.columns)) { - params[region.name + '.columns'] = region.columns; - } - - _applyOptionalParameters(region, params, [ - 'allowChooseQuery', - 'allowChooseView', - 'allowHeaderLock', - 'buttonBarPosition', - 'detailsURL', - 'deleteURL', - 'importURL', - 'insertURL', - 'linkTarget', - 'updateURL', - 'shadeAlternatingRows', - 'showBorders', - 'showDeleteButton', - 'showDetailsColumn', - 'showExportButtons', - 'showRStudioButton', - 'showImportDataButton', - 'showInsertNewButton', - 'showPagination', - 'showPaginationCount', - 'showReports', - 'showSurroundingBorder', - 'showFilterDescription', - 'showUpdateColumn', - 'showViewPanel', - 'timeout', - {name: 'disableAnalytics', prefix: true}, - {name: 'maxRows', prefix: true, check: function(v) { return v > 0; }}, - {name: 'showRows', prefix: true}, - {name: 'offset', prefix: true, check: function(v) { return v !== 0; }}, - {name: 'reportId', prefix: true}, - {name: 'viewName', prefix: true} - ]); - - // Sorts configured by the user when interacting with the grid. We need to pass these as URL parameters. - if (LABKEY.Utils.isString(region._userSort) && region._userSort.length > 0) { - params[name + SORT_PREFIX] = region._userSort; - } - - if (region.userFilters) { - $.each(region.userFilters, function(filterExp, filterValue) { - if (params[filterExp] == undefined) { - params[filterExp] = []; - } - params[filterExp].push(filterValue); - }); - region.userFilters = {}; // they've been applied - } - - // TODO: Get rid of this and incorporate it with the normal containerFilter checks - if (region.userContainerFilter) { - params[name + CONTAINER_FILTER_NAME] = region.userContainerFilter; - } - - if (region.parameters) { - var paramPrefix = name + PARAM_PREFIX; - $.each(region.parameters, function(parameter, value) { - var key = parameter; - if (parameter.indexOf(paramPrefix) !== 0) { - key = paramPrefix + parameter; - } - params[key] = value; - }); - } - } - - // - // apply all parameters - // - - var newParamPrefixes = {}; - - if (newParams) { - newParams.forEach(function(pair) { - // Issue 25337: Filters may repeat themselves - if (_isFilter(region, pair[0])) { - if (params[pair[0]] == undefined) { - params[pair[0]] = []; - } - else if (!LABKEY.Utils.isArray(params[pair[0]])) { - params[pair[0]] = [params[pair[0]]]; - } - - var value = pair[1]; - - // Issue 47735: QWP date filter not being formatted - // This needs to be formatted for the response passed back to the grid for the filter display and - // filter dialog to render correctly - value = _ensureFilterDateFormat(value); - - params[pair[0]].push(value); - } - else { - params[pair[0]] = pair[1]; - } - - newParamPrefixes[pair[0]] = true; - }); - } - - // Issue 40226: Don't include parameters that are being logically excluded - if (skipPrefixes) { - skipPrefixes.forEach(function(skipKey) { - if (params.hasOwnProperty(skipKey) && !newParamPrefixes.hasOwnProperty(skipKey)) { - delete params[skipKey]; - } - }); - } - - // - // Properties that cannot be modified - // - - params.dataRegionName = region.name; - params.schemaName = region.schemaName; - params.viewName = region.viewName; - params.reportId = region.reportId; - params.returnUrl = window.location.href; - params['webpart.name'] = 'Query'; - - if (region.queryName) { - params.queryName = region.queryName; - } - else if (region.sql) { - params.sql = region.sql; - } - - var key = region.name + CONTAINER_FILTER_NAME; - var cf = region.getContainerFilter.call(region); - if (cf && !(key in params)) { - params[key] = cf; - } - - return params; - }; - - var _updateFilter = function(region, filter, skipPrefixes) { - var params = []; - if (filter) { - params.push([filter.getURLParameterName(region.name), filter.getURLParameterValue()]); - } - _setParameters(region, params, [OFFSET_PREFIX].concat(skipPrefixes)); - }; - - var _updateRequiresSelectionButtons = function(region, selectedCount) { - - // update the 'select all on page' checkbox state - _getAllRowSelectors(region).each(function() { - if (region.isPageSelected.call(region)) { - this.checked = true; - this.indeterminate = false; - } - else if (region.selectedCount > 0) { - // There are rows selected, but the are not visible on this page. - this.checked = false; - this.indeterminate = true; - } - else { - this.checked = false; - this.indeterminate = false; - } - }); - - // If not all rows are visible and some rows are selected, show selection message - if (region.totalRows && 0 !== region.selectedCount && !region.complete) { - var msg = (region.selectedCount === region.totalRows) ? - 'All ' + region.totalRows.toLocaleString() + ' rows selected.' : - 'Selected ' + region.selectedCount.toLocaleString() + ' of ' + region.totalRows.toLocaleString() + ' rows.'; - _showSelectMessage(region, msg); - } - - // Issue 10566: for javascript perf on IE stash the requires selection buttons - if (!region._requiresSelectionButtons) { - // escape ', ", and \ - var escaped = region.name.replace(/('|"|\\)/g, "\\$1"); - region._requiresSelectionButtons = $("a[data-labkey-requires-selection='" + escaped + "']"); - } - - region._requiresSelectionButtons.each(function() { - var el = $(this); - - var isDropdown = false; - var dropdownBtn = el.parent(); - if (dropdownBtn && dropdownBtn.hasClass('lk-menu-drop') && dropdownBtn.hasClass('dropdown')) - isDropdown = true; - - // handle min-count - var minCount = el.attr('data-labkey-requires-selection-min-count'); - if (minCount) { - minCount = parseInt(minCount); - } - if (minCount === undefined) { - minCount = 1; - } - - // handle max-count - var maxCount = el.attr('data-labkey-requires-selection-max-count'); - if (maxCount) { - maxCount = parseInt(maxCount); - } - - if (minCount <= selectedCount && (!maxCount || maxCount >= selectedCount)) { - el.removeClass('labkey-disabled-button'); - if (isDropdown) - dropdownBtn.removeClass('labkey-disabled-button'); - } - else { - el.addClass('labkey-disabled-button'); - if (isDropdown) - dropdownBtn.addClass('labkey-disabled-button'); - } - }); - }; - - var HeaderLock = function(region) { - - // init - if (!region.headerLock()) { - region._allowHeaderLock = false; - return; - } - - this.region = region; - - var table = $('#' + region.domId); - var firstRow = table.find('tr.labkey-alternate-row').first().children('td'); - - // If no data rows exist just turn off header locking - if (firstRow.length === 0) { - firstRow = table.find('tr.labkey-row').first().children('td'); - if (firstRow.length === 0) { - region._allowHeaderLock = false; - return; - } - } - - var headerRowId = region.domId + '-column-header-row'; - var headerRow = $('#' + headerRowId); - - if (headerRow.length === 0) { - region._allowHeaderLock = false; - return; - } - - var BOTTOM_OFFSET = 100; - - var me = this, - timeout, - locked = false, - lastLeft = 0, - pos = [ 0, 0, 0, 0 ], - domObserver = null; - - // init - var floatRow = headerRow - .clone() - // TODO: Possibly namespace all the ids underneath - .attr('id', headerRowId + '-float') - .css({ - 'box-shadow': '0 4px 4px #DCDCDC', - display: 'none', - position: 'fixed', - top: 0, - 'z-index': 2 - }); - - floatRow.insertAfter(headerRow); - - // respect showPagination but do not use it directly as it may change - var isPagingFloat = region.showPagination; - var floatPaging, floatPagingWidth = 0; - - if (isPagingFloat) { - var pageWidget = _getBarSelector(region).find('.labkey-pagination'); - if (pageWidget.children().length) { - floatPaging = $('
') - .css({ - 'background-color': 'white', - 'box-shadow': '0 4px 4px #DCDCDC', - display: 'none', - 'min-width': pageWidget.width(), - opacity: 0.7, - position: 'fixed', - top: floatRow.height(), - 'z-index': 1 - }) - .on('mouseover', function() { - $(this).css('opacity', '1.0'); - }) - .on('mouseout', function() { - $(this).css('opacity', '0.7') - }); - - var floatingPageWidget = pageWidget.clone(true).css('padding', '4px 8px'); - - // adjust padding when buttons aren't shown - if (!pageWidget.find('.btn-group').length) { - floatingPageWidget.css('padding-bottom', '8px') - } - - floatPaging.append(floatingPageWidget); - table.parent().append(floatPaging); - floatPagingWidth = floatPaging.width(); - } else { - isPagingFloat = false; - } - } - - var disable = function() { - me.region._allowHeaderLock = false; - - if (timeout) { - clearTimeout(timeout); - } - - $(window) - .unbind('load', domTask) - .unbind('resize', resizeTask) - .unbind('scroll', onScroll); - - if (domObserver) { - domObserver.disconnect(); - domObserver = null; - } - }; - - /** - * Configures the 'pos' array containing the following values: - * [0] - X-coordinate of the top of the object relative to the offset parent. - * [1] - Y-coordinate of the top of the object relative to the offset parent. - * [2] - Y-coordinate of the bottom of the object. - * [3] - width of the object - * This method assumes interaction with the Header of the Data Region. - */ - var loadPosition = function() { - var header = headerRow.offset() || {top: 0}; - var table = $('#' + region.domId); - - var bottom = header.top + table.height() - BOTTOM_OFFSET; - var width = headerRow.width(); - pos = [ header.left, header.top, bottom, width ]; - }; - - loadPosition(); - - var onResize = function() { - loadPosition(); - var sub_h = headerRow.find('th'); - - floatRow.width(headerRow.width()).find('th').each(function(i, el) { - $(el).width($(sub_h[i]).width()); - }); - - isPagingFloat && floatPaging.css({ - left: pos[0] - window.pageXOffset + floatRow.width() - floatPaging.width(), - top: floatRow.height() - }); - }; - - /** - * WARNING: This function is called often. Performance implications for each line. - */ - var onScroll = function() { - if (window.pageYOffset >= pos[1] && window.pageYOffset < pos[2]) { - var newLeft = pos[0] - window.pageXOffset; - var newPagingLeft = isPagingFloat ? newLeft + pos[3] - floatPagingWidth : 0; - - var floatRowCSS = { - top: 0 - }; - var pagingCSS = isPagingFloat && { - top: floatRow.height() - }; - - if (!locked) { - locked = true; - floatRowCSS.display = 'table-row'; - floatRowCSS.left = newLeft; - - pagingCSS.display = 'block'; - pagingCSS.left = newPagingLeft; - } - else if (lastLeft !== newLeft) { - floatRowCSS.left = newLeft; - - pagingCSS.left = newPagingLeft; - } - - floatRow.css(floatRowCSS); - isPagingFloat && floatPaging.css(pagingCSS); - - lastLeft = newLeft; - } - else if (locked && window.pageYOffset >= pos[2]) { - var newTop = pos[2] - window.pageYOffset; - - floatRow.css({ - top: newTop - }); - - isPagingFloat && floatPaging.css({ - top: newTop + floatRow.height() - }); - } - else if (locked) { - locked = false; - floatRow.hide(); - isPagingFloat && floatPaging.hide(); - } - }; - - var resizeTask = function(immediate) { - clearTimeout(timeout); - if (immediate) { - onResize(); - } - else { - timeout = setTimeout(onResize, 110); - } - }; - - var isDOMInit = false; - - var domTask = function() { - if (!isDOMInit) { - isDOMInit = true; - // fire immediate to prevent flicker of components when reloading region - resizeTask(true); - } - else { - resizeTask(); - } - onScroll(); - }; - - $(window) - .one('load', domTask) - .on('resize', resizeTask) - .on('scroll', onScroll); - - domObserver = new MutationObserver(mutationList => - mutationList.filter(m => m.type === 'childList').forEach(m => { - m.addedNodes.forEach(domTask); - })); - domObserver.observe(document,{childList: true, subtree: true}); // Issue 13121, 50939 - - // ensure that resize/scroll fire at the end of initialization - domTask(); - - return { - disable: disable - } - }; - - // - // LOADER - // - LABKEY.DataRegion.create = function(config) { - - var region = LABKEY.DataRegions[config.name]; - - if (region) { - // region already exists, update properties - $.each(config, function(key, value) { - region[key] = value; - }); - if (!config.view) { - // when switching back to 'default' view, needs to clear region.view - region.view = undefined; - } - _init.call(region, config); - } - else { - // instantiate a new region - region = new LABKEY.DataRegion(config); - LABKEY.DataRegions[region.name] = region; - } - - return region; - }; - - LABKEY.DataRegion.loadViewDesigner = function(cb, scope) { - LABKEY.requiresExt4Sandbox(function() { - LABKEY.requiresScript('internal/ViewDesigner', cb, scope); - }); - }; - - LABKEY.DataRegion.getCustomViewEditableErrors = function(customView) { - var errors = []; - if (customView && !customView.editable) { - errors.push("The view is read-only and cannot be edited."); - } - return errors; - }; - - LABKEY.DataRegion.registerPane = function(regionName, callback, scope) { - var region = LABKEY.DataRegions[regionName]; - if (region) { - callback.call(scope || region, region); - return; - } - else if (!_paneCache[regionName]) { - _paneCache[regionName] = []; - } - - _paneCache[regionName].push({cb: callback, scope: scope}); - }; - - LABKEY.DataRegion.selectAll = function(config) { - var params = {}; - if (!config.url) { - // DataRegion doesn't have selectAllURL so generate url and query parameters manually - config.url = LABKEY.ActionURL.buildURL('query', 'selectAll.api', config.containerPath); - - config.dataRegionName = config.dataRegionName || 'query'; - - params = LABKEY.Query.buildQueryParams( - config.schemaName, - config.queryName, - config.filters, - null, - config.dataRegionName - ); - - if (config.viewName) - params[config.dataRegionName + VIEWNAME_PREFIX] = config.viewName; - - if (config.containerFilter) - params.containerFilter = config.containerFilter; - - if (config.selectionKey) - params[config.dataRegionName + '.selectionKey'] = config.selectionKey; - - $.each(config.parameters, function(propName, value) { - params[config.dataRegionName + PARAM_PREFIX + propName] = value; - }); - - if (config.ignoreFilter) { - params[config.dataRegionName + '.ignoreFilter'] = true; - } - - // NOTE: ignore maxRows, showRows, and offset - } - - LABKEY.Ajax.request({ - url: config.url, - method: 'POST', - params: params, - success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), - failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) - }); - }; - - /** - * Static method to add or remove items from the selection for a given {@link #selectionKey}. - * - * @param config A configuration object with the following properties: - * @param {String} config.selectionKey See {@link #selectionKey}. - * @param {Array} config.ids Array of primary key ids for each row to select/unselect. - * @param {Boolean} config.checked If true, the ids will be selected, otherwise unselected. - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'count' to indicate the updated selection count. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion#getSelected - * @see LABKEY.DataRegion#clearSelected - */ - LABKEY.DataRegion.setSelected = function(config) { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'setSelected.api', config.containerPath), - method: 'POST', - jsonData: { - checked: config.checked, - id: config.ids || config.id, - key: config.selectionKey, - }, - scope: config.scope, - success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), - failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) - }); - }; - - /** - * Static method to clear all selected items for a given {@link #selectionKey}. - * - * @param config A configuration object with the following properties: - * @param {String} config.selectionKey See {@link #selectionKey}. - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'count' of 0 to indicate an empty selection. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * - * @see LABKEY.DataRegion#setSelected - * @see LABKEY.DataRegion#getSelected - */ - LABKEY.DataRegion.clearSelected = function(config) { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'clearSelected.api', config.containerPath), - method: 'POST', - jsonData: { key: config.selectionKey }, - success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), - failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) - }); - }; - - /** - * Static method to get all selected items for a given {@link #selectionKey}. - * - * @param config A configuration object with the following properties: - * @param {String} config.selectionKey See {@link #selectionKey}. - * @param {Function} config.success The function to be called upon success of the request. - * The callback will be passed the following parameters: - *
    - *
  • data: an object with the property 'selected' that is an array of the primary keys for the selected rows. - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Function} [config.failure] The function to call upon error of the request. - * The callback will be passed the following parameters: - *
    - *
  • errorInfo: an object containing detailed error information (may be null)
  • - *
  • response: The XMLHttpResponse object
  • - *
- * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). - * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. - * @param {boolean} [config.clearSelected] If true, clear the session-based selection for this Data Region after - * retrieving the current selection. Defaults to false. - * - * @see LABKEY.DataRegion#setSelected - * @see LABKEY.DataRegion#clearSelected - */ - LABKEY.DataRegion.getSelected = function(config) { - var jsonData = { key: config.selectionKey }; - - // Issue 41705: Support clearing selection from getSelected() - if (config.clearSelected) { - jsonData.clearSelected = true; - } - - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'getSelected.api', config.containerPath), - method: 'POST', - jsonData: jsonData, - success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), - failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) - }); - }; - - /** - * MessageArea wraps the display of messages in a DataRegion. - * @param dataRegion - The dataregion that the MessageArea will bind itself to. - * @param {String[]} [messages] - An initial messages object containing mappings of 'part' to 'msg' - * @constructor - */ - var MessageArea = function(dataRegion, messages) { - this.bindRegion(dataRegion); - - if (messages) { - this.setMessages(messages); - } - }; - - var MsgProto = MessageArea.prototype; - - MsgProto.bindRegion = function(region) { - this.parentSel = '#' + region.domId + '-msgbox'; - }; - - MsgProto.toJSON = function() { - return this.parts; - }; - - MsgProto.addMessage = function(msg, part, append) { - part = part || 'info'; - - var p = part.toLowerCase(); - if (append && this.parts.hasOwnProperty(p)) - { - this.parts[p] += msg; - this.render(p, msg); - } - else { - this.parts[p] = msg; - this.render(p); - } - }; - - MsgProto.getMessage = function(part) { - return this.parts[part.toLowerCase()]; - }; - - MsgProto.hasMessage = function(part) { - return this.getMessage(part) !== undefined; - }; - - MsgProto.hasContent = function() { - return this.parts && Object.keys(this.parts).length > 0; - }; - - MsgProto.removeAll = function() { - this.parts = {}; - this.render(); - }; - - MsgProto.removeMessage = function(part) { - var p = part.toLowerCase(); - if (this.parts.hasOwnProperty(p)) { - this.parts[p] = undefined; - this.render(); - } - }; - - MsgProto.setMessages = function(messages) { - if (LABKEY.Utils.isObject(messages)) { - this.parts = messages; - } - else { - this.parts = {}; - } - }; - - MsgProto.getParent = function() { - return $(this.parentSel); - }; - - MsgProto.render = function(partToUpdate, appendMsg) { - var hasMsg = false, - me = this, - parent = this.getParent(); - - $.each(this.parts, function(part, msg) { - - if (msg) { - // If this is modified, update the server-side renderer in DataRegion.java renderMessages() - var partEl = parent.find('div[data-msgpart="' + part + '"]'); - if (partEl.length === 0) { - parent.append([ - '
', - msg, - '
' - ].join('')); - } - else if (partToUpdate !== undefined && partToUpdate === part) { - if (appendMsg !== undefined) - partEl.append(appendMsg); - else - partEl.html(msg) - } - - hasMsg = true; - } - else { - parent.find('div[data-msgpart="' + part + '"]').remove(); - delete me.parts[part]; - } - }); - - if (hasMsg) { - this.show(); - $(this).trigger('rendermsg', [this, this.parts]); - } - else { - this.hide(); - parent.html(''); - } - }; - - MsgProto.show = function() { this.getParent().show(); }; - MsgProto.hide = function() { this.getParent().hide(); }; - MsgProto.isVisible = function() { return $(this.parentSel + ':visible').length > 0; }; - MsgProto.find = function(selector) { - return this.getParent().find('.dataregion_msgbox_ct').find(selector); - }; - MsgProto.on = function(evt, callback, scope) { $(this).bind(evt, $.proxy(callback, scope)); }; - - /** - * @description Constructs a LABKEY.QueryWebPart class instance - * @class The LABKEY.QueryWebPart simplifies the task of dynamically adding a query web part to your page. Please use - * this class for adding query web parts to a page instead of {@link LABKEY.WebPart}, - * which can be used for other types of web parts. - *

Additional Documentation: - *

- *

- * @constructor - * @param {Object} config A configuration object with the following possible properties: - * @param {String} config.schemaName The name of the schema the web part will query. - * @param {String} config.queryName The name of the query within the schema the web part will select and display. - * @param {String} [config.viewName] the name of a saved view you wish to display for the given schema and query name. - * @param {String} [config.reportId] the report id of a saved report you wish to display for the given schema and query name. - * @param {Mixed} [config.renderTo] The element id, DOM element, or Ext element inside of which the part should be rendered. This is typically a <div>. - * If not supplied in the configuration, you must call the render() method to render the part into the page. - * @param {String} [config.errorType] A parameter to specify how query parse errors are returned. (default 'html'). Valid - * values are either 'html' or 'json'. If 'html' is specified the error will be rendered to an HTML view, if 'json' is specified - * the errors will be returned to the callback handlers as an array of objects named 'parseErrors' with the following properties: - *
    - *
  • msg: The error message.
  • - *
  • line: The line number the error occurred at (optional).
  • - *
  • col: The column number the error occurred at (optional).
  • - *
  • errorStr: The line from the source query that caused the error (optional).
  • - *
- * @param {String} [config.sql] A SQL query that can be used instead of an existing schema name/query name combination. - * @param {Object} [config.metadata] Metadata that can be applied to the properties of the table fields. Currently, this option is only - * available if the query has been specified through the config.sql option. For full documentation on - * available properties, see LabKey XML Schema Reference. - * This object may contain the following properties: - *
    - *
  • type: The type of metadata being specified. Currently, only 'xml' is supported.
  • - *
  • value: The metadata XML value as a string. For example: '<tables xmlns="http://labkey.org/data/xml"><table tableName="Announcement" tableDbType="NOT_IN_DB"><columns><column columnName="Title"><columnTitle>Custom Title</columnTitle></column></columns></table></tables>'
  • - *
- * @param {String} [config.title] A title for the web part. If not supplied, the query name will be used as the title. - * @param {String} [config.titleHref] If supplied, the title will be rendered as a hyperlink with this value as the href attribute. - * @param {String} [config.buttonBarPosition] DEPRECATED--see config.buttonBar.position - * @param {boolean} [config.allowChooseQuery] If the button bar is showing, whether or not it should be include a button - * to let the user choose a different query. - * @param {boolean} [config.allowChooseView] If the button bar is showing, whether or not it should be include a button - * to let the user choose a different view. - * @param {String} [config.detailsURL] Specify or override the default details URL for the table with one of the form - * "/controller/action.view?id=${RowId}" or "org.labkey.package.MyController$ActionAction.class?id=${RowId}" - * @param {boolean} [config.showDetailsColumn] If the underlying table has a details URL, show a column that renders a [details] link (default true). If true, the record selectors will be included regardless of the 'showRecordSelectors' config option. - * @param {String} [config.updateURL] Specify or override the default updateURL for the table with one of the form - * "/controller/action.view?id=${RowId}" or "org.labkey.package.MyController$ActionAction.class?id=${RowId}" - * @param {boolean} [config.showUpdateColumn] If the underlying table has an update URL, show a column that renders an [edit] link (default true). - * @param {String} [config.insertURL] Specify or override the default insert URL for the table with one of the form - * "/controller/insertAction.view" or "org.labkey.package.MyController$InsertActionAction.class" - * @param {String} [config.importURL] Specify or override the default bulk import URL for the table with one of the form - * "/controller/importAction.view" or "org.labkey.package.MyController$ImportActionAction.class" - * @param {String} [config.deleteURL] Specify or override the default delete URL for the table with one of the form - * "/controller/action.view" or "org.labkey.package.MyController$ActionAction.class". The keys for the selected rows - * will be included in the POST. - * @param {boolean} [config.showImportDataButton] If the underlying table has an import URL, show an "Import Bulk Data" button in the button bar (default true). - * @param {boolean} [config.showInsertNewButton] If the underlying table has an insert URL, show an "Insert New" button in the button bar (default true). - * @param {boolean} [config.showDeleteButton] Show a "Delete" button in the button bar (default true). - * @param {boolean} [config.showReports] If true, show reports on the Views menu (default true). - * @param {boolean} [config.showExportButtons] Show the export button menu in the button bar (default true). - * @param {boolean} [config.showRStudioButton] Show the export to RStudio button menu in the button bar. Requires export button to work. (default false). - * @param {boolean} [config.showBorders] Render the table with borders (default true). - * @param {boolean} [config.showSurroundingBorder] Render the table with a surrounding border (default true). - * @param {boolean} [config.showFilterDescription] Include filter and parameter values in the grid header, if present (default true). - * @param {boolean} [config.showRecordSelectors] Render the select checkbox column (default undefined, meaning they will be shown if the query is updatable by the current user). - * Both 'showDeleteButton' and 'showExportButtons' must be set to false for the 'showRecordSelectors = false' setting to hide the checkboxes. - * @param {boolean} [config.showPagination] Show the pagination links and count (default true). - * @param {boolean} [config.showPaginationCount] Show the total count of rows in the pagination information text (default true). - * @param {boolean} [config.showPaginationCountAsync] Show the total count of rows in the pagination information text, but query for it asynchronously so that the grid data can load initially without it (default false). - * @param {boolean} [config.shadeAlternatingRows] Shade every other row with a light gray background color (default true). - * @param {boolean} [config.suppressRenderErrors] If true, no alert will appear if there is a problem rendering the QueryWebpart. This is most often encountered if page configuration changes between the time when a request was made and the content loads. Defaults to false. - * @param {Object} [config.buttonBar] Button bar configuration. This object may contain any of the following properties: - *
    - *
  • position: Configures where the button bar will appear with respect to the data grid: legal values are 'top', or 'none'. Default is 'top'.
  • - *
  • includeStandardButtons: If true, all standard buttons not specifically mentioned in the items array will be included at the end of the button bar. Default is false.
  • - *
  • items: An array of button bar items. Each item may be either a reference to a standard button, or a new button configuration. - * to reference standard buttons, use one of the properties on {@link #standardButtons}, or simply include a string - * that matches the button's caption. To include a new button configuration, create an object with the following properties: - *
      - *
    • text: The text you want displayed on the button (aka the caption).
    • - *
    • url: The URL to navigate to when the button is clicked. You may use LABKEY.ActionURL to build URLs to controller actions. - * Specify this or a handler function, but not both.
    • - *
    • handler: A reference to the JavaScript function you want called when the button is clicked.
    • - *
    • permission: Optional. Permission that the current user must possess to see the button. - * Valid options are 'READ', 'INSERT', 'UPDATE', 'DELETE', and 'ADMIN'. - * Default is 'READ' if permissionClass is not specified.
    • - *
    • permissionClass: Optional. If permission (see above) is not specified, the fully qualified Java class - * name of the permission that the user must possess to view the button.
    • - *
    • requiresSelection: A boolean value (true/false) indicating whether the button should only be enabled when - * data rows are checked/selected.
    • - *
    • items: To create a drop-down menu button, set this to an array of menu item configurations. - * Each menu item configuration can specify any of the following properties: - *
        - *
      • text: The text of the menu item.
      • - *
      • handler: A reference to the JavaScript function you want called when the menu item is clicked.
      • - *
      • icon: A url to an image to use as the menu item's icon.
      • - *
      • items: An array of sub-menu item configurations. Used for fly-out menus.
      • - *
      - *
    • - *
    - *
  • - *
- * @param {String} [config.columns] Comma-separated list of column names to be shown in the grid, overriding - * whatever might be set in a custom view. - * @param {String} [config.sort] A base sort order to use. This is a comma-separated list of column names, each of - * which may have a - prefix to indicate a descending sort. It will be treated as the final sort, after any that the user - * has defined in a custom view or through interacting with the grid column headers. - * @param {String} [config.removeableSort] An additional sort order to use. This is a comma-separated list of column names, each of - * which may have a - prefix to indicate a descending sort. It will be treated as the first sort, before any that the user - * has defined in a custom view or through interacting with the grid column headers. - * @param {Array} [config.filters] A base set of filters to apply. This should be an array of {@link LABKEY.Filter} objects - * each of which is created using the {@link LABKEY.Filter.create} method. These filters cannot be removed by the user - * interacting with the UI. - * For compatibility with the {@link LABKEY.Query} object, you may also specify base filters using config.filterArray. - * @param {Array} [config.removeableFilters] A set of filters to apply. This should be an array of {@link LABKEY.Filter} objects - * each of which is created using the {@link LABKEY.Filter.create} method. These filters can be modified or removed by the user - * interacting with the UI. - * @param {Object} [config.parameters] Map of name (string)/value pairs for the values of parameters if the SQL - * references underlying queries that are parameterized. For example, the following passes two parameters to the query: {'Gender': 'M', 'CD4': '400'}. - * The parameters are written to the request URL as follows: query.param.Gender=M&query.param.CD4=400. For details on parameterized SQL queries, see - * Parameterized SQL Queries. - * @param {Array} [config.aggregates] An array of aggregate definitions. The objects in this array should have the properties: - *
    - *
  • column: The name of the column to be aggregated.
  • - *
  • type: The aggregate type (see {@link LABKEY.AggregateTypes})
  • - *
  • label: Optional label used when rendering the aggregate row. - *
- * @param {String} [config.showRows] Either 'paginated' (the default) 'selected', 'unselected', 'all', or 'none'. - * When 'paginated', the maxRows and offset parameters can be used to page through the query's result set rows. - * When 'selected' or 'unselected' the set of rows selected or unselected by the user in the grid view will be returned. - * You can programmatically get and set the selection using the {@link LABKEY.DataRegion.setSelected} APIs. - * Setting config.maxRows to -1 is the same as 'all' - * and setting config.maxRows to 0 is the same as 'none'. - * @param {Integer} [config.maxRows] The maximum number of rows to return from the server (defaults to 100). - * If you want to return all possible rows, set this config property to -1. - * @param {Integer} [config.offset] The index of the first row to return from the server (defaults to 0). - * Use this along with the maxRows config property to request pages of data. - * @param {String} [config.dataRegionName] The name to be used for the data region. This should be unique within - * the set of query views on the page. If not supplied, a unique name is generated for you. - * @param {String} [config.linkTarget] The name of a browser window/tab in which to open URLs rendered in the - * QueryWebPart. If not supplied, links will generally be opened in the same browser window/tab where the QueryWebPart. - * @param {String} [config.frame] The frame style to use for the web part. This may be one of the following: - * 'div', 'portal', 'none', 'dialog', 'title', 'left-nav'. - * @param {String} [config.showViewPanel] Open the customize view panel after rendering. The value of this option can be "true" or one of "ColumnsTab", "FilterTab", or "SortTab". - * @param {String} [config.bodyClass] A CSS style class that will be added to the enclosing element for the web part. - * Note, this may not be applied when used in conjunction with some "frame" types (e.g. 'none'). - * @param {Function} [config.success] A function to call after the part has been rendered. It will be passed two arguments: - *
    - *
  • dataRegion: the LABKEY.DataRegion object representing the rendered QueryWebPart
  • - *
  • request: the XMLHTTPRequest that was issued to the server
  • - *
- * @param {Function} [config.failure] A function to call if the request to retrieve the content fails. It will be passed three arguments: - *
    - *
  • json: JSON object containing the exception.
  • - *
  • response: The XMLHttpRequest object containing the response data.
  • - *
  • options: The parameter to the request call.
  • - *
- * @param {Object} [config.scope] An object to use as the callback function's scope. Defaults to this. - * @param {int} [config.timeout] A timeout for the AJAX call, in milliseconds. Default is 30000 (30 seconds). - * @param {String} [config.containerPath] The container path in which the schema and query name are defined. If not supplied, the current container path will be used. - * @param {String} [config.containerFilter] One of the values of {@link LABKEY.Query.containerFilter} that sets the scope of this query. If not supplied, the current folder will be used. - * @example - * <div id='queryTestDiv1'/> - * <script type="text/javascript"> - var qwp1 = new LABKEY.QueryWebPart({ - - renderTo: 'queryTestDiv1', - title: 'My Query Web Part', - schemaName: 'lists', - queryName: 'People', - buttonBarPosition: 'none', - aggregates: [ - {column: 'First', type: LABKEY.AggregateTypes.COUNT, label: 'Total People'}, - {column: 'Age', type: LABKEY.AggregateTypes.MEAN} - ], - filters: [ - LABKEY.Filter.create('Last', 'Flintstone') - ], - sort: '-Last' - }); - - //note that you may also register for the 'render' event - //instead of using the success config property. - //registering for events is done using Ext event registration. - //Example: - qwp1.on("render", onRender); - function onRender() - { - //...do something after the part has rendered... - } - - /////////////////////////////////////// - // Custom Button Bar Example - - var qwp1 = new LABKEY.QueryWebPart({ - renderTo: 'queryTestDiv1', - title: 'My Query Web Part', - schemaName: 'lists', - queryName: 'People', - buttonBar: { - includeStandardButtons: true, - items:[ - LABKEY.QueryWebPart.standardButtons.views, - {text: 'Test', url: LABKEY.ActionURL.buildURL('project', 'begin')}, - {text: 'Test Script', onClick: "alert('Hello World!'); return false;"}, - {text: 'Test Handler', handler: onTestHandler}, - {text: 'Test Menu', items: [ - {text: 'Item 1', handler: onItem1Handler}, - {text: 'Fly Out', items: [ - {text: 'Sub Item 1', handler: onItem1Handler} - ]}, - '-', //separator - {text: 'Item 2', handler: onItem2Handler} - ]}, - LABKEY.QueryWebPart.standardButtons.exportRows - ]} - }); - - function onTestHandler(dataRegion) - { - alert("onTestHandler called!"); - return false; - } - - function onItem1Handler(dataRegion) - { - alert("onItem1Handler called!"); - } - - function onItem2Handler(dataRegion) - { - alert("onItem2Handler called!"); - } - - </script> - */ - LABKEY.QueryWebPart = function(config) { - config._useQWPDefaults = true; - return LABKEY.DataRegion.create(config); - }; -})(jQuery); - -/** - * A read-only object that exposes properties representing standard buttons shown in LabKey data grids. - * These are used in conjunction with the buttonBar configuration. The following buttons are currently defined: - *
    - *
  • LABKEY.QueryWebPart.standardButtons.query
  • - *
  • LABKEY.QueryWebPart.standardButtons.views
  • - *
  • LABKEY.QueryWebPart.standardButtons.charts
  • - *
  • LABKEY.QueryWebPart.standardButtons.insertNew
  • - *
  • LABKEY.QueryWebPart.standardButtons.deleteRows
  • - *
  • LABKEY.QueryWebPart.standardButtons.exportRows
  • - *
  • LABKEY.QueryWebPart.standardButtons.print
  • - *
- * @name standardButtons - * @memberOf LABKEY.QueryWebPart# - */ -LABKEY.QueryWebPart.standardButtons = { - query: 'query', - views: 'grid views', - charts: 'charts', - insertNew: 'insert', - deleteRows: 'delete', - exportRows: 'export', - print: 'print' -}; - -/** - * Requests the query web part content and renders it within the element identified by the renderTo parameter. - * Note that you do not need to call this method explicitly if you specify a renderTo property on the config object - * handed to the class constructor. If you do not specify renderTo in the config, then you must call this method - * passing the id of the element in which you want the part rendered - * @function - * @param renderTo The id of the element in which you want the part rendered. - */ - -LABKEY.QueryWebPart.prototype.render = LABKEY.DataRegion.prototype.render; - -/** - * @returns {LABKEY.DataRegion} - */ -LABKEY.QueryWebPart.prototype.getDataRegion = LABKEY.DataRegion.prototype.getDataRegion; - -LABKEY.AggregateTypes = { - /** - * Displays the sum of the values in the specified column - */ - SUM: 'sum', - /** - * Displays the mean of the values in the specified column - */ - MEAN: 'mean', - /** - * Displays the count of the non-blank values in the specified column - */ - COUNT: 'count', - /** - * Displays the maximum value from the specified column - */ - MIN: 'min', - /** - * Displays the minimum values from the specified column - */ - MAX: 'max', - - /** - * Deprecated - */ - AVG: 'mean' - - // TODO how to allow premium module additions to aggregate types? -}; +/* + * Copyright (c) 2015-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +if (!LABKEY.DataRegions) { + LABKEY.DataRegions = {}; +} + +(function($) { + + // + // CONSTANTS + // + // Issue 48715: Limit the number of rows that can be displayed in a data region + var ALL_ROWS_MAX = 5_000; + var CUSTOM_VIEW_PANELID = '~~customizeView~~'; + var DEFAULT_TIMEOUT = 30_000; + const MAX_SELECTION_SIZE = 1_000; + var PARAM_PREFIX = '.param.'; + var SORT_ASC = '+'; + var SORT_DESC = '-'; + + // + // URL PREFIXES + // + var ALL_FILTERS_SKIP_PREFIX = '.~'; + var COLUMNS_PREFIX = '.columns'; + var CONTAINER_FILTER_NAME = '.containerFilterName'; + var MAX_ROWS_PREFIX = '.maxRows'; + var OFFSET_PREFIX = '.offset'; + var REPORTID_PREFIX = '.reportId'; + var SORT_PREFIX = '.sort'; + var SHOW_ROWS_PREFIX = '.showRows'; + var VIEWNAME_PREFIX = '.viewName'; + + // Issue 33536: These prefixes should match the URL parameter key exactly + var EXACT_MATCH_PREFIXES = [ + COLUMNS_PREFIX, + CONTAINER_FILTER_NAME, + MAX_ROWS_PREFIX, + OFFSET_PREFIX, + REPORTID_PREFIX, + SORT_PREFIX, + SHOW_ROWS_PREFIX, + VIEWNAME_PREFIX + ]; + + var VALID_LISTENERS = [ + /** + * @memberOf LABKEY.DataRegion.prototype + * @name afterpanelhide + * @event LABKEY.DataRegion.prototype#hidePanel + * @description Fires after hiding a visible 'Customize Grid' panel. + */ + 'afterpanelhide', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name afterpanelshow + * @event LABKEY.DataRegion.prototype.showPanel + * @description Fires after showing 'Customize Grid' panel. + */ + 'afterpanelshow', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforechangeview + * @event + * @description Fires before changing grid/view/report. + * @see LABKEY.DataRegion#changeView + */ + 'beforechangeview', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforeclearsort + * @event + * @description Fires before clearing sort applied to grid. + * @see LABKEY.DataRegion#clearSort + */ + 'beforeclearsort', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforemaxrowschange + * @event + * @description Fires before change page size. + * @see LABKEY.DataRegion#setMaxRows + */ + 'beforemaxrowschange', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforeoffsetchange + * @event + * @description Fires before change page number. + * @see LABKEY.DataRegion#setPageOffset + */ + 'beforeoffsetchange', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforerefresh + * @event + * @description Fires before refresh grid. + * @see LABKEY.DataRegion#refresh + */ + 'beforerefresh', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforesetparameters + * @event + * @description Fires before setting the parameterized query values for this query. + * @see LABKEY.DataRegion#setParameters + */ + 'beforesetparameters', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name beforesortchange + * @event + * @description Fires before change sorting on the grid. + * @see LABKEY.DataRegion#changeSort + */ + 'beforesortchange', + /** + * @memberOf LABKEY.DataRegion.prototype + * @member + * @name render + * @event + * @description Fires when data region renders. + */ + 'render', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name selectchange + * @event + * @description Fires when data region selection changes. + */ + 'selectchange', + /** + * @memberOf LABKEY.DataRegion.prototype + * @name success + * @event + * @description Fires when data region loads successfully. + */ + 'success']; + + // TODO: Update constants to not include '.' so mapping can be used easier + var REQUIRE_NAME_PREFIX = { + '~': true, + 'columns': true, + 'param': true, + 'reportId': true, + 'sort': true, + 'offset': true, + 'maxRows': true, + 'showRows': true, + 'containerFilterName': true, + 'viewName': true, + 'disableAnalytics': true + }; + + // + // PRIVATE VARIABLES + // + var _paneCache = {}; + + /** + * The DataRegion constructor is private - to get a LABKEY.DataRegion object, use LABKEY.DataRegions['dataregionname']. + * @class LABKEY.DataRegion + * The DataRegion class allows you to interact with LabKey grids, including querying and modifying selection state, filters, and more. + * @constructor + */ + LABKEY.DataRegion = function(config) { + _init.call(this, config, true); + }; + + LABKEY.DataRegion.prototype.toJSON = function() { + return { + name: this.name, + schemaName: this.schemaName, + queryName: this.queryName, + viewName: this.viewName, + offset: this.offset, + maxRows: this.maxRows, + messages: this.msgbox.toJSON() // hmm, unsure exactly how this works + }; + }; + + /** + * + * @param {Object} config + * @param {Boolean} [applyDefaults=false] + * @private + */ + var _init = function(config, applyDefaults) { + + // ensure name + if (!config.dataRegionName) { + if (!config.name) { + this.name = LABKEY.Utils.id('aqwp'); + } + else { + this.name = config.name; + } + } + else if (!config.name) { + this.name = config.dataRegionName; + } + else { + this.name = config.name; + } + + if (!this.name) { + throw '"name" is required to initialize a LABKEY.DataRegion'; + } + + // _useQWPDefaults is only used on initial construction + var isQWP = config._useQWPDefaults === true; + delete config._useQWPDefaults; + + if (config.buttonBar && config.buttonBar.items && LABKEY.Utils.isArray(config.buttonBar.items)) { + // Be tolerant of the caller passing in undefined items, as pageSize has been removed as an option. Strip + // them out so they don't cause problems downstream. See Issue 34562. + config.buttonBar.items = config.buttonBar.items.filter(function (value, index, arr) { + return value; + }); + } + + var settings; + + if (applyDefaults) { + + // defensively remove, not allowed to be set + delete config._userSort; + + /** + * Config Options + */ + var defaults = { + + _allowHeaderLock: isQWP, + + _failure: isQWP ? LABKEY.Utils.getOnFailure(config) : undefined, + + _success: isQWP ? LABKEY.Utils.getOnSuccess(config) : undefined, + + aggregates: undefined, + + allowChooseQuery: undefined, + + allowChooseView: undefined, + + async: isQWP, + + bodyClass: undefined, + + buttonBar: undefined, + + buttonBarPosition: undefined, + + chartWizardURL: undefined, + + /** + * All rows visible on the current page. + */ + complete: false, + + /** + * The currently applied container filter. Note, this is only if it is set on the URL, otherwise + * the containerFilter could come from the view configuration. Use getContainerFilter() + * on this object to get the right value. + */ + containerFilter: undefined, + + containerPath: undefined, + + /** + * @deprecated use region.name instead + */ + dataRegionName: this.name, + + detailsURL: undefined, + + domId: undefined, + + /** + * The faceted filter pane as been loaded + * @private + */ + facetLoaded: false, + + filters: undefined, + + frame: isQWP ? undefined : 'none', + + errorType: 'html', + + /** + * Id of the DataRegion. Same as name property. + */ + id: this.name, + + deleteURL: undefined, + + importURL: undefined, + + insertURL: undefined, + + linkTarget: undefined, + + /** + * Maximum number of rows to be displayed. 0 if the count is not limited. Read-only. + */ + maxRows: 0, + + metadata: undefined, + + /** + * Name of the DataRegion. Should be unique within a given page. Read-only. This will also be used as the id. + */ + name: this.name, + + /** + * The index of the first row to return from the server (defaults to 0). Use this along with the maxRows config property to request pages of data. + */ + offset: 0, + + parameters: undefined, + + /** + * Name of the query to which this DataRegion is bound. Read-only. + */ + queryName: '', + + disableAnalytics: false, + + removeableContainerFilter: undefined, + + removeableFilters: undefined, + + removeableSort: undefined, + + renderTo: undefined, + + reportId: undefined, + + requestURL: isQWP ? window.location.href : (document.location.search.substring(1) /* strip the ? */ || ''), + + returnUrl: isQWP ? window.location.href : undefined, + + /** + * Schema name of the query to which this DataRegion is bound. Read-only. + */ + schemaName: '', + + /** + * An object to use as the callback function's scope. Defaults to this. + */ + scope: this, + + /** + * URL to use when selecting all rows in the grid. May be null. Read-only. + */ + selectAllURL: undefined, + + selectedCount: 0, + + shadeAlternatingRows: undefined, + + showBorders: undefined, + + showDeleteButton: undefined, + + showDetailsColumn: undefined, + + showExportButtons: undefined, + + showRStudioButton: undefined, + + showImportDataButton: undefined, + + showInsertNewButton: undefined, + + showPagination: undefined, + + showPaginationCount: undefined, + + showPaginationCountAsync: false, + + showRecordSelectors: false, + + showFilterDescription: true, + + showReports: undefined, + + /** + * An enum declaring which set of rows to show. all | selected | unselected | paginated + */ + showRows: 'paginated', + + showSurroundingBorder: undefined, + + showUpdateColumn: undefined, + + /** + * Open the customize view panel after rendering. The value of this option can be "true" or one of "ColumnsTab", "FilterTab", or "SortTab". + */ + showViewPanel: undefined, + + sort: undefined, + + sql: undefined, + + /** + * If true, no alert will appear if there is a problem rendering the QueryWebpart. This is most often encountered if page configuration changes between the time when a request was made and the content loads. Defaults to false. + */ + suppressRenderErrors: false, + + /** + * A timeout for the AJAX call, in milliseconds. + */ + timeout: undefined, + + title: undefined, + + titleHref: undefined, + + totalRows: undefined, // totalRows isn't available when showing all rows. + + updateURL: undefined, + + userContainerFilter: undefined, // TODO: Incorporate this with the standard containerFilter + + userFilters: {}, + + /** + * Name of the custom view to which this DataRegion is bound, may be blank. Read-only. + */ + viewName: null + }; + + settings = $.extend({}, defaults, config); + } + else { + settings = $.extend({}, config); + } + + // if showPaginationCountAsync is set to true, make sure that showPaginationCount is false + if (settings.showPaginationCountAsync && settings.showPaginationCount) { + settings.showPaginationCount = false; + } + + // if 'filters' is not specified and 'filterArray' is, use 'filterArray' + if (!LABKEY.Utils.isArray(settings.filters) && LABKEY.Utils.isArray(config.filterArray)) { + settings.filters = config.filterArray; + } + + // Any 'key' of this object will not be copied from settings to the region instance + var blackList = { + failure: true, + success: true + }; + + for (var s in settings) { + if (settings.hasOwnProperty(s) && !blackList[s]) { + this[s] = settings[s]; + } + } + + if (config.renderTo) { + _convertRenderTo(this, config.renderTo); + } + + if (LABKEY.Utils.isArray(this.removeableFilters)) { + LABKEY.Filter.appendFilterParams(this.userFilters, this.removeableFilters, this.name); + delete this.removeableFilters; // they've been applied + } + + // initialize sorting + if (this._userSort === undefined) { + this._userSort = _getUserSort(this, true /* asString */); + } + + if (LABKEY.Utils.isString(this.removeableSort)) { + this._userSort = this.removeableSort + (this._userSort ? this._userSort : ''); + delete this.removeableSort; + } + + this._allowHeaderLock = this.allowHeaderLock === true; + + if (!config.messages) { + this.messages = {}; + } + + /** + * @ignore + * Non-configurable Options + */ + this.selectionModified = false; + + if (this.panelConfigurations === undefined) { + this.panelConfigurations = {}; + } + + if (isQWP && this.renderTo) { + _load(this); + } + else if (!isQWP) { + _initContexts.call(this); + _initMessaging.call(this); + _initSelection.call(this); + _initPaging.call(this); + _initHeaderLocking.call(this); + _initCustomViews.call(this); + _initPanes.call(this); + _initReport.call(this); + } + // else the user needs to call render + + // bind supported listeners + if (isQWP) { + var me = this; + if (config.listeners) { + var scope = config.listeners.scope || me; + $.each(config.listeners, function(event, handler) { + if ($.inArray(event, VALID_LISTENERS) > -1) { + + // support either "event: function" or "event: { fn: function }" + var callback; + if ($.isFunction(handler)) { + callback = handler; + } + else if ($.isFunction(handler.fn)) { + callback = handler.fn; + } + else { + throw 'Unsupported listener configuration: ' + event; + } + + $(me).bind(event, function() { + callback.apply(scope, $(arguments).slice(1)); + }); + } + else if (event != 'scope') { + throw 'Unsupported listener: ' + event; + } + }); + } + } + }; + + LABKEY.DataRegion.prototype.destroy = function() { + // clean-up panel configurations because we preserve this in init + this.panelConfigurations = {}; + + // currently a no-op, but should be used to clean-up after ourselves + this.disableHeaderLock(); + }; + + /** + * Refreshes the grid, via AJAX region is in async mode (loaded through a QueryWebPart), + * and via a page reload otherwise. Can be prevented with a listener + * on the 'beforerefresh' + * event. + */ + LABKEY.DataRegion.prototype.refresh = function() { + $(this).trigger('beforerefresh', this); + + if (this.async) { + _load(this); + } + else { + window.location.reload(); + } + }; + + // + // Filtering + // + + /** + * Add a filter to this Data Region. + * @param {LABKEY.Filter} filter + * @see LABKEY.DataRegion.addFilter static method. + */ + LABKEY.DataRegion.prototype.addFilter = function(filter) { + this.clearSelected({quiet: true}); + _updateFilter(this, filter); + }; + + /** + * Removes all filters from the DataRegion + */ + LABKEY.DataRegion.prototype.clearAllFilters = function() { + this.clearSelected({quiet: true}); + if (this.async) { + this.offset = 0; + this.userFilters = {}; + } + + _removeParameters(this, [ALL_FILTERS_SKIP_PREFIX, OFFSET_PREFIX]); + }; + + /** + * Removes all the filters for a particular field + * @param {string|FieldKey} fieldKey the name of the field from which all filters should be removed + */ + LABKEY.DataRegion.prototype.clearFilter = function(fieldKey) { + this.clearSelected({quiet: true}); + var fk = _resolveFieldKey(this, fieldKey); + + if (fk) { + var columnPrefix = '.' + fk.toString() + '~'; + + if (this.async) { + this.offset = 0; + + if (this.userFilters) { + var namePrefix = this.name + columnPrefix, + me = this; + + $.each(this.userFilters, function(name, v) { + if (name.indexOf(namePrefix) >= 0) { + delete me.userFilters[name]; + } + }); + } + } + + _removeParameters(this, [columnPrefix, OFFSET_PREFIX]); + } + }; + + /** + * Returns an Array of LABKEY.Filter instances applied when creating this DataRegion. These cannot be removed through the UI. + * @returns {Array} Array of {@link LABKEY.Filter} objects that represent currently applied base filters. + */ + LABKEY.DataRegion.prototype.getBaseFilters = function() { + if (this.filters) { + return this.filters.slice(); + } + + return []; + }; + + /** + * Returns the {@link LABKEY.Query.containerFilter} currently applied to the DataRegion. Defaults to LABKEY.Query.containerFilter.current. + * @returns {String} The container filter currently applied to this DataRegion. Defaults to 'undefined' if a container filter is not specified by the configuration. + * @see LABKEY.DataRegion#getUserContainerFilter to get the containerFilter value from the URL. + */ + LABKEY.DataRegion.prototype.getContainerFilter = function() { + var cf; + + if (LABKEY.Utils.isString(this.containerFilter) && this.containerFilter.length > 0) { + cf = this.containerFilter; + } + else if (LABKEY.Utils.isObject(this.view) && LABKEY.Utils.isString(this.view.containerFilter) && this.view.containerFilter.length > 0) { + cf = this.view.containerFilter; + } + + return cf; + }; + + LABKEY.DataRegion.prototype.getDataRegion = function() { + return this; + }; + + /** + * Returns the user {@link LABKEY.Query.containerFilter} parameter from the URL. + * @returns {LABKEY.Query.containerFilter} The user container filter. + */ + LABKEY.DataRegion.prototype.getUserContainerFilter = function() { + return this.getParameter(this.name + CONTAINER_FILTER_NAME); + }; + + /** + * Returns the user filter from the URL. The filter is represented as an Array of objects of the form: + *
    + *
  • fieldKey: {String} The field key of the filter. + *
  • op: {String} The filter operator (eg. "eq" or "in") + *
  • value: {String} Optional value to filter by. + *
+ * @returns {Object} Object representing the user filter. + * @deprecated 12.2 Use getUserFilterArray instead + */ + LABKEY.DataRegion.prototype.getUserFilter = function() { + + if (LABKEY.devMode) { + console.warn([ + 'LABKEY.DataRegion.getUserFilter() is deprecated since release 12.2.', + 'Consider using getUserFilterArray() instead.' + ].join(' ')); + } + + return this.getUserFilterArray().map(function(filter) { + return { + fieldKey: filter.getColumnName(), + op: filter.getFilterType().getURLSuffix(), + value: filter.getValue() + }; + }); + }; + + /** + * Returns an Array of LABKEY.Filter instances constructed from the URL. + * @returns {Array} Array of {@link LABKEY.Filter} objects that represent currently applied filters. + */ + LABKEY.DataRegion.prototype.getUserFilterArray = function() { + var userFilter = [], me = this; + + _getParameters(this).forEach(function(pair) { + if (pair[0].indexOf(me.name + '.') == 0 && pair[0].indexOf('~') > -1) { + var tilde = pair[0].indexOf('~'); + var fieldKey = pair[0].substring(me.name.length + 1, tilde); + var op = pair[0].substring(tilde + 1); + userFilter.push(LABKEY.Filter.create(fieldKey, pair[1], LABKEY.Filter.getFilterTypeForURLSuffix(op))); + } + }); + + return userFilter; + }; + + /** + * Remove a filter on this DataRegion. + * @param {LABKEY.Filter} filter + */ + LABKEY.DataRegion.prototype.removeFilter = function(filter) { + this.clearSelected({quiet: true}); + if (LABKEY.Utils.isObject(filter) && LABKEY.Utils.isFunction(filter.getColumnName)) { + _updateFilter(this, null, [this.name + '.' + filter.getColumnName() + '~']); + } + }; + + /** + * Replace a filter on this Data Region. Optionally, supply another filter to replace for cases when the filter + * columns don't match exactly. + * @param {LABKEY.Filter} filter + * @param {LABKEY.Filter} [filterToReplace] + */ + LABKEY.DataRegion.prototype.replaceFilter = function(filter, filterToReplace) { + this.clearSelected({quiet: true}); + var target = filterToReplace ? filterToReplace : filter; + _updateFilter(this, filter, [this.name + '.' + target.getColumnName() + '~']); + }; + + /** + * @ignore + * @param filters + * @param columnNames + */ + LABKEY.DataRegion.prototype.replaceFilters = function(filters, columnNames) { + this.clearSelected({quiet: true}); + var filterPrefixes = [], + filterParams = [], + me = this; + + if (LABKEY.Utils.isArray(filters)) { + filters.forEach(function(filter) { + filterPrefixes.push(me.name + '.' + filter.getColumnName() + '~'); + filterParams.push([filter.getURLParameterName(me.name), filter.getURLParameterValue()]); + }); + } + + var fieldKeys = []; + + if (LABKEY.Utils.isArray(columnNames)) { + fieldKeys = fieldKeys.concat(columnNames); + } + else if ($.isPlainObject(columnNames) && columnNames.fieldKey) { + fieldKeys.push(columnNames.fieldKey.toString()); + } + + // support fieldKeys (e.g. ["ColumnA", "ColumnA/Sub1"]) + // A special case of fieldKey is "SUBJECT_PREFIX/", used by participant group facet + if (fieldKeys.length > 0) { + _getParameters(this).forEach(function(param) { + var p = param[0]; + if (p.indexOf(me.name + '.') === 0 && p.indexOf('~') > -1) { + $.each(fieldKeys, function(j, name) { + var postfix = name && name.length && name[name.length - 1] == '/' ? '' : '~'; + if (p.indexOf(me.name + '.' + name + postfix) > -1) { + filterPrefixes.push(p); + } + }); + } + }); + } + + _setParameters(this, filterParams, [OFFSET_PREFIX].concat($.unique(filterPrefixes))); + }; + + /** + * @private + * @param filter + * @param filterMatch + */ + LABKEY.DataRegion.prototype.replaceFilterMatch = function(filter, filterMatch) { + this.clearSelected({quiet: true}); + var skips = [], me = this; + + _getParameters(this).forEach(function(param) { + if (param[0].indexOf(me.name + '.') === 0 && param[0].indexOf(filterMatch) > -1) { + skips.push(param[0]); + } + }); + + _updateFilter(this, filter, skips); + }; + + // + // Selection + // + + /** + * @private + */ + var _initSelection = function() { + + var me = this, + form = _getFormSelector(this); + + if (form && form.length) { + // backwards compatibility -- some references use this directly + // if you're looking to use this internally to the region use _getFormSelector() instead + this.form = form[0]; + } + + if (form && this.showRecordSelectors) { + _onSelectionChange(this); + } + + // Bind Events + _getAllRowSelectors(this).on('click', function(evt) { + evt.stopPropagation(); + me.selectPage.call(me, this.checked); + }); + _getRowSelectors(this).on('click', function() { me.selectRow.call(me, this); }); + + // click row highlight + var rows = form.find('.labkey-data-region > tbody > tr'); + rows.on('click', function(e) { + if (e.target && e.target.tagName.toLowerCase() === 'td') { + $(this).siblings('tr').removeClass('lk-row-hl'); + $(this).addClass('lk-row-hl'); + _selClickLock = me; + } + }); + rows.on('mouseenter', function() { + $(this).siblings('tr').removeClass('lk-row-over'); + $(this).addClass('lk-row-over'); + }); + rows.on('mouseleave', function() { + $(this).removeClass('lk-row-over'); + }); + + if (!_selDocClick) { + _selDocClick = $(document).on('click', _onDocumentClick); + } + }; + + var _selClickLock; // lock to prevent removing a row highlight that was just applied + var _selDocClick; // global (shared across all Data Region instances) click event handler instance + + // Issue 32898: Clear row highlights on document click + var _onDocumentClick = function() { + if (_selClickLock) { + var form = _getFormSelector(_selClickLock); + _selClickLock = undefined; + + $('.lk-row-hl').each(function() { + if (!form.has($(this)).length) { + $(this).removeClass('lk-row-hl'); + } + }); + } + else { + $('.lk-row-hl').removeClass('lk-row-hl'); + } + }; + + /** + * Clear all selected items for the current DataRegion. + * + * @param config A configuration object with the following properties: + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'count' of 0 to indicate an empty selection. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion#selectPage + * @see LABKEY.DataRegion.clearSelected static method. + */ + LABKEY.DataRegion.prototype.clearSelected = function(config) { + config = config || {}; + config.selectionKey = this.selectionKey; + config.scope = config.scope || this; + + this.selectedCount = 0; + if (!config.quiet) + { + _onSelectionChange(this); + } + + if (config.selectionKey) { + LABKEY.DataRegion.clearSelected(config); + } + + if (this.showRows == 'selected') { + _removeParameters(this, [SHOW_ROWS_PREFIX]); + } + else if (this.showRows == 'unselected') { + // keep "SHOW_ROWS_PREFIX=unselected" parameter + window.location.reload(true); + } + else { + _toggleAllRows(this, false); + this.removeMessage('selection'); + } + }; + + /** + * Get selected items on the current page of the DataRegion, based on the current state of the checkboxes in the + * browser's DOM. Note, if the region is paginated, selected items may exist on other pages which will not be + * included in the results of this function. + * @see LABKEY.DataRegion#getSelected + */ + LABKEY.DataRegion.prototype.getChecked = function() { + var values = []; + _getRowSelectors(this).each(function() { + if (this.checked) { + values.push(this.value); + } + }); + return values; + }; + + /** + * Get all selected items for this DataRegion, as maintained in server-state. This will include rows on any + * pages of a paginated grid, and may not correspond directly with the state of the checkboxes in the current + * browser window's DOM if the server-side state has been modified. + * + * @param config A configuration object with the following properties: + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'selected' that is an array of the primary keys for the selected rows. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion.getSelected static method. + */ + LABKEY.DataRegion.prototype.getSelected = function(config) { + if (!this.selectionKey) + return; + + config = config || {}; + config.selectionKey = this.selectionKey; + LABKEY.DataRegion.getSelected(config); + }; + + /** + * Returns the number of selected rows on the current page of the DataRegion. Selected items may exist on other pages. + * @returns {Integer} the number of selected rows on the current page of the DataRegion. + * @see LABKEY.DataRegion#getSelected to get all selected rows. + */ + LABKEY.DataRegion.prototype.getSelectionCount = function() { + if (!$('#' + this.domId)) { + return 0; + } + + var count = 0; + _getRowSelectors(this).each(function() { + if (this.checked === true) { + count++; + } + }); + + return count; + }; + + /** + * Returns true if any row is checked on the current page of the DataRegion. Selected items may exist on other pages. + * @returns {Boolean} true if any row is checked on the current page of the DataRegion. + * @see LABKEY.DataRegion#getSelected to get all selected rows. + */ + LABKEY.DataRegion.prototype.hasSelected = function() { + return this.getSelectionCount() > 0; + }; + + /** + * Returns true if all rows are checked on the current page of the DataRegion and at least one row is present. + * @returns {Boolean} true if all rows are checked on the current page of the DataRegion and at least one row is present. + * @see LABKEY.DataRegion#getSelected to get all selected rows. + */ + LABKEY.DataRegion.prototype.isPageSelected = function() { + var checkboxes = _getRowSelectors(this); + var i=0; + + for (; i < checkboxes.length; i++) { + if (!checkboxes[i].checked) { + return false; + } + } + return i > 0; + }; + + LABKEY.DataRegion.prototype.selectAll = function(config) { + if (this.selectionKey) { + config = config || {}; + config.scope = config.scope || this; + + // Either use the selectAllURL provided or create a query config + // object that can be used with the generic query/selectAll.api action. + if (this.selectAllURL) { + config.url = this.selectAllURL; + } + else { + config = LABKEY.Utils.apply(config, this.getQueryConfig()); + } + + config = _chainSelectionCountCallback(this, config); + + LABKEY.DataRegion.selectAll(config); + + if (this.showRows === "selected") { + // keep "SHOW_ROWS_PREFIX=selected" parameter + window.location.reload(true); + } + else if (this.showRows === "unselected") { + _removeParameters(this, [SHOW_ROWS_PREFIX]); + } + else { + _toggleAllRows(this, true); + } + } + }; + + /** + * @deprecated use clearSelected instead + * @function + * @see LABKEY.DataRegion#clearSelected + */ + LABKEY.DataRegion.prototype.selectNone = LABKEY.DataRegion.prototype.clearSelected; + + /** + * Set the selection state for all checkboxes on the current page of the DataRegion. + * @param checked whether all of the rows on the current page should be selected or unselected + * @returns {Array} Array of ids that were selected or unselected. + * + * @see LABKEY.DataRegion#setSelected to set selected items on the current page of the DataRegion. + * @see LABKEY.DataRegion#clearSelected to clear all selected. + */ + LABKEY.DataRegion.prototype.selectPage = function(checked) { + var _check = (checked === true); + var ids = _toggleAllRows(this, _check); + var me = this; + + if (ids.length > 0) { + _getAllRowSelectors(this).each(function() { this.checked = _check}); + this.setSelected({ + ids: ids, + checked: _check, + success: function(data) { + if (data && data.count > 0 && !this.complete) { + var count = data.count; + var msg; + if (me.totalRows) { + if (count == me.totalRows) { + msg = 'All ' + this.totalRows + ' rows selected.'; + } + else { + msg = 'Selected ' + count + ' of ' + this.totalRows + ' rows.'; + } + } + else { + // totalRows isn't available when showing all rows. + msg = 'Selected ' + count + ' rows.'; + } + _showSelectMessage(me, msg); + } + else { + this.removeMessage('selection'); + } + } + }); + } + + return ids; + }; + + /** + * @ignore + * @param el + */ + LABKEY.DataRegion.prototype.selectRow = function(el) { + this.setSelected({ + ids: [el.value], + checked: el.checked + }); + + if (!el.checked) { + this.removeMessage('selection'); + } + }; + + /** + * Add or remove items from the selection associated with this DataRegion. + * + * @param config A configuration object with the following properties: + * @param {Array} config.ids Array of primary key ids for each row to select/unselect. + * @param {Boolean} config.checked If true, the ids will be selected, otherwise unselected. + * @param {Function} [config.success] The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'count' to indicate the updated selection count. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion#getSelected to get the selected items for this DataRegion. + * @see LABKEY.DataRegion#clearSelected to clear all selected items for this DataRegion. + */ + LABKEY.DataRegion.prototype.setSelected = function(config) { + if (!config || !LABKEY.Utils.isArray(config.ids) || config.ids.length === 0) { + return; + } + + var me = this; + config = config || {}; + config.selectionKey = this.selectionKey; + config.scope = config.scope || me; + + config = _chainSelectionCountCallback(this, config); + + var failure = LABKEY.Utils.getOnFailure(config); + if ($.isFunction(failure)) { + config.failure = failure; + } + else { + config.failure = function(error) { + let msg = 'Error setting selection'; + if (error && error.exception) msg += ': ' + error.exception; + me.addMessage(msg, 'selection'); + }; + } + + if (config.selectionKey) { + LABKEY.DataRegion.setSelected(config); + } + else if ($.isFunction(config.success)) { + // Don't send the selection change to the server if there is no selectionKey. + // Call the success callback directly. + config.success.call(config.scope, {count: this.getSelectionCount()}); + } + }; + + // + // Parameters + // + + /** + * Removes all parameters from the DataRegion + */ + LABKEY.DataRegion.prototype.clearAllParameters = function() { + if (this.async) { + this.offset = 0; + this.parameters = undefined; + } + + _removeParameters(this, [PARAM_PREFIX, OFFSET_PREFIX]); + }; + + /** + * Returns the specified parameter from the URL. Note, this is not related specifically + * to parameterized query values (e.g. setParameters()/getParameters()) + * @param {String} paramName + * @returns {*} + */ + LABKEY.DataRegion.prototype.getParameter = function(paramName) { + var param = null; + + $.each(_getParameters(this), function(i, pair) { + if (pair.length > 0 && pair[0] === paramName) { + param = pair.length > 1 ? pair[1] : ''; + return false; + } + }); + + return param; + }; + + /** + * Get the parameterized query values for this query. These parameters + * are named by the query itself. + * @param {boolean} toLowercase If true, all parameter names will be converted to lowercase + * returns params An Object of key/val pairs. + */ + LABKEY.DataRegion.prototype.getParameters = function(toLowercase) { + + var params = this.parameters ? this.parameters : {}, + re = new RegExp('^' + LABKEY.Utils.escapeRe(this.name) + LABKEY.Utils.escapeRe(PARAM_PREFIX), 'i'), + name; + + _getParameters(this).forEach(function(pair) { + if (pair.length > 0 && pair[0].match(re)) { + name = pair[0].replace(re, ''); + if (toLowercase === true) { + name = name.toLowerCase(); + } + + // URL parameters will override this.parameters values + params[name] = pair[1]; + } + }); + + return params; + }; + + /** + * Set the parameterized query values for this query. These parameters + * are named by the query itself. + * @param {Mixed} params An Object or Array of Array key/val pairs. + */ + LABKEY.DataRegion.prototype.setParameters = function(params) { + var event = $.Event('beforesetparameters'); + + $(this).trigger(event); + + if (event.isDefaultPrevented()) { + return; + } + + var paramPrefix = this.name + PARAM_PREFIX, _params = []; + var newParameters = this.parameters ? this.parameters : {}; + + function applyParameters(pKey, pValue) { + var key = pKey; + if (pKey.indexOf(paramPrefix) !== 0) { + key = paramPrefix + pKey; + } + newParameters[key.replace(paramPrefix, '')] = pValue; + _params.push([key, pValue]); + } + + // convert Object into Array of Array pairs and prefix the parameter name if necessary. + if (LABKEY.Utils.isObject(params)) { + $.each(params, applyParameters); + } + else if (LABKEY.Utils.isArray(params)) { + params.forEach(function(pair) { + if (LABKEY.Utils.isArray(pair) && pair.length > 1) { + applyParameters(pair[0], pair[1]); + } + }); + } + else { + return; // invalid argument shape + } + + this.parameters = newParameters; + + _setParameters(this, _params, [PARAM_PREFIX, OFFSET_PREFIX]); + }; + + /** + * @ignore + * @Deprecated + */ + LABKEY.DataRegion.prototype.setSearchString = function(regionName, search) { + this.savedSearchString = search || ""; + // If the search string doesn't change and there is a hash on the url, the page won't reload. + // Remove the hash by setting the full path plus search string. + window.location.assign(window.location.pathname + (this.savedSearchString.length > 0 ? "?" + this.savedSearchString : "")); + }; + + // + // Messaging + // + + /** + * @private + */ + var _initMessaging = function() { + if (!this.msgbox) { + this.msgbox = new MessageArea(this); + this.msgbox.on('rendermsg', function(evt, msgArea, parts) { _onRenderMessageArea(this, parts); }, this); + } + else { + this.msgbox.bindRegion(this); + } + + if (this.messages) { + this.msgbox.setMessages(this.messages); + this.msgbox.render(); + } + }; + + /** + * Show a message in the header of this DataRegion. + * @param {String / Object} config the HTML source of the message to be shown or a config object with the following properties: + *
    + *
  • html: {String} the HTML source of the message to be shown.
  • + *
  • part: {String} The part of the message area to render the message to.
  • + *
  • duration: {Integer} The amount of time (in milliseconds) the message will stay visible.
  • + *
  • hideButtonPanel: {Boolean} If true the button panel (customize view, export, etc.) will be hidden if visible.
  • + *
  • append: {Boolean} If true the msg is appended to any existing content for the given part.
  • + *
+ * @param part The part of the message area to render the message to. Used to scope messages so they can be added + * and removed without clearing other messages. + */ + LABKEY.DataRegion.prototype.addMessage = function(config, part) { + this.hidePanel(); + + if (LABKEY.Utils.isString(config)) { + this.msgbox.addMessage(config, part); + } + else if (LABKEY.Utils.isObject(config)) { + this.msgbox.addMessage(config.html, config.part || part, config.append); + + if (config.hideButtonPanel) { + this.hideButtonPanel(); + } + + if (config.duration) { + var dr = this; + setTimeout(function() { + dr.removeMessage(config.part || part); + _getHeaderSelector(dr).trigger('resize'); + }, config.duration); + } + } + }; + + /** + * Clear the message box contents. + */ + LABKEY.DataRegion.prototype.clearMessage = function() { + if (this.msgbox) this.msgbox.removeAll(); + }; + + /** + * @param part The part of the message area to render the message to. Used to scope messages so they can be added + * and removed without clearing other messages. + * @return {String} The message for 'part'. Could be undefined. + */ + LABKEY.DataRegion.prototype.getMessage = function(part) { + if (this.msgbox) { return this.msgbox.getMessage(part); } // else undefined + }; + + /** + * @param part The part of the message area to render the message to. Used to scope messages so they can be added + * and removed without clearing other messages. + * @return {Boolean} true iff there is a message area for this region and it has the message keyed by 'part'. + */ + LABKEY.DataRegion.prototype.hasMessage = function(part) { + return this.msgbox && this.msgbox.hasMessage(part); + }; + + LABKEY.DataRegion.prototype.hideContext = function() { + _getContextBarSelector(this).hide(); + _getViewBarSelector(this).hide(); + }; + + /** + * If a message is currently showing, hide it and clear out its contents + * @param keepContent If true don't remove the message area content + */ + LABKEY.DataRegion.prototype.hideMessage = function(keepContent) { + if (this.msgbox) { + this.msgbox.hide(); + + if (!keepContent) + this.removeAllMessages(); + } + }; + + /** + * Returns true if a message is currently being shown for this DataRegion. Messages are shown as a header. + * @return {Boolean} true if a message is showing. + */ + LABKEY.DataRegion.prototype.isMessageShowing = function() { + return this.msgbox && this.msgbox.isVisible(); + }; + + /** + * Removes all messages from this Data Region. + */ + LABKEY.DataRegion.prototype.removeAllMessages = function() { + if (this.msgbox) { this.msgbox.removeAll(); } + }; + + /** + * If a message is currently showing, remove the specified part + */ + LABKEY.DataRegion.prototype.removeMessage = function(part) { + if (this.msgbox) { this.msgbox.removeMessage(part); } + }; + + /** + * Show a message in the header of this DataRegion with a loading indicator. + * @param html the HTML source of the message to be shown + */ + LABKEY.DataRegion.prototype.showLoadingMessage = function(html) { + html = html || "Loading..."; + this.addMessage('
 ' + html + '
', 'drloading'); + }; + + LABKEY.DataRegion.prototype.hideLoadingMessage = function() { + this.removeMessage('drloading'); + }; + + /** + * Show a success message in the header of this DataRegion. + * @param html the HTML source of the message to be shown + */ + LABKEY.DataRegion.prototype.showSuccessMessage = function(html) { + html = html || "Completed successfully."; + this.addMessage('
' + html + '
'); + }; + + /** + * Show an error message in the header of this DataRegion. + * @param html the HTML source of the message to be shown + */ + LABKEY.DataRegion.prototype.showErrorMessage = function(html) { + html = html || "An error occurred."; + this.addMessage('
' + html + '
'); + }; + + LABKEY.DataRegion.prototype.showContext = function() { + _initContexts(); + + var contexts = [ + _getContextBarSelector(this), + _getViewBarSelector(this) + ]; + + for (var i = 0; i < contexts.length; i++) { + var ctx = contexts[i]; + var html = ctx.html(); + + if (html && html.trim() !== '') { + ctx.show(); + } + } + }; + + /** + * Show a message in the header of this DataRegion. + * @param msg the HTML source of the message to be shown + * @deprecated use addMessage(msg, part) instead. + */ + LABKEY.DataRegion.prototype.showMessage = function(msg) { + if (this.msgbox) { + this.msgbox.addMessage(msg); + } + }; + + LABKEY.DataRegion.prototype.showMessageArea = function() { + if (this.msgbox && this.msgbox.hasContent()) { + this.msgbox.show(); + } + }; + + // + // Sections + // + + LABKEY.DataRegion.prototype.displaySection = function(options) { + var dir = options && options.dir ? options.dir : 'n'; + + var sec = _getSectionSelector(this, dir); + if (options && options.html) { + options.append === true ? sec.append(options.html) : sec.html(options.html); + } + sec.show(); + }; + + LABKEY.DataRegion.prototype.hideSection = function(options) { + var dir = options && options.dir ? options.dir : 'n'; + var sec = _getSectionSelector(this, dir); + + sec.hide(); + + if (options && options.clear === true) { + sec.html(''); + } + }; + + LABKEY.DataRegion.prototype.writeSection = function(content, options) { + var append = options && options.append === true; + var dir = options && options.dir ? options.dir : 'n'; + + var sec = _getSectionSelector(this, dir); + append ? sec.append(content) : sec.html(content); + }; + + // + // Sorting + // + + /** + * Replaces the sort on the given column, if present, or sets a brand new sort + * @param {string or LABKEY.FieldKey} fieldKey name of the column to be sorted + * @param {string} [sortDir=+] Set to '+' for ascending or '-' for descending + */ + LABKEY.DataRegion.prototype.changeSort = function(fieldKey, sortDir) { + if (!fieldKey) + return; + + fieldKey = _resolveFieldKey(this, fieldKey); + + var columnName = fieldKey.toString(); + + var event = $.Event("beforesortchange"); + + $(this).trigger(event, [this, columnName, sortDir]); + + if (event.isDefaultPrevented()) { + return; + } + + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + this._userSort = _alterSortString(this, this._userSort, fieldKey, sortDir); + _setParameter(this, SORT_PREFIX, this._userSort, [SORT_PREFIX, OFFSET_PREFIX]); + }; + + /** + * Removes the sort on a specified column + * @param {string or LABKEY.FieldKey} fieldKey name of the column + */ + LABKEY.DataRegion.prototype.clearSort = function(fieldKey) { + if (!fieldKey) + return; + + fieldKey = _resolveFieldKey(this, fieldKey); + + var columnName = fieldKey.toString(); + + var event = $.Event("beforeclearsort"); + + $(this).trigger(event, [this, columnName]); + + if (event.isDefaultPrevented()) { + return; + } + + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + this._userSort = _alterSortString(this, this._userSort, fieldKey); + if (this._userSort.length > 0) { + _setParameter(this, SORT_PREFIX, this._userSort, [SORT_PREFIX, OFFSET_PREFIX]); + } + else { + _removeParameters(this, [SORT_PREFIX, OFFSET_PREFIX]); + } + }; + + /** + * Returns the user sort from the URL. The sort is represented as an Array of objects of the form: + *
    + *
  • fieldKey: {String} The field key of the sort. + *
  • dir: {String} The sort direction, either "+" or "-". + *
+ * @returns {Object} Object representing the user sort. + */ + LABKEY.DataRegion.prototype.getUserSort = function() { + return _getUserSort(this); + }; + + // + // Paging + // + + var _initPaging = function() { + if (this.showPagination) { + // Issue 51036: load totalRows count async for DataRegions + if (!this.complete && this.showPaginationCountAsync && !this.skipTotalRowCount && this.loadingTotalRows === undefined) { + var params = _getAsyncParams(this, _getParameters(this), false); + var jsonData = _getAsyncBody(this, params); + _loadAsyncTotalRowCount(this, params, jsonData); + } + + var ct = _getBarSelector(this).find('.labkey-pagination'); + + if (ct && ct.length) { + var hasOffset = $.isNumeric(this.offset); + var hasTotal = $.isNumeric(this.totalRows); + + // display the counts + if (hasOffset) { + + // small result set + if (hasTotal && this.totalRows < 5) { + return; + } + + var low = this.offset + 1; + var high = this.offset + this.rowCount; + + // user has opted to show all rows + if (hasTotal && (this.rowCount === null || this.rowCount < 1)) { + high = this.totalRows; + } + + var showFirst = _showFirstEnabled(this); + var showLast = _showLastEnabled(this); + var showAll = _showAllEnabled(this); + this.showFirstID = LABKEY.Utils.id(); + this.showLastID = LABKEY.Utils.id(); + this.showAllID = LABKEY.Utils.id(); + + // If modifying this ensure it is consistent with DOM generated by PopupMenu.java + var elems = [ + ''); + ct.append(elems.join('')); + + //bind functions to menu items + _getShowFirstSelector(this).click(_firstPage.bind(this)); + _getShowLastSelector(this).click(_lastPage.bind(this)); + _getShowAllSelector(this).click(this.showAllRows.bind(this)); + + if (_isMaxRowsAllRows(this) && this.totalRows > this.maxRows) { + this.addMessage('Show all: Displaying the first ' + ALL_ROWS_MAX.toLocaleString() + ' rows. Use paging to see more results.'); + } + + for (var key in offsetIds) { + if (offsetIds.hasOwnProperty(key)) { + $('#' + key).click(_setMaxRows.bind(this, offsetIds[key])); + } + } + + // only display buttons if all the results are not shown + if (low === 1 && high === this.totalRows) { + _getBarSelector(this).find('.paging-widget').css("top", "4px"); + return; + } + + var canNext = this.maxRows > 0 && high !== this.totalRows, + canPrev = this.maxRows > 0 && low > 1, + prevId = LABKEY.Utils.id(), + nextId = LABKEY.Utils.id(); + + ct.append([ + '
', + '', + '', + '
' + ].join('')); + + var prev = $('#' + prevId); + prev.click(_page.bind(this, this.offset - this.maxRows, canPrev)); + if (!canPrev) { + prev.addClass('disabled'); + } + + var next = $('#' + nextId); + next.click(_page.bind(this, this.offset + this.maxRows, canNext)); + if (!canNext) { + next.addClass('disabled'); + } + } + } + } + else { + _getHeaderSelector(this).find('div.labkey-pagination').css('visibility', 'visible'); + } + }; + + var _showFirstEnabled = function(region) { + return region.offset && region.offset > 0; + }; + + var _showLastEnabled = function(region) { + var low = region.offset + 1; + var high = region.offset + region.rowCount; + return !(low === 1 && high === region.totalRows) && (region.offset + region.maxRows <= region.totalRows); + }; + + var _showAllEnabled = function(region) { + return (_showFirstEnabled(region) || _showLastEnabled(region)) && !_isMaxRowsAllRows(region); + }; + + var _getPaginationText = function(region) { + var hasTotal = $.isNumeric(region.totalRows); + var low = region.offset + 1; + var high = region.offset + region.rowCount; + + var paginationText = low.toLocaleString() + ' - ' + high.toLocaleString(); + if (region.showPaginationCount || region.showPaginationCountAsync) { + if (hasTotal) { + paginationText += ' of ' + region.totalRows.toLocaleString(); + } else if (region.loadingTotalRows) { + paginationText += ' of '; + } + } + + return paginationText; + }; + + var _page = function(offset, enabled) { + if (enabled) { + this.setPageOffset(offset); + } + return false; + }; + + var _firstPage = function() { + if (_showFirstEnabled(this)) { + this.setPageOffset(0); + } + return false; + }; + + var _lastPage = function() { + if (_showLastEnabled(this)) { + var lastPageSize = this.totalRows % this.maxRows === 0 ? this.maxRows : this.totalRows % this.maxRows; + this.setPageOffset(this.totalRows - lastPageSize); + } + return false; + }; + + var _setMaxRows = function(rows) { + if (this.maxRows !== rows) { + this.setMaxRows(rows); + } + return false; + }; + + var _isMaxRowsAllRows = function(region) { + return region.maxRows === ALL_ROWS_MAX; + }; + + /** + * Forces the grid to show all rows, up to ALL_ROWS_MAX, without any paging + */ + LABKEY.DataRegion.prototype.showAllRows = function() { + _setMaxRows.bind(this, ALL_ROWS_MAX)(); + }; + + /** + * @deprecated use showAllRows instead + * @function + * @see LABKEY.DataRegion#showAllRows + */ + LABKEY.DataRegion.prototype.showAll = LABKEY.DataRegion.prototype.showAllRows; + + /** + * Forces the grid to show only rows that have been selected + */ + LABKEY.DataRegion.prototype.showSelectedRows = function() { + _showRows(this, 'selected'); + }; + /** + * @deprecated use showSelectedRows instead + * @function + * @see LABKEY.DataRegion#showSelectedRows + */ + LABKEY.DataRegion.prototype.showSelected = LABKEY.DataRegion.prototype.showSelectedRows; + + /** + * Forces the grid to show only rows that have not been selected + */ + LABKEY.DataRegion.prototype.showUnselectedRows = function() { + _showRows(this, 'unselected'); + }; + /** + * @deprecated use showUnselectedRows instead + * @function + * @see LABKEY.DataRegion#showUnselectedRows + */ + LABKEY.DataRegion.prototype.showUnselected = LABKEY.DataRegion.prototype.showUnselectedRows; + + /** + * Forces the grid to do paging based on the current maximum number of rows + */ + LABKEY.DataRegion.prototype.showPaged = function() { + _removeParameters(this, [SHOW_ROWS_PREFIX]); + }; + + /** + * Displays the first page of the grid + */ + LABKEY.DataRegion.prototype.showFirstPage = function() { + this.setPageOffset(0); + }; + /** + * @deprecated use showFirstPage instead + * @function + * @see LABKEY.DataRegion#showFirstPage + */ + LABKEY.DataRegion.prototype.pageFirst = LABKEY.DataRegion.prototype.showFirstPage; + + /** + * Changes the current row offset for paged content + * @param rowOffset row index that should be at the top of the grid + */ + LABKEY.DataRegion.prototype.setPageOffset = function(rowOffset) { + var event = $.Event('beforeoffsetchange'); + + $(this).trigger(event, [this, rowOffset]); + + if (event.isDefaultPrevented()) { + return; + } + + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + // clear sibling parameters + this.showRows = undefined; + + if ($.isNumeric(rowOffset)) { + _setParameter(this, OFFSET_PREFIX, rowOffset, [OFFSET_PREFIX, SHOW_ROWS_PREFIX]); + } + else { + _removeParameters(this, [OFFSET_PREFIX, SHOW_ROWS_PREFIX]); + } + }; + /** + * @deprecated use setPageOffset instead + * @function + * @see LABKEY.DataRegion#setPageOffset + */ + LABKEY.DataRegion.prototype.setOffset = LABKEY.DataRegion.prototype.setPageOffset; + + /** + * Changes the maximum number of rows that the grid will display at one time + * @param newmax the maximum number of rows to be shown + */ + LABKEY.DataRegion.prototype.setMaxRows = function(newmax) { + var event = $.Event('beforemaxrowschange'); + $(this).trigger(event, [this, newmax]); + if (event.isDefaultPrevented()) { + return; + } + + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + // clear sibling parameters + this.showRows = undefined; + this.offset = 0; + + _setParameter(this, MAX_ROWS_PREFIX, newmax, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); + }; + + var _initContexts = function() { + // clear old contents + var ctxBar = _getContextBarSelector(this); + ctxBar.find('.labkey-button-bar').remove(); + + var numFilters = ctxBar.find('.fa-filter').length; + var numParams = ctxBar.find('.fa-question').length; + + var html = []; + + if (numParams > 0) { + html = html.concat([ + '
', + 'Clear Variables', + '
' + ]) + } + + if (numFilters >= 2) { + html = html.concat([ + '
', + '' + + (numParams > 0 ? 'Clear Filters' : 'Clear All') + + '', + '
' + ]); + } + + if (html.length) { + ctxBar.append(html.join('')); + ctxBar.find('.ctx-clear-var').off('click').on('click', $.proxy(this.clearAllParameters, this)); + ctxBar.find('.ctx-clear-all').off('click').on('click', $.proxy(this.clearAllFilters, this)); + } + + // Issue 35396: Support ButtonBarOptions + if (LABKEY.Utils.isArray(this.buttonBarOnRenders)) { + for (var i=0; i < this.buttonBarOnRenders.length; i++) { + var scriptFnName = this.buttonBarOnRenders[i]; + var fnParts = scriptFnName.split('.'); + var scope = window; + var called = false; + + for (var j=0; j < fnParts.length; j++) { + scope = scope[fnParts[j]]; + if (!scope) break; + if (j === fnParts.length - 1 && LABKEY.Utils.isFunction(scope)) { + scope(this); + called = true; + } + } + + if (!called) { + console.warn('Unable to call "' + scriptFnName + '" for DataRegion.ButtonBar.onRender.'); + } + } + } + }; + + // + // Customize View + // + var _initCustomViews = function() { + if (this.view && this.view.session) { + // clear old contents + _getViewBarSelector(this).find('.labkey-button-bar').remove(); + + _getViewBarSelector(this).append([ + '
', + 'This grid view has been modified.', + 'Revert', + 'Edit', + 'Save', + '
' + ].join('')); + _getViewBarSelector(this).find('.unsavedview-revert').off('click').on('click', $.proxy(function() { + _revertCustomView(this); + }, this)); + _getViewBarSelector(this).find('.unsavedview-edit').off('click').on('click', $.proxy(function() { + this.showCustomizeView(undefined); + }, this)); + _getViewBarSelector(this).find('.unsavedview-save').off('click').on('click', $.proxy(function() { + _saveSessionCustomView(this); + }, this)); + } + }; + + /** + * Change the currently selected view to the named view + * @param {Object} view An object which contains the following properties. + * @param {String} [view.type] the type of view, either a 'view' or a 'report'. + * @param {String} [view.viewName] If the type is 'view', then the name of the view. + * @param {String} [view.reportId] If the type is 'report', then the report id. + * @param {Object} urlParameters NOTE: Experimental parameter; may change without warning. A set of filter and sorts to apply as URL parameters when changing the view. + */ + LABKEY.DataRegion.prototype.changeView = function(view, urlParameters) { + var event = $.Event('beforechangeview'); + $(this).trigger(event, [this, view, urlParameters]); + if (event.isDefaultPrevented()) { + return; + } + + var paramValPairs = [], + newSort = [], + skipPrefixes = [OFFSET_PREFIX, SHOW_ROWS_PREFIX, VIEWNAME_PREFIX, REPORTID_PREFIX]; + + // clear sibling parameters + this.viewName = undefined; + this.reportId = undefined; + + if (view) { + if (LABKEY.Utils.isString(view)) { + paramValPairs.push([VIEWNAME_PREFIX, view]); + this.viewName = view; + } + else if (view.type === 'report') { + paramValPairs.push([REPORTID_PREFIX, view.reportId]); + this.reportId = view.reportId; + } + else if (view.type === 'view' && view.viewName) { + paramValPairs.push([VIEWNAME_PREFIX, view.viewName]); + this.viewName = view.viewName; + } + } + + if (urlParameters) { + $.each(urlParameters.filter, function(i, filter) { + paramValPairs.push(['.' + filter.fieldKey + '~' + filter.op, filter.value]); + }); + + if (urlParameters.sort && urlParameters.sort.length > 0) { + $.each(urlParameters.sort, function(i, sort) { + newSort.push((sort.dir === '+' ? '' : sort.dir) + sort.fieldKey); + }); + paramValPairs.push([SORT_PREFIX, newSort.join(',')]); + } + + if (urlParameters.containerFilter) { + paramValPairs.push([CONTAINER_FILTER_NAME, urlParameters.containerFilter]); + } + + // removes all filter, sort, and container filter parameters + skipPrefixes = skipPrefixes.concat([ + ALL_FILTERS_SKIP_PREFIX, SORT_PREFIX, COLUMNS_PREFIX, CONTAINER_FILTER_NAME + ]); + } + + // removes all filter, sort, and container filter parameters + _setParameters(this, paramValPairs, skipPrefixes); + }; + + LABKEY.DataRegion.prototype.getQueryDetails = function(success, failure, scope) { + + var userSort = this.getUserSort(), + userColumns = this.getParameter(this.name + COLUMNS_PREFIX), + fields = [], + viewName = (this.view && this.view.name) || this.viewName || ''; + + var userFilter = this.getUserFilterArray().map(function(filter) { + var fieldKey = filter.getColumnName(); + fields.push(fieldKey); + + return { + fieldKey: fieldKey, + op: filter.getFilterType().getURLSuffix(), + value: filter.getValue() + }; + }); + + userSort.forEach(function(sort) { + fields.push(sort.fieldKey); + }); + + LABKEY.Query.getQueryDetails({ + containerPath: this.containerPath, + schemaName: this.schemaName, + queryName: this.queryName, + viewName: viewName, + fields: fields, + initializeMissingView: true, + success: function(queryDetails) { + success.call(scope || this, queryDetails, viewName, userColumns, userFilter, userSort); + }, + failure: failure, + scope: scope + }); + }; + + /** + * Hides the customize view interface if it is visible. + */ + LABKEY.DataRegion.prototype.hideCustomizeView = function() { + if (this.activePanelId === CUSTOM_VIEW_PANELID) { + this.hideButtonPanel(); + } + }; + + /** + * Show the customize view interface. + * @param activeTab {[String]} Optional. One of "ColumnsTab", "FilterTab", or "SortTab". If no value is specified (or undefined), the ColumnsTab will be shown. + */ + LABKEY.DataRegion.prototype.showCustomizeView = function(activeTab) { + var region = this; + + var panelConfig = this.getPanelConfiguration(CUSTOM_VIEW_PANELID); + + if (!panelConfig) { + + // whistle while we wait + var timerId = setTimeout(function() { + timerId = 0; + region.showLoadingMessage("Opening custom view designer..."); + }, 500); + + LABKEY.DataRegion.loadViewDesigner(function() { + + var success = function(queryDetails, viewName, userColumns, userFilter, userSort) { + timerId > 0 ? clearTimeout(timerId) : this.hideLoadingMessage(); + + // If there was an error parsing the query, we won't be able to render the customize view panel. + if (queryDetails.exception) { + var viewSourceUrl = LABKEY.ActionURL.buildURL('query', 'viewQuerySource.view', this.containerPath, { + schemaName: this.schemaName, + 'query.queryName': this.queryName + }); + var msg = LABKEY.Utils.encodeHtml(queryDetails.exception) + + "  View Source"; + + this.showErrorMessage(msg); + return; + } + + this.customizeView = Ext4.create('LABKEY.internal.ViewDesigner.Designer', { + renderTo: Ext4.getBody().createChild({tag: 'div', customizeView: true, style: {display: 'none'}}), + activeTab: activeTab, + dataRegion: this, + containerPath : this.containerPath, + schemaName: this.schemaName, + queryName: this.queryName, + viewName: viewName, + query: queryDetails, + userFilter: userFilter, + userSort: userSort, + userColumns: userColumns, + userContainerFilter: this.getUserContainerFilter(), + allowableContainerFilters: this.allowableContainerFilters + }); + + this.customizeView.on('viewsave', function(designer, savedViewsInfo, urlParameters) { + _onViewSave.apply(this, [this, designer, savedViewsInfo, urlParameters]); + }, this); + + this.customizeView.on({ + beforedeleteview: function(cv, revert) { + _beforeViewDelete(region, revert); + }, + deleteview: function(cv, success, json) { + _onViewDelete(region, success, json); + } + }); + + var first = true; + + // Called when customize view needs to be shown + var showFn = function(id, panel, element, callback, scope) { + if (first) { + panel.hide(); + panel.getEl().appendTo(Ext4.get(element[0])); + first = false; + } + panel.doLayout(); + $(panel.getEl().dom).slideDown(undefined, function() { + panel.show(); + callback.call(scope); + }); + }; + + // Called when customize view needs to be hidden + var hideFn = function(id, panel, element, callback, scope) { + $(panel.getEl().dom).slideUp(undefined, function() { + panel.hide(); + callback.call(scope); + }); + }; + + this.publishPanel(CUSTOM_VIEW_PANELID, this.customizeView, showFn, hideFn, this); + this.showPanel(CUSTOM_VIEW_PANELID); + }; + var failure = function() { + timerId > 0 ? clearTimeout(timerId) : this.hideLoadingMessage(); + }; + + this.getQueryDetails(success, failure, this); + }, region); + } + else { + if (activeTab) { + panelConfig.panel.setActiveDesignerTab(activeTab); + } + this.showPanel(CUSTOM_VIEW_PANELID); + } + }; + + /** + * @ignore + * @private + * Shows/Hides customize view depending on if it is currently shown + */ + LABKEY.DataRegion.prototype.toggleShowCustomizeView = function() { + if (this.activePanelId === CUSTOM_VIEW_PANELID) { + this.hideCustomizeView(); + } + else { + this.showCustomizeView(undefined); + } + }; + + var _defaultShow = function(panelId, panel, ribbon, cb, cbScope) { + $('#' + panelId).slideDown(undefined, function() { + cb.call(cbScope); + }); + }; + + var _defaultHide = function(panelId, panel, ribbon, cb, cbScope) { + $('#' + panelId).slideUp(undefined, function() { + cb.call(cbScope); + }); + }; + + // TODO this is a pretty bad prototype, consider using config parameter with backward compat option + LABKEY.DataRegion.prototype.publishPanel = function(panelId, panel, showFn, hideFn, scope, friendlyName) { + this.panelConfigurations[panelId] = { + panelId: panelId, + panel: panel, + show: $.isFunction(showFn) ? showFn : _defaultShow, + hide: $.isFunction(hideFn) ? hideFn : _defaultHide, + scope: scope + }; + if (friendlyName && friendlyName !== panelId) + this.panelConfigurations[friendlyName] = this.panelConfigurations[panelId]; + return this; + }; + + LABKEY.DataRegion.prototype.getPanelConfiguration = function(panelId) { + return this.panelConfigurations[panelId]; + }; + + /** + * @ignore + * Hides any panel that is currently visible. Returns a callback once the panel is hidden. + */ + LABKEY.DataRegion.prototype.hidePanel = function(callback, scope) { + if (this.activePanelId) { + var config = this.getPanelConfiguration(this.activePanelId); + if (config) { + + // find the ribbon container + var ribbon = _getDrawerSelector(this); + + config.hide.call(config.scope || this, this.activePanelId, config.panel, ribbon, function() { + this.activePanelId = undefined; + ribbon.hide(); + if ($.isFunction(callback)) { + callback.call(scope || this); + } + LABKEY.Utils.signalWebDriverTest("dataRegionPanelHide"); + $(this).trigger($.Event('afterpanelhide'), [this]); + }, this); + } + } + else { + if ($.isFunction(callback)) { + callback.call(scope || this); + } + } + }; + + LABKEY.DataRegion.prototype.showPanel = function(panelId, callback, scope) { + + var config = this.getPanelConfiguration(panelId); + + if (!config) { + console.error('Unable to find panel for id (' + panelId + '). Use publishPanel() to register a panel to be shown.'); + return; + } + + this.hideContext(); + this.hideMessage(true); + + this.hidePanel(function() { + this.activePanelId = config.panelId; + + // ensure the ribbon is visible + var ribbon = _getDrawerSelector(this); + ribbon.show(); + + config.show.call(config.scope || this, this.activePanelId, config.panel, ribbon, function() { + if ($.isFunction(callback)) { + callback.call(scope || this); + } + LABKEY.Utils.signalWebDriverTest("dataRegionPanelShow"); + $(this).trigger($.Event('afterpanelshow'), [this]); + }, this); + }, this); + }; + + function _hasPanelOpen(dr) { + return dr.activePanelId !== undefined; + } + + function _hasButtonBarMenuOpen(dr) { + return _getBarSelector(dr).find(".lk-menu-drop.open").length > 0; + } + + /** + * Returns true if the user has interacted with the DataRegion by changing + * the selection, opening a button menu, or opening a panel. + * @return {boolean} + * @private + */ + LABKEY.DataRegion.prototype.isUserInteracting = function () { + return this.selectionModified || _hasPanelOpen(this) || _hasButtonBarMenuOpen(this); + }; + + // + // Misc + // + + /** + * @private + */ + var _initHeaderLocking = function() { + if (this._allowHeaderLock === true) { + this.hLock = new HeaderLock(this); + } + }; + + /** + * @private + */ + var _initPanes = function() { + var callbacks = _paneCache[this.name]; + if (callbacks) { + var me = this; + callbacks.forEach(function(config) { + config.cb.call(config.scope || me, me); + }); + delete _paneCache[this.name]; + } + }; + + /** + * @private + */ + var _initReport = function() { + if (LABKEY.Utils.isObject(this.report)) { + this.addMessage({ + html: [ + 'Name:', + LABKEY.Utils.encodeHtml(this.report.name), + 'Source:', + LABKEY.Utils.encodeHtml(this.report.source) + ].join(' '), + part: 'report', + }); + } + }; + + // These study specific functions/constants should be moved out of Data Region + // and into their own dependency. + + var COHORT_LABEL = '/Cohort/Label'; + var ADV_COHORT_LABEL = '/InitialCohort/Label'; + var COHORT_ENROLLED = '/Cohort/Enrolled'; + var ADV_COHORT_ENROLLED = '/InitialCohort/Enrolled'; + + /** + * DO NOT CALL DIRECTLY. This method is private and only available for removing cohort/group filters + * for this Data Region. + * @param subjectColumn + * @param groupNames + * @private + */ + LABKEY.DataRegion.prototype._removeCohortGroupFilters = function(subjectColumn, groupNames) { + this.clearSelected({quiet: true}); + var params = _getParameters(this); + var skips = [], i, p, k; + + var keys = [ + subjectColumn + COHORT_LABEL, + subjectColumn + ADV_COHORT_LABEL, + subjectColumn + COHORT_ENROLLED, + subjectColumn + ADV_COHORT_ENROLLED + ]; + + if (LABKEY.Utils.isArray(groupNames)) { + for (k=0; k < groupNames.length; k++) { + keys.push(subjectColumn + '/' + groupNames[k]); + } + } + + for (i = 0; i < params.length; i++) { + p = params[i][0]; + if (p.indexOf(this.name + '.') === 0) { + for (k=0; k < keys.length; k++) { + if (p.indexOf(keys[k] + '~') > -1) { + skips.push(p); + k = keys.length; // break loop + } + } + } + } + + _updateFilter(this, undefined, skips); + }; + + /** + * DO NOT CALL DIRECTLY. This method is private and only available for replacing advanced cohort filters + * for this Data Region. Remove if advanced cohorts are removed. + * @param filter + * @private + */ + LABKEY.DataRegion.prototype._replaceAdvCohortFilter = function(filter) { + this.clearSelected({quiet: true}); + var params = _getParameters(this); + var skips = [], i, p; + + for (i = 0; i < params.length; i++) { + p = params[i][0]; + if (p.indexOf(this.name + '.') === 0) { + if (p.indexOf(COHORT_LABEL) > -1 || p.indexOf(ADV_COHORT_LABEL) > -1 || p.indexOf(COHORT_ENROLLED) > -1 || p.indexOf(ADV_COHORT_ENROLLED)) { + skips.push(p); + } + } + } + + _updateFilter(this, filter, skips); + }; + + /** + * Looks for a column based on fieldKey, name, displayField, or caption (in that order) + * @param columnIdentifier + * @returns {*} + */ + LABKEY.DataRegion.prototype.getColumn = function(columnIdentifier) { + + var column = null, // backwards compat + isString = LABKEY.Utils.isString, + cols = this.columns; + + if (isString(columnIdentifier) && LABKEY.Utils.isArray(cols)) { + $.each(['fieldKey', 'name', 'displayField', 'caption'], function(i, key) { + $.each(cols, function(c, col) { + if (isString(col[key]) && col[key] == columnIdentifier) { + column = col; + return false; + } + }); + if (column) { + return false; + } + }); + } + + return column; + }; + + /** + * Returns a query config object suitable for passing into LABKEY.Query.selectRows() or other LABKEY.Query APIs. + * @returns {Object} Object representing the query configuration that generated this grid. + */ + LABKEY.DataRegion.prototype.getQueryConfig = function() { + var config = { + dataRegionName: this.name, + dataRegionSelectionKey: this.selectionKey, + schemaName: this.schemaName, + viewName: this.viewName, + sort: this.getParameter(this.name + SORT_PREFIX), + // NOTE: The parameterized query values from QWP are included + parameters: this.getParameters(false), + containerFilter: this.containerFilter + }; + + if (this.queryName) { + config.queryName = this.queryName; + } + else if (this.sql) { + config.sql = this.sql; + } + + var filters = this.getUserFilterArray(); + if (filters.length > 0) { + config.filters = filters; + } + + return config; + }; + + /** + * Hide the ribbon panel. If visible the ribbon panel will be hidden. + */ + LABKEY.DataRegion.prototype.hideButtonPanel = function() { + this.hidePanel(); + this.showContext(); + this.showMessageArea(); + }; + + /** + * Allows for asynchronous rendering of the Data Region. This region must be in "async" mode for + * this to do anything. + * @function + * @param {String} [renderTo] - The element ID where to render the data region. If not given it will default to + * the current renderTo target is. + */ + LABKEY.DataRegion.prototype.render = function(renderTo) { + if (!this.RENDER_LOCK && this.async) { + _convertRenderTo(this, renderTo); + this.refresh(); + } + }; + + /** + * Show a ribbon panel. + * + * first arg can be button on the button bar or target panel id/configuration + */ + + LABKEY.DataRegion.prototype.toggleButtonPanelHandler = function(panelButton) { + _toggleButtonPanel( this, $(panelButton).attr('data-labkey-panel-toggle'), null, true); + }; + + LABKEY.DataRegion.prototype.showButtonPanel = function(panel, optionalTab) { + _toggleButtonPanel(this, panel, optionalTab, false); + }; + + LABKEY.DataRegion.prototype.toggleButtonPanel = function(panel, optionalTab) { + _toggleButtonPanel(this, panel, optionalTab, true); + }; + + var _toggleButtonPanel = function(dr, panel, optionalTab, toggle) { + var ribbon = _getDrawerSelector(dr); + // first check if this is a named panel instead of a button element + var panelId, panelSel; + if (typeof panel === 'string' && dr.getPanelConfiguration(panel)) + panelId = dr.getPanelConfiguration(panel).panelId; + else + panelId = panel; + + if (panelId) { + + panelSel = $('#' + panelId); + + // allow for toggling the state + if (panelId === dr.activePanelId) { + if (toggle) { + dr.hideButtonPanel(); + return; + } + } + else { + // determine if the content needs to be moved to the ribbon + if (ribbon.has(panelSel).length === 0) { + panelSel.detach().appendTo(ribbon); + } + + // determine if this panel has been registered + if (!dr.getPanelConfiguration(panelId) && panelSel.length > 0) { + dr.publishPanel(panelId, panelId); + } + + dr.showPanel(panelId); + } + if (optionalTab) + { + var t = panelSel.find('a[data-toggle="tab"][href="#' + optionalTab + '"]'); + if (!t.length) + t = panelSel.find('a[data-toggle="tab"][data-tabName="' + optionalTab + '"]'); + t.tab('show'); + } + } + }; + + LABKEY.DataRegion.prototype.loadFaceting = function(cb, scope) { + + var region = this; + + var onLoad = function() { + region.facetLoaded = true; + if ($.isFunction(cb)) { + cb.call(scope || this); + } + }; + + LABKEY.requiresExt4ClientAPI(function() { + if (LABKEY.devMode) { + // should match study/ParticipantFilter.lib.xml + LABKEY.requiresScript([ + '/study/ReportFilterPanel.js', + '/study/ParticipantFilterPanel.js' + ], function() { + LABKEY.requiresScript('/dataregion/panel/Facet.js', onLoad); + }); + } + else { + LABKEY.requiresScript('/study/ParticipantFilter.min.js', function() { + LABKEY.requiresScript('/dataregion/panel/Facet.js', onLoad); + }); + } + }, this); + }; + + LABKEY.DataRegion.prototype.showFaceting = function() { + if (this.facetLoaded) { + if (!this.facet) { + this.facet = LABKEY.dataregion.panel.Facet.display(this); + } + this.facet.toggleCollapse(); + } + else { + this.loadFaceting(this.showFaceting, this); + } + }; + + LABKEY.DataRegion.prototype.on = function(evt, callback, scope) { + // Prevent from handing back the jQuery event itself. + $(this).bind(evt, function() { callback.apply(scope || this, $(arguments).slice(1)); }); + }; + + LABKEY.DataRegion.prototype.one = function(evt, callback, scope) { + $(this).one(evt, function() { callback.apply(scope || this, $(arguments).slice(1)); }); + }; + + LABKEY.DataRegion.prototype._onButtonClick = function(buttonId) { + var item = this.findButtonById(this.buttonBar.items, buttonId); + if (item && $.isFunction(item.handler)) { + try { + return item.handler.call(item.scope || this, this); + } + catch(ignore) {} + } + return false; + }; + + LABKEY.DataRegion.prototype.findButtonById = function(items, id) { + if (!items || !items.length || items.length <= 0) { + return null; + } + + var ret; + for (var i = 0; i < items.length; i++) { + if (items[i].id == id) { + return items[i]; + } + ret = this.findButtonById(items[i].items, id); + if (null != ret) { + return ret; + } + } + + return null; + }; + + LABKEY.DataRegion.prototype.headerLock = function() { return this._allowHeaderLock === true; }; + + LABKEY.DataRegion.prototype.disableHeaderLock = function() { + if (this.headerLock() && this.hLock) { + this.hLock.disable(); + this.hLock = undefined; + } + }; + + /** + * Add or remove a summary statistic for a given column in the DataRegion query view. + * @param viewName + * @param colFieldKey + * @param summaryStatName + */ + LABKEY.DataRegion.prototype.toggleSummaryStatForCustomView = function(viewName, colFieldKey, summaryStatName) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + var colProviderNames = []; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey === colFieldKey) + colProviderNames.push(existingProvider.name); + }); + + if (colProviderNames.indexOf(summaryStatName) === -1) { + _addAnalyticsProviderToView.call(this, view, colFieldKey, summaryStatName, true); + } + else { + _removeAnalyticsProviderFromView.call(this, view, colFieldKey, summaryStatName, true); + } + } + }, null, this); + }; + + /** + * Get the array of selected ColumnAnalyticsProviders for the given column FieldKey in a view. + * @param viewName + * @param colFieldKey + * @param callback + * @param callbackScope + */ + LABKEY.DataRegion.prototype.getColumnAnalyticsProviders = function(viewName, colFieldKey, callback, callbackScope) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + var colProviderNames = []; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey === colFieldKey) { + colProviderNames.push(existingProvider.name); + } + }); + + if ($.isFunction(callback)) { + callback.call(callbackScope, colProviderNames); + } + } + }, null, this); + }; + + /** + * Set the summary statistic ColumnAnalyticsProviders for the given column FieldKey in the view. + * @param viewName + * @param colFieldKey + * @param summaryStatProviderNames + */ + LABKEY.DataRegion.prototype.setColumnSummaryStatistics = function(viewName, colFieldKey, summaryStatProviderNames) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + var newAnalyticsProviders = []; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey !== colFieldKey || existingProvider.name.indexOf('AGG_') != 0) { + newAnalyticsProviders.push(existingProvider); + } + }); + + $.each(summaryStatProviderNames, function(index, providerName) { + newAnalyticsProviders.push({ + fieldKey: colFieldKey, + name: providerName, + isSummaryStatistic: true + }); + }); + + view.analyticsProviders = newAnalyticsProviders; + _updateSessionCustomView.call(this, view, true); + } + }, null, this); + }; + + /** + * Used via SummaryStatisticsAnalyticsProvider to show a dialog of the applicable summary statistics for a column in the view. + * @param colFieldKey + */ + LABKEY.DataRegion.prototype.showColumnStatisticsDialog = function(colFieldKey) { + LABKEY.requiresScript('query/ColumnSummaryStatistics', function() { + var regionViewName = this.viewName || "", + column = this.getColumn(colFieldKey); + + if (column) { + this.getColumnAnalyticsProviders(regionViewName, colFieldKey, function(colSummaryStats) { + Ext4.create('LABKEY.ext4.ColumnSummaryStatisticsDialog', { + queryConfig: this.getQueryConfig(), + filterArray: LABKEY.Filter.getFiltersFromUrl(this.selectAllURL, 'query'), //Issue 26594 + containerPath: this.containerPath, + column: column, + initSelection: colSummaryStats, + listeners: { + scope: this, + applySelection: function(win, colSummaryStatsNames) { + win.getEl().mask("Applying selection..."); + this.setColumnSummaryStatistics(regionViewName, colFieldKey, colSummaryStatsNames); + win.close(); + } + } + }).show(); + }, this); + } + }, this); + }; + + /** + * Remove a column from the given DataRegion query view. + * @param viewName + * @param colFieldKey + */ + LABKEY.DataRegion.prototype.removeColumn = function(viewName, colFieldKey) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + var colFieldKeys = $.map(view.columns, function (c) { + return c.fieldKey; + }), + fieldKeyIndex = colFieldKeys.indexOf(colFieldKey); + + if (fieldKeyIndex > -1) { + view.columns.splice(fieldKeyIndex, 1); + _updateSessionCustomView.call(this, view, true); + } + } + }, null, this); + }; + + /** + * Add the enabled analytics provider to the custom view definition based on the column fieldKey and provider name. + * In addition, disable the column menu item if the column is visible in the grid. + * @param viewName + * @param colFieldKey + * @param providerName + */ + LABKEY.DataRegion.prototype.addAnalyticsProviderForCustomView = function(viewName, colFieldKey, providerName) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + _addAnalyticsProviderToView.call(this, view, colFieldKey, providerName, false); + _updateAnalyticsProviderMenuItem(this.name + ':' + colFieldKey, providerName, true); + } + }, null, this); + }; + + /** + * Remove an enabled analytics provider from the custom view definition based on the column fieldKey and provider name. + * In addition, enable the column menu item if the column is visible in the grid. + * @param viewName + * @param colFieldKey + * @param providerName + */ + LABKEY.DataRegion.prototype.removeAnalyticsProviderForCustomView = function(viewName, colFieldKey, providerName) { + this.getQueryDetails(function(queryDetails) { + var view = _getViewFromQueryDetails(queryDetails, viewName); + if (view && _viewContainsColumn(view, colFieldKey)) { + _removeAnalyticsProviderFromView.call(this, view, colFieldKey, providerName, false); + _updateAnalyticsProviderMenuItem(this.name + ':' + colFieldKey, providerName, false); + } + }, null, this); + }; + + /** + * @private + */ + LABKEY.DataRegion.prototype._openFilter = function(columnName, evt) { + if (evt && $(evt.target).hasClass('fa-close')) { + return; + } + + var column = this.getColumn(columnName); + + if (column) { + var show = function() { + this._dialogLoaded = true; + new LABKEY.FilterDialog({ + dataRegionName: this.name, + column: this.getColumn(columnName), + cacheFacetResults: false // could have changed on Ajax + }).show(); + }.bind(this); + + this._dialogLoaded ? show() : LABKEY.requiresExt3ClientAPI(show); + } + else { + LABKEY.Utils.alert('Column not available', 'Unable to find column "' + columnName + '" in this view.'); + } + }; + + var _updateSessionCustomView = function(customView, requiresRefresh) { + var viewConfig = $.extend({}, customView, { + shared: false, + inherit: false, + hidden: false, + session: true + }); + + LABKEY.Query.saveQueryViews({ + containerPath: this.containerPath, + schemaName: this.schemaName, + queryName: this.queryName, + views: [viewConfig], + scope: this, + success: function(info) { + if (requiresRefresh) { + this.refresh(); + } + else if (info.views.length === 1) { + this.view = info.views[0]; + _initCustomViews.call(this); + this.showContext(); + } + } + }); + }; + + var _addAnalyticsProviderToView = function(view, colFieldKey, providerName, isSummaryStatistic) { + var colProviderNames = []; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey === colFieldKey) + colProviderNames.push(existingProvider.name); + }); + + if (colProviderNames.indexOf(providerName) === -1) { + view.analyticsProviders.push({ + fieldKey: colFieldKey, + name: providerName, + isSummaryStatistic: isSummaryStatistic + }); + + _updateSessionCustomView.call(this, view, isSummaryStatistic); + } + }; + + var _removeAnalyticsProviderFromView = function(view, colFieldKey, providerName, isSummaryStatistic) { + var indexToRemove = null; + $.each(view.analyticsProviders, function(index, existingProvider) { + if (existingProvider.fieldKey === colFieldKey && existingProvider.name === providerName) { + indexToRemove = index; + return false; + } + }); + + if (indexToRemove != null) { + view.analyticsProviders.splice(indexToRemove, 1); + _updateSessionCustomView.call(this, view, isSummaryStatistic); + } + }; + + /** + * Attempt to find a DataRegion analytics provider column menu item so that it can be either enabled to allow + * it to once again be selected after removal or disabled so that it can't be selected a second time. + * @param columnName the DataRegion column th element column-name attribute + * @param providerName the analytics provider name + * @param disable + * @private + */ + var _updateAnalyticsProviderMenuItem = function(columnName, providerName, disable) { + var menuItemEl = $("th[column-name|='" + columnName + "']").find("a[onclick*='" + providerName + "']").parent(); + if (menuItemEl) { + if (disable) { + menuItemEl.addClass('disabled'); + } + else { + menuItemEl.removeClass('disabled'); + } + } + }; + + // + // PRIVATE FUNCTIONS + // + var _applyOptionalParameters = function(region, params, optionalParams) { + optionalParams.forEach(function(p) { + if (LABKEY.Utils.isObject(p)) { + if (region[p.name] !== undefined) { + if (p.check && !p.check.call(region, region[p.name])) { + return; + } + if (p.prefix) { + params[region.name + '.' + p.name] = region[p.name]; + } + else { + params[p.name] = region[p.name]; + } + } + } + else if (p && region[p] !== undefined) { + params[p] = region[p]; + } + }); + }; + + var _alterSortString = function(region, current, fieldKey, direction /* optional */) { + fieldKey = _resolveFieldKey(region, fieldKey); + + var columnName = fieldKey.toString(), + newSorts = []; + + if (current != null) { + current.split(',').forEach(function(sort) { + if (sort.length > 0 && (sort != columnName) && (sort != SORT_ASC + columnName) && (sort != SORT_DESC + columnName)) { + newSorts.push(sort); + } + }); + } + + if (direction === SORT_ASC) { // Easier to read without the encoded + on the URL... + direction = ''; + } + + if (LABKEY.Utils.isString(direction)) { + newSorts = [direction + columnName].concat(newSorts); + } + + return newSorts.join(','); + }; + + var _ensureFilterDateFormat = function(value) { + if (LABKEY.Utils.isDate(value)) { + value = $.format.date(value, 'yyyy-MM-dd'); + if (LABKEY.Utils.endsWith(value, 'Z')) { + value = value.substring(0, value.length - 1); + } + } + + return value; + } + + var _buildQueryString = function(region, pairs) { + if (!LABKEY.Utils.isArray(pairs)) { + return ''; + } + + var queryParts = [], key, value; + + pairs.forEach(function(pair) { + key = pair[0]; + value = pair.length > 1 ? pair[1] : undefined; + + queryParts.push(encodeURIComponent(key)); + if (LABKEY.Utils.isDefined(value)) { + + value = _ensureFilterDateFormat(value); + queryParts.push('='); + queryParts.push(encodeURIComponent(value)); + } + queryParts.push('&'); + }); + + if (queryParts.length > 0) { + queryParts.pop(); + } + + return queryParts.join(""); + }; + + var _chainSelectionCountCallback = function(region, config) { + + var success = LABKEY.Utils.getOnSuccess(config); + + // On success, update the current selectedCount on this DataRegion and fire the 'selectchange' event + config.success = function(data) { + region.removeMessage('selection'); + region.selectionModified = true; + region.selectedCount = data.count; + _onSelectionChange(region); + + // Chain updateSelected with the user-provided success callback + if ($.isFunction(success)) { + success.call(config.scope, data); + } + }; + + return config; + }; + + var _convertRenderTo = function(region, renderTo) { + if (renderTo) { + if (LABKEY.Utils.isString(renderTo)) { + region.renderTo = renderTo; + } + else if (LABKEY.Utils.isString(renderTo.id)) { + region.renderTo = renderTo.id; // support 'Ext' elements + } + else { + throw 'Unsupported "renderTo"'; + } + } + + return region; + }; + + var _deleteTimer; + + var _beforeViewDelete = function(region, revert) { + _deleteTimer = setTimeout(function() { + _deleteTimer = 0; + region.showLoadingMessage(revert ? 'Reverting view...' : 'Deleting view...'); + }, 500); + }; + + var _onViewDelete = function(region, success, json) { + if (_deleteTimer) { + clearTimeout(_deleteTimer); + } + + if (success) { + region.removeMessage.call(region, 'customizeview'); + region.showSuccessMessage.call(region); + + // change view to either a shadowed view or the default view + var config = { type: 'view' }; + if (json.viewName) { + config.viewName = json.viewName; + } + region.changeView.call(region, config); + } + else { + region.removeMessage.call(region, 'customizeview'); + region.showErrorMessage.call(region, json.exception); + } + }; + + // The view can be reverted without ViewDesigner present + var _revertCustomView = function(region) { + _beforeViewDelete(region, true); + + var config = { + schemaName: region.schemaName, + queryName: region.queryName, + containerPath: region.containerPath, + revert: true, + success: function(json) { + _onViewDelete(region, true /* success */, json); + }, + failure: function(json) { + _onViewDelete(region, false /* success */, json); + } + }; + + if (region.viewName) { + config.viewName = region.viewName; + } + + LABKEY.Query.deleteQueryView(config); + }; + + var _getViewFromQueryDetails = function(queryDetails, viewName) { + var matchingView; + + $.each(queryDetails.views, function(index, view) { + if (view.name === viewName) { + matchingView = view; + return false; + } + }); + + return matchingView; + }; + + var _viewContainsColumn = function(view, colFieldKey) { + var keys = $.map(view.columns, function(c) { + return c.fieldKey.toLowerCase(); + }); + var exists = colFieldKey && keys.indexOf(colFieldKey.toLowerCase()) > -1; + + if (!exists) { + console.warn('Unable to find column in view: ' + colFieldKey); + } + + return exists; + }; + + var _getAllRowSelectors = function(region) { + return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".toggle"]'); + }; + + var _getBarSelector = function(region) { + return $('#' + region.domId + '-headerbar'); + }; + + var _getContextBarSelector = function(region) { + return $('#' + region.domId + '-ctxbar'); + }; + + var _getDrawerSelector = function(region) { + return $('#' + region.domId + '-drawer'); + }; + + var _getFormSelector = function(region) { + var form = $('form#' + region.domId + '-form'); + + // derived DataRegion's may not include the form id + if (form.length === 0) { + form = $('#' + region.domId).closest('form'); + } + + return form; + }; + + var _getHeaderSelector = function(region) { + return $('#' + region.domId + '-header'); + }; + + var _getRowSelectors = function(region) { + return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".select"]'); + }; + + var _getSectionSelector = function(region, dir) { + return $('#' + region.domId + '-section-' + dir); + }; + + var _getShowFirstSelector = function(region) { + return $('#' + region.showFirstID); + }; + + var _getShowLastSelector = function(region) { + return $('#' + region.showLastID); + }; + + var _getShowAllSelector = function(region) { + return $('#' + region.showAllID); + }; + + // Formerly, LABKEY.DataRegion.getParamValPairsFromString / LABKEY.DataRegion.getParamValPairs + var _getParameters = function(region, skipPrefixSet /* optional */) { + + var params = []; + var qString = region.requestURL; + + if (LABKEY.Utils.isString(qString) && qString.length > 0) { + + var qmIdx = qString.indexOf('?'); + if (qmIdx > -1) { + qString = qString.substring(qmIdx + 1); + + var poundIdx = qString.indexOf('#'); + if (poundIdx > -1) + qString = qString.substr(0, poundIdx); + + if (qString.length > 1) { + var pairs = qString.split('&'), p, key, + LAST = '.lastFilter', lastIdx, skip = LABKEY.Utils.isArray(skipPrefixSet); + + var exactMatches = EXACT_MATCH_PREFIXES.map(function (prefix) { + return region.name + prefix; + }); + + $.each(pairs, function (i, pair) { + p = pair.split('=', 2); + key = p[0] = decodeURIComponent(p[0]); + lastIdx = key.indexOf(LAST); + + if (lastIdx > -1 && lastIdx === (key.length - LAST.length)) { + return; + } + else if (REQUIRE_NAME_PREFIX.hasOwnProperty(key)) { + // Issue 26686: Block known parameters, should be prefixed by region name + return; + } + + var stop = false; + if (skip) { + $.each(skipPrefixSet, function (j, skipPrefix) { + if (LABKEY.Utils.isString(skipPrefix)) { + + // Special prefix that should remove all filters, but no other parameters for the current grid + if (skipPrefix.indexOf(ALL_FILTERS_SKIP_PREFIX) === (skipPrefix.length - 2)) { + if (key.indexOf(region.name + '.') === 0 && key.indexOf('~') > 0) { + + stop = true; + return false; + } + } + else { + if (exactMatches.indexOf(skipPrefix) > -1) { + if (key === skipPrefix) { + stop = true; + return false; + } + } + else if (key.toLowerCase().indexOf(skipPrefix.toLowerCase()) === 0) { + // only skip filters, parameters, and sorts for the current grid + if (key.indexOf(region.name + '.') === 0 && + + (key === skipPrefix || + key.indexOf('~') > 0 || + key.indexOf(PARAM_PREFIX) > 0 || + key === (skipPrefix + 'sort'))) { + stop = true; + return false; + } + } + } + } + }); + } + + if (!stop) { + if (p.length > 1) { + p[1] = decodeURIComponent(p[1]); + } + params.push(p); + } + }); + } + } + } + + return params; + }; + + /** + * + * @param region + * @param {boolean} [asString=false] + * @private + */ + var _getUserSort = function(region, asString) { + var userSort = [], + sortParam = region.getParameter(region.name + SORT_PREFIX); + + if (asString) { + userSort = sortParam || ''; + } + else { + if (sortParam) { + var fieldKey, dir; + sortParam.split(',').forEach(function(sort) { + fieldKey = sort; + dir = SORT_ASC; + if (sort.charAt(0) === SORT_DESC) { + fieldKey = fieldKey.substring(1); + dir = SORT_DESC; + } + else if (sort.charAt(0) === SORT_ASC) { + fieldKey = fieldKey.substring(1); + } + userSort.push({fieldKey: fieldKey, dir: dir}); + }); + } + } + + return userSort; + }; + + var _getViewBarSelector = function(region) { + return $('#' + region.domId + '-viewbar'); + }; + + var _buttonSelectionBind = function(region, cls, fn) { + var partEl = region.msgbox.getParent().find('div[data-msgpart="selection"]'); + partEl.find('.labkey-button' + cls).off('click').on('click', $.proxy(function() { + fn.call(this); + }, region)); + }; + + var _onRenderMessageArea = function(region, parts) { + var msgArea = region.msgbox; + if (msgArea) { + if (region.showRecordSelectors && parts['selection']) { + _buttonSelectionBind(region, '.select-all', region.selectAll); + _buttonSelectionBind(region, '.select-none', region.clearSelected); + _buttonSelectionBind(region, '.show-all', region.showAll); + _buttonSelectionBind(region, '.show-selected', region.showSelectedRows); + _buttonSelectionBind(region, '.show-unselected', region.showUnselectedRows); + } + else if (parts['customizeview']) { + _buttonSelectionBind(region, '.unsavedview-revert', function() { _revertCustomView(this); }); + _buttonSelectionBind(region, '.unsavedview-edit', function() { this.showCustomizeView(undefined); }); + _buttonSelectionBind(region, '.unsavedview-save', function() { _saveSessionCustomView(this); }); + } + } + }; + + var _onSelectionChange = function(region) { + $(region).trigger('selectchange', [region, region.selectedCount]); + _updateRequiresSelectionButtons(region, region.selectedCount); + LABKEY.Utils.signalWebDriverTest('dataRegionUpdate', region.selectedCount); + LABKEY.Utils.signalWebDriverTest('dataRegionUpdate-' + region.name, region.selectedCount); + }; + + var _onViewSave = function(region, designer, savedViewsInfo, urlParameters) { + if (savedViewsInfo && savedViewsInfo.views.length > 0) { + region.hideCustomizeView.call(region); + region.changeView.call(region, { + type: 'view', + viewName: savedViewsInfo.views[0].name + }, urlParameters); + } + }; + + var _removeParameters = function(region, skipPrefixes /* optional */) { + return _setParameters(region, null, skipPrefixes); + }; + + var _resolveFieldKey = function(region, fieldKey) { + var fk = fieldKey; + if (!(fk instanceof LABKEY.FieldKey)) { + fk = LABKEY.FieldKey.fromString('' + fk); + } + return fk; + }; + + var _saveSessionCustomView = function(region) { + // Note: currently only will save session views. Future version could create a new view using url sort/filters. + if (!(region.view && region.view.session)) { + return; + } + + // Get the canEditSharedViews permission and candidate targetContainers. + var viewName = (region.view && region.view.name) || region.viewName || ''; + + LABKEY.Query.getQueryDetails({ + schemaName: region.schemaName, + queryName: region.queryName, + viewName: viewName, + initializeMissingView: false, + containerPath: region.containerPath, + success: function (json) { + // Display an error if there was an issue error getting the query details + if (json.exception) { + var viewSourceUrl = LABKEY.ActionURL.buildURL('query', 'viewQuerySource.view', null, {schemaName: this.schemaName, "query.queryName": this.queryName}); + var msg = LABKEY.Utils.encodeHtml(json.exception) + "  View Source"; + + this.showErrorMessage.call(this, msg); + return; + } + + _saveSessionShowPrompt(this, json); + }, + scope: region + }); + }; + + var _saveSessionView = function(o, region, win) { + var timerId = setTimeout(function() { + timerId = 0; + Ext4.Msg.progress("Saving...", "Saving custom view..."); + }, 500); + + var jsonData = { + schemaName: region.schemaName, + "query.queryName": region.queryName, + "query.viewName": region.viewName, + newName: o.name, + inherit: o.inherit, + shared: o.shared, + hidden: o.hidden, + replace: o.replace, + }; + + if (o.inherit) { + jsonData.containerPath = o.containerPath; + } + + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'saveSessionView', region.containerPath), + method: 'POST', + jsonData: jsonData, + callback: function() { + if (timerId > 0) + clearTimeout(timerId); + win.close(); + }, + success: function() { + region.showSuccessMessage.call(region); + region.changeView.call(region, {type: 'view', viewName: o.name}); + }, + failure: function(resp) { + var json = resp.responseText ? Ext4.decode(resp.responseText) : resp; + if (json.exception && json.exception.indexOf('A saved view by the name') === 0) { + + Ext4.Msg.show({ + title : "Duplicate View Name", + msg : json.exception + " Would you like to replace it?", + cls : 'data-window', + icon : Ext4.Msg.QUESTION, + buttons : Ext4.Msg.YESNO, + fn : function(btn) { + if (btn === 'yes') { + o.replace = true; + _saveSessionView(o, region, win); + } + }, + scope : this + }); + } + else + Ext4.Msg.alert('Error saving view', json.exception || json.statusText || Ext4.decode(json.responseText).exception); + }, + scope: region + }); + }; + + var _saveSessionShowPrompt = function(region, queryDetails) { + LABKEY.DataRegion.loadViewDesigner(function() { + var config = Ext4.applyIf({ + allowableContainerFilters: region.allowableContainerFilters, + targetContainers: queryDetails.targetContainers, + canEditSharedViews: queryDetails.canEditSharedViews, + canEdit: LABKEY.DataRegion.getCustomViewEditableErrors(config).length == 0, + success: function (win, o) { + _saveSessionView(o, region, win); + }, + scope: region + }, region.view); + + LABKEY.internal.ViewDesigner.Designer.saveCustomizeViewPrompt(config); + }); + }; + + var _setParameter = function(region, param, value, skipPrefixes /* optional */) { + _setParameters(region, [[param, value]], skipPrefixes); + }; + + var _setParameters = function(region, newParamValPairs, skipPrefixes /* optional */) { + // prepend region name + // e.g. ['.hello', '.goodbye'] becomes ['aqwp19.hello', 'aqwp19.goodbye'] + if (LABKEY.Utils.isArray(skipPrefixes)) { + skipPrefixes.forEach(function(skip, i) { + if (skip && skip.indexOf(region.name + '.') !== 0) { + skipPrefixes[i] = region.name + skip; + } + }); + } + + var param, value, + params = _getParameters(region, skipPrefixes); + + if (LABKEY.Utils.isArray(newParamValPairs)) { + newParamValPairs.forEach(function(newPair) { + if (!LABKEY.Utils.isArray(newPair)) { + throw new Error("DataRegion: _setParameters newParamValPairs improperly initialized. It is an array of arrays. You most likely passed in an array of strings."); + } + param = newPair[0]; + value = newPair[1]; + + // Allow value to be null/undefined to support no-value filter types (Is Blank, etc) + if (LABKEY.Utils.isString(param) && param.length > 1) { + if (param.indexOf(region.name) !== 0) { + param = region.name + param; + } + + params.push([param, value]); + } + }); + } + + if (region.async) { + _load(region, params, skipPrefixes); + } + else { + region.setSearchString.call(region, region.name, _buildQueryString(region, params)); + } + }; + + var _showRows = function(region, showRowsEnum) { + // no need to re-query for totalRowCount, if async + this.skipTotalRowCount = true; + + // clear sibling parameters, could we do this with events? + this.maxRows = undefined; + this.offset = 0; + + _setParameter(region, SHOW_ROWS_PREFIX, showRowsEnum, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); + }; + + var _showSelectMessage = function(region, msg) { + if (region.showRecordSelectors) { + if (region.totalRows && region.totalRows !== region.selectedCount && region.selectedCount < MAX_SELECTION_SIZE) { + let text = 'Select All Rows'; + if (region.totalRows > MAX_SELECTION_SIZE) { + text = `Select First ${MAX_SELECTION_SIZE.toLocaleString()} Rows`; + } + msg += " " + text + ""; + } + + msg += " " + "Select None"; + var showOpts = []; + if (region.showRows !== 'all' && !_isMaxRowsAllRows(region)) + showOpts.push("Show All"); + if (region.showRows !== 'selected') + showOpts.push("Show Selected"); + if (region.showRows !== 'unselected') + showOpts.push("Show Unselected"); + msg += "  " + showOpts.join(" "); + } + + // add the record selector message, the link handlers will get added after render in _onRenderMessageArea + region.addMessage.call(region, msg, 'selection'); + }; + + var _toggleAllRows = function(region, checked) { + var ids = []; + + _getRowSelectors(region).each(function() { + if (!this.disabled) { + this.checked = checked; + ids.push(this.value); + } + }); + + _getAllRowSelectors(region).each(function() { this.checked = checked === true; }); + return ids; + }; + + /** + * Asynchronous loader for a DataRegion + * @param region {DataRegion} + * @param [newParams] {string} + * @param [skipPrefixes] {string[]} + * @param [callback] {Function} + * @param [scope] + * @private + */ + var _load = function(region, newParams, skipPrefixes, callback, scope) { + + var params = _getAsyncParams(region, newParams ? newParams : _getParameters(region), skipPrefixes); + var jsonData = _getAsyncBody(region, params); + + // TODO: This should be done in _getAsyncParams, but is not since _getAsyncBody relies on it. Refactor it. + // ensure SQL is not on the URL -- we allow any property to be pulled through when creating parameters. + if (params.sql) { + delete params.sql; + } + + /** + * The target jQuery element that will be either written to or replaced + */ + var target; + + /** + * Flag used to determine if we should replace target element (default) or write to the target contents + * (used during QWP render for example) + * @type {boolean} + */ + var useReplace = true; + + /** + * The string identifier for where the region will render. Mainly used to display useful messaging upon failure. + * @type {string} + */ + var renderEl; + + if (region.renderTo) { + useReplace = false; + renderEl = region.renderTo; + target = $('#' + region.renderTo); + } + else if (!region.domId) { + throw '"renderTo" must be specified either upon construction or when calling render()'; + } + else { + renderEl = region.domId; + target = $('#' + region.domId); + + // attempt to find the correct node to render to... + var form = _getFormSelector(region); + if (form.length && form.parent('div').length) { + target = form.parent('div'); + } + else { + // next best render target + throw 'unable to find a good target element. Perhaps this region is not using the standard renderer?' + } + } + var timerId = setTimeout(function() { + timerId = 0; + if (target) { + target.html("
" + + "
loading...
" + + "
"); + } + }, 500); + + LABKEY.Ajax.request({ + timeout: region.timeout === undefined ? DEFAULT_TIMEOUT : region.timeout, + url: LABKEY.ActionURL.buildURL('project', 'getWebPart.api', region.containerPath), + method: 'POST', + params: params, + jsonData: jsonData, + success: function(response) { + if (timerId > 0) { + clearTimeout(timerId);//load mask task no longer needed + } + this.hidePanel(function() { + if (target.length) { + + this.destroy(); + + LABKEY.Utils.loadAjaxContent(response, target, function() { + + if ($.isFunction(callback)) { + callback.call(scope); + } + + if ($.isFunction(this._success)) { + this._success.call(this.scope || this, this, response); + } + + $(this).trigger('success', [this, response]); + + this.RENDER_LOCK = true; + $(this).trigger('render', this); + this.RENDER_LOCK = false; + }, this, useReplace); + } + else { + // not finding element considered a failure + if ($.isFunction(this._failure)) { + this._failure.call(this.scope || this, response /* json */, response, undefined /* options */, target); + } + else if (!this.suppressRenderErrors) { + LABKEY.Utils.alert('Rendering Error', 'The element "' + renderEl + '" does not exist in the document. You may need to specify "renderTo".'); + } + } + }, this); + }, + failure: LABKEY.Utils.getCallbackWrapper(function(json, response, options) { + + if (target.length) { + if ($.isFunction(this._failure)) { + this._failure.call(this.scope || this, json, response, options); + } + else if (this.errorType === 'html') { + if (useReplace) { + target.replaceWith('
' + LABKEY.Utils.encodeHtml(json.exception) + '
'); + } + else { + target.html('
' + LABKEY.Utils.encodeHtml(json.exception) + '
'); + } + } + } + else if (!this.suppressRenderErrors) { + LABKEY.Utils.alert('Rendering Error', 'The element "' + renderEl + '" does not exist in the document. You may need to specify "renderTo".'); + } + }, region, true), + scope: region + }); + + if (region.async && !region.complete && region.showPaginationCountAsync && !region.skipTotalRowCount) { + _loadAsyncTotalRowCount(region, params, jsonData); + } + region.skipTotalRowCount = false; + }; + + var totalRowCountRequests = {}; // track the request per region name so that we cancel the correct request when necessary + var _loadAsyncTotalRowCount = function(region, params, jsonData) { + // if there is a previous request pending, abort it before starting a new one + var totalRowCountRequest = totalRowCountRequests[region.name]; + if (totalRowCountRequest !== undefined) { + totalRowCountRequest.abort(); + } + + region.totalRows = undefined; + region.loadingTotalRows = true; + + totalRowCountRequests[region.name] = LABKEY.Query.selectRows({ + ...region.getQueryConfig(), + method: 'POST', + containerPath: region.containerPath, + filterArray: LABKEY.Filter.getFiltersFromParameters({ ...params, ...jsonData.filters }, params.dataRegionName), + sort: undefined, + maxRows: 1, + offset: 0, + includeMetadata: false, + includeDetailsColumn: false, + includeUpdateColumn: false, + includeTotalCount: true, + success: function(json) { + totalRowCountRequests[region.name] = undefined; + region.loadingTotalRows = false; + + if (json !== undefined && json.rowCount !== undefined) { + region.totalRows = json.rowCount; + + // update the pagination button disabled state for 'Show Last' and 'Show All' since they include the totalRows count in their calc + var showLast = _showLastEnabled(region); + if (showLast) { + _getShowLastSelector(region).parent('li').removeClass('disabled'); + _getShowAllSelector(region).parent('li').removeClass('disabled'); + } + } + // note: use _getFormSelector instead of _getBarSelector so that we get the floating header as well + _getFormSelector(region).find('a.labkey-paginationText').html(_getPaginationText(region)); + }, + failure: function(error, request) { + var aborted = request.status === 0; + if (!aborted) { + console.error(error); + totalRowCountRequests[region.name] = undefined; + region.loadingTotalRows = false; + _getFormSelector(region).find('a.labkey-paginationText').html(_getPaginationText(region)); + } + } + }); + }; + + var _getAsyncBody = function(region, params) { + var json = {}; + + if (params.sql) { + json.sql = LABKEY.Utils.wafEncode(params.sql); + } + + _processButtonBar(region, json); + + // Issue 10505: add non-removable sorts and filters to json (not url params). + if (region.sort || region.filters || region.aggregates) { + json.filters = {}; + + if (region.filters) { + LABKEY.Filter.appendFilterParams(json.filters, region.filters, region.name); + } + + if (region.sort) { + json.filters[region.dataRegionName + SORT_PREFIX] = region.sort; + } + + if (region.aggregates) { + LABKEY.Filter.appendAggregateParams(json.filters, region.aggregates, region.name); + } + } + + if (region.metadata) { + json.metadata = { + type: region.metadata.type, + value: LABKEY.Utils.wafEncode(region.metadata.value) + }; + } + + return json; + }; + + var _processButtonBar = function(region, json) { + + var bar = region.buttonBar; + + if (bar && (bar.position || (bar.items && bar.items.length > 0))) { + _processButtonBarItems(region, bar.items); + + // only attach if valid + json.buttonBar = bar; + } + }; + + var _processButtonBarItems = function(region, items) { + if (LABKEY.Utils.isArray(items) && items.length > 0) { + for (var i = 0; i < items.length; i++) { + var item = items[i]; + + if (item && $.isFunction(item.handler)) { + item.id = item.id || LABKEY.Utils.id(); + // TODO: A better way? This exposed _onButtonClick isn't very awesome + item.onClick = "return LABKEY.DataRegions['" + region.name + "']._onButtonClick('" + item.id + "');"; + } + + if (item.items) { + _processButtonBarItems(region, item.items); + } + } + } + }; + + var _isFilter = function(region, parameter) { + return parameter && parameter.indexOf(region.name + '.') === 0 && parameter.indexOf('~') > 0; + }; + + var _getAsyncParams = function(region, newParams, skipPrefixes) { + + var params = {}; + var name = region.name; + + // + // Certain parameters are only included if the region is 'async'. These + // were formerly a part of Query Web Part. + // + if (region.async) { + params[name + '.async'] = true; + + if (LABKEY.Utils.isString(region.frame)) { + params['webpart.frame'] = region.frame; + } + + if (LABKEY.Utils.isString(region.bodyClass)) { + params['webpart.bodyClass'] = region.bodyClass; + } + + if (LABKEY.Utils.isString(region.title)) { + params['webpart.title'] = region.title; + } + + if (LABKEY.Utils.isString(region.titleHref)) { + params['webpart.titleHref'] = region.titleHref; + } + + if (LABKEY.Utils.isString(region.columns)) { + params[region.name + '.columns'] = region.columns; + } + + _applyOptionalParameters(region, params, [ + 'allowChooseQuery', + 'allowChooseView', + 'allowHeaderLock', + 'buttonBarPosition', + 'detailsURL', + 'deleteURL', + 'importURL', + 'insertURL', + 'linkTarget', + 'updateURL', + 'shadeAlternatingRows', + 'showBorders', + 'showDeleteButton', + 'showDetailsColumn', + 'showExportButtons', + 'showRStudioButton', + 'showImportDataButton', + 'showInsertNewButton', + 'showPagination', + 'showPaginationCount', + 'showReports', + 'showSurroundingBorder', + 'showFilterDescription', + 'showUpdateColumn', + 'showViewPanel', + 'timeout', + {name: 'disableAnalytics', prefix: true}, + {name: 'maxRows', prefix: true, check: function(v) { return v > 0; }}, + {name: 'showRows', prefix: true}, + {name: 'offset', prefix: true, check: function(v) { return v !== 0; }}, + {name: 'reportId', prefix: true}, + {name: 'viewName', prefix: true} + ]); + + // Sorts configured by the user when interacting with the grid. We need to pass these as URL parameters. + if (LABKEY.Utils.isString(region._userSort) && region._userSort.length > 0) { + params[name + SORT_PREFIX] = region._userSort; + } + + if (region.userFilters) { + $.each(region.userFilters, function(filterExp, filterValue) { + if (params[filterExp] == undefined) { + params[filterExp] = []; + } + params[filterExp].push(filterValue); + }); + region.userFilters = {}; // they've been applied + } + + // TODO: Get rid of this and incorporate it with the normal containerFilter checks + if (region.userContainerFilter) { + params[name + CONTAINER_FILTER_NAME] = region.userContainerFilter; + } + + if (region.parameters) { + var paramPrefix = name + PARAM_PREFIX; + $.each(region.parameters, function(parameter, value) { + var key = parameter; + if (parameter.indexOf(paramPrefix) !== 0) { + key = paramPrefix + parameter; + } + params[key] = value; + }); + } + } + + // + // apply all parameters + // + + var newParamPrefixes = {}; + + if (newParams) { + newParams.forEach(function(pair) { + // Issue 25337: Filters may repeat themselves + if (_isFilter(region, pair[0])) { + if (params[pair[0]] == undefined) { + params[pair[0]] = []; + } + else if (!LABKEY.Utils.isArray(params[pair[0]])) { + params[pair[0]] = [params[pair[0]]]; + } + + var value = pair[1]; + + // Issue 47735: QWP date filter not being formatted + // This needs to be formatted for the response passed back to the grid for the filter display and + // filter dialog to render correctly + value = _ensureFilterDateFormat(value); + + params[pair[0]].push(value); + } + else { + params[pair[0]] = pair[1]; + } + + newParamPrefixes[pair[0]] = true; + }); + } + + // Issue 40226: Don't include parameters that are being logically excluded + if (skipPrefixes) { + skipPrefixes.forEach(function(skipKey) { + if (params.hasOwnProperty(skipKey) && !newParamPrefixes.hasOwnProperty(skipKey)) { + delete params[skipKey]; + } + }); + } + + // + // Properties that cannot be modified + // + + params.dataRegionName = region.name; + params.schemaName = region.schemaName; + params.viewName = region.viewName; + params.reportId = region.reportId; + params.returnUrl = window.location.href; + params['webpart.name'] = 'Query'; + + if (region.queryName) { + params.queryName = region.queryName; + } + else if (region.sql) { + params.sql = region.sql; + } + + var key = region.name + CONTAINER_FILTER_NAME; + var cf = region.getContainerFilter.call(region); + if (cf && !(key in params)) { + params[key] = cf; + } + + return params; + }; + + var _updateFilter = function(region, filter, skipPrefixes) { + var params = []; + if (filter) { + params.push([filter.getURLParameterName(region.name), filter.getURLParameterValue()]); + } + _setParameters(region, params, [OFFSET_PREFIX].concat(skipPrefixes)); + }; + + var _updateRequiresSelectionButtons = function(region, selectedCount) { + + // update the 'select all on page' checkbox state + _getAllRowSelectors(region).each(function() { + if (region.isPageSelected.call(region)) { + this.checked = true; + this.indeterminate = false; + } + else if (region.selectedCount > 0) { + // There are rows selected, but the are not visible on this page. + this.checked = false; + this.indeterminate = true; + } + else { + this.checked = false; + this.indeterminate = false; + } + }); + + // If not all rows are visible and some rows are selected, show selection message + if (region.totalRows && 0 !== region.selectedCount && !region.complete) { + var msg = (region.selectedCount === region.totalRows) ? + 'All ' + region.totalRows.toLocaleString() + ' rows selected.' : + 'Selected ' + region.selectedCount.toLocaleString() + ' of ' + region.totalRows.toLocaleString() + ' rows.'; + _showSelectMessage(region, msg); + } + + // Issue 10566: for javascript perf on IE stash the requires selection buttons + if (!region._requiresSelectionButtons) { + // escape ', ", and \ + var escaped = region.name.replace(/('|"|\\)/g, "\\$1"); + region._requiresSelectionButtons = $("a[data-labkey-requires-selection='" + escaped + "']"); + } + + region._requiresSelectionButtons.each(function() { + var el = $(this); + + var isDropdown = false; + var dropdownBtn = el.parent(); + if (dropdownBtn && dropdownBtn.hasClass('lk-menu-drop') && dropdownBtn.hasClass('dropdown')) + isDropdown = true; + + // handle min-count + var minCount = el.attr('data-labkey-requires-selection-min-count'); + if (minCount) { + minCount = parseInt(minCount); + } + if (minCount === undefined) { + minCount = 1; + } + + // handle max-count + var maxCount = el.attr('data-labkey-requires-selection-max-count'); + if (maxCount) { + maxCount = parseInt(maxCount); + } + + if (minCount <= selectedCount && (!maxCount || maxCount >= selectedCount)) { + el.removeClass('labkey-disabled-button'); + if (isDropdown) + dropdownBtn.removeClass('labkey-disabled-button'); + } + else { + el.addClass('labkey-disabled-button'); + if (isDropdown) + dropdownBtn.addClass('labkey-disabled-button'); + } + }); + }; + + var HeaderLock = function(region) { + + // init + if (!region.headerLock()) { + region._allowHeaderLock = false; + return; + } + + this.region = region; + + var table = $('#' + region.domId); + var firstRow = table.find('tr.labkey-alternate-row').first().children('td'); + + // If no data rows exist just turn off header locking + if (firstRow.length === 0) { + firstRow = table.find('tr.labkey-row').first().children('td'); + if (firstRow.length === 0) { + region._allowHeaderLock = false; + return; + } + } + + var headerRowId = region.domId + '-column-header-row'; + var headerRow = $('#' + headerRowId); + + if (headerRow.length === 0) { + region._allowHeaderLock = false; + return; + } + + var BOTTOM_OFFSET = 100; + + var me = this, + timeout, + locked = false, + lastLeft = 0, + pos = [ 0, 0, 0, 0 ], + domObserver = null; + + // init + var floatRow = headerRow + .clone() + // TODO: Possibly namespace all the ids underneath + .attr('id', headerRowId + '-float') + .css({ + 'box-shadow': '0 4px 4px #DCDCDC', + display: 'none', + position: 'fixed', + top: 0, + 'z-index': 2 + }); + + floatRow.insertAfter(headerRow); + + // respect showPagination but do not use it directly as it may change + var isPagingFloat = region.showPagination; + var floatPaging, floatPagingWidth = 0; + + if (isPagingFloat) { + var pageWidget = _getBarSelector(region).find('.labkey-pagination'); + if (pageWidget.children().length) { + floatPaging = $('
') + .css({ + 'background-color': 'white', + 'box-shadow': '0 4px 4px #DCDCDC', + display: 'none', + 'min-width': pageWidget.width(), + opacity: 0.7, + position: 'fixed', + top: floatRow.height(), + 'z-index': 1 + }) + .on('mouseover', function() { + $(this).css('opacity', '1.0'); + }) + .on('mouseout', function() { + $(this).css('opacity', '0.7') + }); + + var floatingPageWidget = pageWidget.clone(true).css('padding', '4px 8px'); + + // adjust padding when buttons aren't shown + if (!pageWidget.find('.btn-group').length) { + floatingPageWidget.css('padding-bottom', '8px') + } + + floatPaging.append(floatingPageWidget); + table.parent().append(floatPaging); + floatPagingWidth = floatPaging.width(); + } else { + isPagingFloat = false; + } + } + + var disable = function() { + me.region._allowHeaderLock = false; + + if (timeout) { + clearTimeout(timeout); + } + + $(window) + .unbind('load', domTask) + .unbind('resize', resizeTask) + .unbind('scroll', onScroll); + + if (domObserver) { + domObserver.disconnect(); + domObserver = null; + } + }; + + /** + * Configures the 'pos' array containing the following values: + * [0] - X-coordinate of the top of the object relative to the offset parent. + * [1] - Y-coordinate of the top of the object relative to the offset parent. + * [2] - Y-coordinate of the bottom of the object. + * [3] - width of the object + * This method assumes interaction with the Header of the Data Region. + */ + var loadPosition = function() { + var header = headerRow.offset() || {top: 0}; + var table = $('#' + region.domId); + + var bottom = header.top + table.height() - BOTTOM_OFFSET; + var width = headerRow.width(); + pos = [ header.left, header.top, bottom, width ]; + }; + + loadPosition(); + + var onResize = function() { + loadPosition(); + var sub_h = headerRow.find('th'); + + floatRow.width(headerRow.width()).find('th').each(function(i, el) { + $(el).width($(sub_h[i]).width()); + }); + + isPagingFloat && floatPaging.css({ + left: pos[0] - window.pageXOffset + floatRow.width() - floatPaging.width(), + top: floatRow.height() + }); + }; + + /** + * WARNING: This function is called often. Performance implications for each line. + */ + var onScroll = function() { + if (window.pageYOffset >= pos[1] && window.pageYOffset < pos[2]) { + var newLeft = pos[0] - window.pageXOffset; + var newPagingLeft = isPagingFloat ? newLeft + pos[3] - floatPagingWidth : 0; + + var floatRowCSS = { + top: 0 + }; + var pagingCSS = isPagingFloat && { + top: floatRow.height() + }; + + if (!locked) { + locked = true; + floatRowCSS.display = 'table-row'; + floatRowCSS.left = newLeft; + + pagingCSS.display = 'block'; + pagingCSS.left = newPagingLeft; + } + else if (lastLeft !== newLeft) { + floatRowCSS.left = newLeft; + + pagingCSS.left = newPagingLeft; + } + + floatRow.css(floatRowCSS); + isPagingFloat && floatPaging.css(pagingCSS); + + lastLeft = newLeft; + } + else if (locked && window.pageYOffset >= pos[2]) { + var newTop = pos[2] - window.pageYOffset; + + floatRow.css({ + top: newTop + }); + + isPagingFloat && floatPaging.css({ + top: newTop + floatRow.height() + }); + } + else if (locked) { + locked = false; + floatRow.hide(); + isPagingFloat && floatPaging.hide(); + } + }; + + var resizeTask = function(immediate) { + clearTimeout(timeout); + if (immediate) { + onResize(); + } + else { + timeout = setTimeout(onResize, 110); + } + }; + + var isDOMInit = false; + + var domTask = function() { + if (!isDOMInit) { + isDOMInit = true; + // fire immediate to prevent flicker of components when reloading region + resizeTask(true); + } + else { + resizeTask(); + } + onScroll(); + }; + + $(window) + .one('load', domTask) + .on('resize', resizeTask) + .on('scroll', onScroll); + + domObserver = new MutationObserver(mutationList => + mutationList.filter(m => m.type === 'childList').forEach(m => { + m.addedNodes.forEach(domTask); + })); + domObserver.observe(document,{childList: true, subtree: true}); // Issue 13121, 50939 + + // ensure that resize/scroll fire at the end of initialization + domTask(); + + return { + disable: disable + } + }; + + // + // LOADER + // + LABKEY.DataRegion.create = function(config) { + + var region = LABKEY.DataRegions[config.name]; + + if (region) { + // region already exists, update properties + $.each(config, function(key, value) { + region[key] = value; + }); + if (!config.view) { + // when switching back to 'default' view, needs to clear region.view + region.view = undefined; + } + _init.call(region, config); + } + else { + // instantiate a new region + region = new LABKEY.DataRegion(config); + LABKEY.DataRegions[region.name] = region; + } + + return region; + }; + + LABKEY.DataRegion.loadViewDesigner = function(cb, scope) { + LABKEY.requiresExt4Sandbox(function() { + LABKEY.requiresScript('internal/ViewDesigner', cb, scope); + }); + }; + + LABKEY.DataRegion.getCustomViewEditableErrors = function(customView) { + var errors = []; + if (customView && !customView.editable) { + errors.push("The view is read-only and cannot be edited."); + } + return errors; + }; + + LABKEY.DataRegion.registerPane = function(regionName, callback, scope) { + var region = LABKEY.DataRegions[regionName]; + if (region) { + callback.call(scope || region, region); + return; + } + else if (!_paneCache[regionName]) { + _paneCache[regionName] = []; + } + + _paneCache[regionName].push({cb: callback, scope: scope}); + }; + + LABKEY.DataRegion.selectAll = function(config) { + var params = {}; + if (!config.url) { + // DataRegion doesn't have selectAllURL so generate url and query parameters manually + config.url = LABKEY.ActionURL.buildURL('query', 'selectAll.api', config.containerPath); + + config.dataRegionName = config.dataRegionName || 'query'; + + params = LABKEY.Query.buildQueryParams( + config.schemaName, + config.queryName, + config.filters, + null, + config.dataRegionName + ); + + if (config.viewName) + params[config.dataRegionName + VIEWNAME_PREFIX] = config.viewName; + + if (config.containerFilter) + params.containerFilter = config.containerFilter; + + if (config.selectionKey) + params[config.dataRegionName + '.selectionKey'] = config.selectionKey; + + $.each(config.parameters, function(propName, value) { + params[config.dataRegionName + PARAM_PREFIX + propName] = value; + }); + + if (config.ignoreFilter) { + params[config.dataRegionName + '.ignoreFilter'] = true; + } + + // NOTE: ignore maxRows, showRows, and offset + } + + LABKEY.Ajax.request({ + url: config.url, + method: 'POST', + params: params, + success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), + failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) + }); + }; + + /** + * Static method to add or remove items from the selection for a given {@link #selectionKey}. + * + * @param config A configuration object with the following properties: + * @param {String} config.selectionKey See {@link #selectionKey}. + * @param {Array} config.ids Array of primary key ids for each row to select/unselect. + * @param {Boolean} config.checked If true, the ids will be selected, otherwise unselected. + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'count' to indicate the updated selection count. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion#getSelected + * @see LABKEY.DataRegion#clearSelected + */ + LABKEY.DataRegion.setSelected = function(config) { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'setSelected.api', config.containerPath), + method: 'POST', + jsonData: { + checked: config.checked, + id: config.ids || config.id, + key: config.selectionKey, + }, + scope: config.scope, + success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), + failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) + }); + }; + + /** + * Static method to clear all selected items for a given {@link #selectionKey}. + * + * @param config A configuration object with the following properties: + * @param {String} config.selectionKey See {@link #selectionKey}. + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'count' of 0 to indicate an empty selection. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * + * @see LABKEY.DataRegion#setSelected + * @see LABKEY.DataRegion#getSelected + */ + LABKEY.DataRegion.clearSelected = function(config) { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'clearSelected.api', config.containerPath), + method: 'POST', + jsonData: { key: config.selectionKey }, + success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), + failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) + }); + }; + + /** + * Static method to get all selected items for a given {@link #selectionKey}. + * + * @param config A configuration object with the following properties: + * @param {String} config.selectionKey See {@link #selectionKey}. + * @param {Function} config.success The function to be called upon success of the request. + * The callback will be passed the following parameters: + *
    + *
  • data: an object with the property 'selected' that is an array of the primary keys for the selected rows. + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Function} [config.failure] The function to call upon error of the request. + * The callback will be passed the following parameters: + *
    + *
  • errorInfo: an object containing detailed error information (may be null)
  • + *
  • response: The XMLHttpResponse object
  • + *
+ * @param {Object} [config.scope] An optional scoping object for the success and error callback functions (default to this). + * @param {string} [config.containerPath] An alternate container path. If not specified, the current container path will be used. + * @param {boolean} [config.clearSelected] If true, clear the session-based selection for this Data Region after + * retrieving the current selection. Defaults to false. + * + * @see LABKEY.DataRegion#setSelected + * @see LABKEY.DataRegion#clearSelected + */ + LABKEY.DataRegion.getSelected = function(config) { + var jsonData = { key: config.selectionKey }; + + // Issue 41705: Support clearing selection from getSelected() + if (config.clearSelected) { + jsonData.clearSelected = true; + } + + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'getSelected.api', config.containerPath), + method: 'POST', + jsonData: jsonData, + success: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnSuccess(config), config.scope), + failure: LABKEY.Utils.getCallbackWrapper(LABKEY.Utils.getOnFailure(config), config.scope, true) + }); + }; + + /** + * MessageArea wraps the display of messages in a DataRegion. + * @param dataRegion - The dataregion that the MessageArea will bind itself to. + * @param {String[]} [messages] - An initial messages object containing mappings of 'part' to 'msg' + * @constructor + */ + var MessageArea = function(dataRegion, messages) { + this.bindRegion(dataRegion); + + if (messages) { + this.setMessages(messages); + } + }; + + var MsgProto = MessageArea.prototype; + + MsgProto.bindRegion = function(region) { + this.parentSel = '#' + region.domId + '-msgbox'; + }; + + MsgProto.toJSON = function() { + return this.parts; + }; + + MsgProto.addMessage = function(msg, part, append) { + part = part || 'info'; + + var p = part.toLowerCase(); + if (append && this.parts.hasOwnProperty(p)) + { + this.parts[p] += msg; + this.render(p, msg); + } + else { + this.parts[p] = msg; + this.render(p); + } + }; + + MsgProto.getMessage = function(part) { + return this.parts[part.toLowerCase()]; + }; + + MsgProto.hasMessage = function(part) { + return this.getMessage(part) !== undefined; + }; + + MsgProto.hasContent = function() { + return this.parts && Object.keys(this.parts).length > 0; + }; + + MsgProto.removeAll = function() { + this.parts = {}; + this.render(); + }; + + MsgProto.removeMessage = function(part) { + var p = part.toLowerCase(); + if (this.parts.hasOwnProperty(p)) { + this.parts[p] = undefined; + this.render(); + } + }; + + MsgProto.setMessages = function(messages) { + if (LABKEY.Utils.isObject(messages)) { + this.parts = messages; + } + else { + this.parts = {}; + } + }; + + MsgProto.getParent = function() { + return $(this.parentSel); + }; + + MsgProto.render = function(partToUpdate, appendMsg) { + var hasMsg = false, + me = this, + parent = this.getParent(); + + $.each(this.parts, function(part, msg) { + + if (msg) { + // If this is modified, update the server-side renderer in DataRegion.java renderMessages() + var partEl = parent.find('div[data-msgpart="' + part + '"]'); + if (partEl.length === 0) { + parent.append([ + '
', + msg, + '
' + ].join('')); + } + else if (partToUpdate !== undefined && partToUpdate === part) { + if (appendMsg !== undefined) + partEl.append(appendMsg); + else + partEl.html(msg) + } + + hasMsg = true; + } + else { + parent.find('div[data-msgpart="' + part + '"]').remove(); + delete me.parts[part]; + } + }); + + if (hasMsg) { + this.show(); + $(this).trigger('rendermsg', [this, this.parts]); + } + else { + this.hide(); + parent.html(''); + } + }; + + MsgProto.show = function() { this.getParent().show(); }; + MsgProto.hide = function() { this.getParent().hide(); }; + MsgProto.isVisible = function() { return $(this.parentSel + ':visible').length > 0; }; + MsgProto.find = function(selector) { + return this.getParent().find('.dataregion_msgbox_ct').find(selector); + }; + MsgProto.on = function(evt, callback, scope) { $(this).bind(evt, $.proxy(callback, scope)); }; + + /** + * @description Constructs a LABKEY.QueryWebPart class instance + * @class The LABKEY.QueryWebPart simplifies the task of dynamically adding a query web part to your page. Please use + * this class for adding query web parts to a page instead of {@link LABKEY.WebPart}, + * which can be used for other types of web parts. + *

Additional Documentation: + *

+ *

+ * @constructor + * @param {Object} config A configuration object with the following possible properties: + * @param {String} config.schemaName The name of the schema the web part will query. + * @param {String} config.queryName The name of the query within the schema the web part will select and display. + * @param {String} [config.viewName] the name of a saved view you wish to display for the given schema and query name. + * @param {String} [config.reportId] the report id of a saved report you wish to display for the given schema and query name. + * @param {Mixed} [config.renderTo] The element id, DOM element, or Ext element inside of which the part should be rendered. This is typically a <div>. + * If not supplied in the configuration, you must call the render() method to render the part into the page. + * @param {String} [config.errorType] A parameter to specify how query parse errors are returned. (default 'html'). Valid + * values are either 'html' or 'json'. If 'html' is specified the error will be rendered to an HTML view, if 'json' is specified + * the errors will be returned to the callback handlers as an array of objects named 'parseErrors' with the following properties: + *
    + *
  • msg: The error message.
  • + *
  • line: The line number the error occurred at (optional).
  • + *
  • col: The column number the error occurred at (optional).
  • + *
  • errorStr: The line from the source query that caused the error (optional).
  • + *
+ * @param {String} [config.sql] A SQL query that can be used instead of an existing schema name/query name combination. + * @param {Object} [config.metadata] Metadata that can be applied to the properties of the table fields. Currently, this option is only + * available if the query has been specified through the config.sql option. For full documentation on + * available properties, see LabKey XML Schema Reference. + * This object may contain the following properties: + *
    + *
  • type: The type of metadata being specified. Currently, only 'xml' is supported.
  • + *
  • value: The metadata XML value as a string. For example: '<tables xmlns="http://labkey.org/data/xml"><table tableName="Announcement" tableDbType="NOT_IN_DB"><columns><column columnName="Title"><columnTitle>Custom Title</columnTitle></column></columns></table></tables>'
  • + *
+ * @param {String} [config.title] A title for the web part. If not supplied, the query name will be used as the title. + * @param {String} [config.titleHref] If supplied, the title will be rendered as a hyperlink with this value as the href attribute. + * @param {String} [config.buttonBarPosition] DEPRECATED--see config.buttonBar.position + * @param {boolean} [config.allowChooseQuery] If the button bar is showing, whether or not it should be include a button + * to let the user choose a different query. + * @param {boolean} [config.allowChooseView] If the button bar is showing, whether or not it should be include a button + * to let the user choose a different view. + * @param {String} [config.detailsURL] Specify or override the default details URL for the table with one of the form + * "/controller/action.view?id=${RowId}" or "org.labkey.package.MyController$ActionAction.class?id=${RowId}" + * @param {boolean} [config.showDetailsColumn] If the underlying table has a details URL, show a column that renders a [details] link (default true). If true, the record selectors will be included regardless of the 'showRecordSelectors' config option. + * @param {String} [config.updateURL] Specify or override the default updateURL for the table with one of the form + * "/controller/action.view?id=${RowId}" or "org.labkey.package.MyController$ActionAction.class?id=${RowId}" + * @param {boolean} [config.showUpdateColumn] If the underlying table has an update URL, show a column that renders an [edit] link (default true). + * @param {String} [config.insertURL] Specify or override the default insert URL for the table with one of the form + * "/controller/insertAction.view" or "org.labkey.package.MyController$InsertActionAction.class" + * @param {String} [config.importURL] Specify or override the default bulk import URL for the table with one of the form + * "/controller/importAction.view" or "org.labkey.package.MyController$ImportActionAction.class" + * @param {String} [config.deleteURL] Specify or override the default delete URL for the table with one of the form + * "/controller/action.view" or "org.labkey.package.MyController$ActionAction.class". The keys for the selected rows + * will be included in the POST. + * @param {boolean} [config.showImportDataButton] If the underlying table has an import URL, show an "Import Bulk Data" button in the button bar (default true). + * @param {boolean} [config.showInsertNewButton] If the underlying table has an insert URL, show an "Insert New" button in the button bar (default true). + * @param {boolean} [config.showDeleteButton] Show a "Delete" button in the button bar (default true). + * @param {boolean} [config.showReports] If true, show reports on the Views menu (default true). + * @param {boolean} [config.showExportButtons] Show the export button menu in the button bar (default true). + * @param {boolean} [config.showRStudioButton] Show the export to RStudio button menu in the button bar. Requires export button to work. (default false). + * @param {boolean} [config.showBorders] Render the table with borders (default true). + * @param {boolean} [config.showSurroundingBorder] Render the table with a surrounding border (default true). + * @param {boolean} [config.showFilterDescription] Include filter and parameter values in the grid header, if present (default true). + * @param {boolean} [config.showRecordSelectors] Render the select checkbox column (default undefined, meaning they will be shown if the query is updatable by the current user). + * Both 'showDeleteButton' and 'showExportButtons' must be set to false for the 'showRecordSelectors = false' setting to hide the checkboxes. + * @param {boolean} [config.showPagination] Show the pagination links and count (default true). + * @param {boolean} [config.showPaginationCount] Show the total count of rows in the pagination information text (default true). + * @param {boolean} [config.showPaginationCountAsync] Show the total count of rows in the pagination information text, but query for it asynchronously so that the grid data can load initially without it (default false). + * @param {boolean} [config.shadeAlternatingRows] Shade every other row with a light gray background color (default true). + * @param {boolean} [config.suppressRenderErrors] If true, no alert will appear if there is a problem rendering the QueryWebpart. This is most often encountered if page configuration changes between the time when a request was made and the content loads. Defaults to false. + * @param {Object} [config.buttonBar] Button bar configuration. This object may contain any of the following properties: + *
    + *
  • position: Configures where the button bar will appear with respect to the data grid: legal values are 'top', or 'none'. Default is 'top'.
  • + *
  • includeStandardButtons: If true, all standard buttons not specifically mentioned in the items array will be included at the end of the button bar. Default is false.
  • + *
  • items: An array of button bar items. Each item may be either a reference to a standard button, or a new button configuration. + * to reference standard buttons, use one of the properties on {@link #standardButtons}, or simply include a string + * that matches the button's caption. To include a new button configuration, create an object with the following properties: + *
      + *
    • text: The text you want displayed on the button (aka the caption).
    • + *
    • url: The URL to navigate to when the button is clicked. You may use LABKEY.ActionURL to build URLs to controller actions. + * Specify this or a handler function, but not both.
    • + *
    • handler: A reference to the JavaScript function you want called when the button is clicked.
    • + *
    • permission: Optional. Permission that the current user must possess to see the button. + * Valid options are 'READ', 'INSERT', 'UPDATE', 'DELETE', and 'ADMIN'. + * Default is 'READ' if permissionClass is not specified.
    • + *
    • permissionClass: Optional. If permission (see above) is not specified, the fully qualified Java class + * name of the permission that the user must possess to view the button.
    • + *
    • requiresSelection: A boolean value (true/false) indicating whether the button should only be enabled when + * data rows are checked/selected.
    • + *
    • items: To create a drop-down menu button, set this to an array of menu item configurations. + * Each menu item configuration can specify any of the following properties: + *
        + *
      • text: The text of the menu item.
      • + *
      • handler: A reference to the JavaScript function you want called when the menu item is clicked.
      • + *
      • icon: A url to an image to use as the menu item's icon.
      • + *
      • items: An array of sub-menu item configurations. Used for fly-out menus.
      • + *
      + *
    • + *
    + *
  • + *
+ * @param {String} [config.columns] Comma-separated list of column names to be shown in the grid, overriding + * whatever might be set in a custom view. + * @param {String} [config.sort] A base sort order to use. This is a comma-separated list of column names, each of + * which may have a - prefix to indicate a descending sort. It will be treated as the final sort, after any that the user + * has defined in a custom view or through interacting with the grid column headers. + * @param {String} [config.removeableSort] An additional sort order to use. This is a comma-separated list of column names, each of + * which may have a - prefix to indicate a descending sort. It will be treated as the first sort, before any that the user + * has defined in a custom view or through interacting with the grid column headers. + * @param {Array} [config.filters] A base set of filters to apply. This should be an array of {@link LABKEY.Filter} objects + * each of which is created using the {@link LABKEY.Filter.create} method. These filters cannot be removed by the user + * interacting with the UI. + * For compatibility with the {@link LABKEY.Query} object, you may also specify base filters using config.filterArray. + * @param {Array} [config.removeableFilters] A set of filters to apply. This should be an array of {@link LABKEY.Filter} objects + * each of which is created using the {@link LABKEY.Filter.create} method. These filters can be modified or removed by the user + * interacting with the UI. + * @param {Object} [config.parameters] Map of name (string)/value pairs for the values of parameters if the SQL + * references underlying queries that are parameterized. For example, the following passes two parameters to the query: {'Gender': 'M', 'CD4': '400'}. + * The parameters are written to the request URL as follows: query.param.Gender=M&query.param.CD4=400. For details on parameterized SQL queries, see + * Parameterized SQL Queries. + * @param {Array} [config.aggregates] An array of aggregate definitions. The objects in this array should have the properties: + *
    + *
  • column: The name of the column to be aggregated.
  • + *
  • type: The aggregate type (see {@link LABKEY.AggregateTypes})
  • + *
  • label: Optional label used when rendering the aggregate row. + *
+ * @param {String} [config.showRows] Either 'paginated' (the default) 'selected', 'unselected', 'all', or 'none'. + * When 'paginated', the maxRows and offset parameters can be used to page through the query's result set rows. + * When 'selected' or 'unselected' the set of rows selected or unselected by the user in the grid view will be returned. + * You can programmatically get and set the selection using the {@link LABKEY.DataRegion.setSelected} APIs. + * Setting config.maxRows to -1 is the same as 'all' + * and setting config.maxRows to 0 is the same as 'none'. + * @param {Integer} [config.maxRows] The maximum number of rows to return from the server (defaults to 100). + * If you want to return all possible rows, set this config property to -1. + * @param {Integer} [config.offset] The index of the first row to return from the server (defaults to 0). + * Use this along with the maxRows config property to request pages of data. + * @param {String} [config.dataRegionName] The name to be used for the data region. This should be unique within + * the set of query views on the page. If not supplied, a unique name is generated for you. + * @param {String} [config.linkTarget] The name of a browser window/tab in which to open URLs rendered in the + * QueryWebPart. If not supplied, links will generally be opened in the same browser window/tab where the QueryWebPart. + * @param {String} [config.frame] The frame style to use for the web part. This may be one of the following: + * 'div', 'portal', 'none', 'dialog', 'title', 'left-nav'. + * @param {String} [config.showViewPanel] Open the customize view panel after rendering. The value of this option can be "true" or one of "ColumnsTab", "FilterTab", or "SortTab". + * @param {String} [config.bodyClass] A CSS style class that will be added to the enclosing element for the web part. + * Note, this may not be applied when used in conjunction with some "frame" types (e.g. 'none'). + * @param {Function} [config.success] A function to call after the part has been rendered. It will be passed two arguments: + *
    + *
  • dataRegion: the LABKEY.DataRegion object representing the rendered QueryWebPart
  • + *
  • request: the XMLHTTPRequest that was issued to the server
  • + *
+ * @param {Function} [config.failure] A function to call if the request to retrieve the content fails. It will be passed three arguments: + *
    + *
  • json: JSON object containing the exception.
  • + *
  • response: The XMLHttpRequest object containing the response data.
  • + *
  • options: The parameter to the request call.
  • + *
+ * @param {Object} [config.scope] An object to use as the callback function's scope. Defaults to this. + * @param {int} [config.timeout] A timeout for the AJAX call, in milliseconds. Default is 30000 (30 seconds). + * @param {String} [config.containerPath] The container path in which the schema and query name are defined. If not supplied, the current container path will be used. + * @param {String} [config.containerFilter] One of the values of {@link LABKEY.Query.containerFilter} that sets the scope of this query. If not supplied, the current folder will be used. + * @example + * <div id='queryTestDiv1'/> + * <script type="text/javascript"> + var qwp1 = new LABKEY.QueryWebPart({ + + renderTo: 'queryTestDiv1', + title: 'My Query Web Part', + schemaName: 'lists', + queryName: 'People', + buttonBarPosition: 'none', + aggregates: [ + {column: 'First', type: LABKEY.AggregateTypes.COUNT, label: 'Total People'}, + {column: 'Age', type: LABKEY.AggregateTypes.MEAN} + ], + filters: [ + LABKEY.Filter.create('Last', 'Flintstone') + ], + sort: '-Last' + }); + + //note that you may also register for the 'render' event + //instead of using the success config property. + //registering for events is done using Ext event registration. + //Example: + qwp1.on("render", onRender); + function onRender() + { + //...do something after the part has rendered... + } + + /////////////////////////////////////// + // Custom Button Bar Example + + var qwp1 = new LABKEY.QueryWebPart({ + renderTo: 'queryTestDiv1', + title: 'My Query Web Part', + schemaName: 'lists', + queryName: 'People', + buttonBar: { + includeStandardButtons: true, + items:[ + LABKEY.QueryWebPart.standardButtons.views, + {text: 'Test', url: LABKEY.ActionURL.buildURL('project', 'begin')}, + {text: 'Test Script', onClick: "alert('Hello World!'); return false;"}, + {text: 'Test Handler', handler: onTestHandler}, + {text: 'Test Menu', items: [ + {text: 'Item 1', handler: onItem1Handler}, + {text: 'Fly Out', items: [ + {text: 'Sub Item 1', handler: onItem1Handler} + ]}, + '-', //separator + {text: 'Item 2', handler: onItem2Handler} + ]}, + LABKEY.QueryWebPart.standardButtons.exportRows + ]} + }); + + function onTestHandler(dataRegion) + { + alert("onTestHandler called!"); + return false; + } + + function onItem1Handler(dataRegion) + { + alert("onItem1Handler called!"); + } + + function onItem2Handler(dataRegion) + { + alert("onItem2Handler called!"); + } + + </script> + */ + LABKEY.QueryWebPart = function(config) { + config._useQWPDefaults = true; + return LABKEY.DataRegion.create(config); + }; +})(jQuery); + +/** + * A read-only object that exposes properties representing standard buttons shown in LabKey data grids. + * These are used in conjunction with the buttonBar configuration. The following buttons are currently defined: + *
    + *
  • LABKEY.QueryWebPart.standardButtons.query
  • + *
  • LABKEY.QueryWebPart.standardButtons.views
  • + *
  • LABKEY.QueryWebPart.standardButtons.charts
  • + *
  • LABKEY.QueryWebPart.standardButtons.insertNew
  • + *
  • LABKEY.QueryWebPart.standardButtons.deleteRows
  • + *
  • LABKEY.QueryWebPart.standardButtons.exportRows
  • + *
  • LABKEY.QueryWebPart.standardButtons.print
  • + *
+ * @name standardButtons + * @memberOf LABKEY.QueryWebPart# + */ +LABKEY.QueryWebPart.standardButtons = { + query: 'query', + views: 'grid views', + charts: 'charts', + insertNew: 'insert', + deleteRows: 'delete', + exportRows: 'export', + print: 'print' +}; + +/** + * Requests the query web part content and renders it within the element identified by the renderTo parameter. + * Note that you do not need to call this method explicitly if you specify a renderTo property on the config object + * handed to the class constructor. If you do not specify renderTo in the config, then you must call this method + * passing the id of the element in which you want the part rendered + * @function + * @param renderTo The id of the element in which you want the part rendered. + */ + +LABKEY.QueryWebPart.prototype.render = LABKEY.DataRegion.prototype.render; + +/** + * @returns {LABKEY.DataRegion} + */ +LABKEY.QueryWebPart.prototype.getDataRegion = LABKEY.DataRegion.prototype.getDataRegion; + +LABKEY.AggregateTypes = { + /** + * Displays the sum of the values in the specified column + */ + SUM: 'sum', + /** + * Displays the mean of the values in the specified column + */ + MEAN: 'mean', + /** + * Displays the count of the non-blank values in the specified column + */ + COUNT: 'count', + /** + * Displays the maximum value from the specified column + */ + MIN: 'min', + /** + * Displays the minimum values from the specified column + */ + MAX: 'max', + + /** + * Deprecated + */ + AVG: 'mean' + + // TODO how to allow premium module additions to aggregate types? +}; diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 5dd88b4921e..9ad1056cd37 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -1,8772 +1,8772 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed 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. - */ - -package org.labkey.query.controllers; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import org.antlr.runtime.tree.Tree; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.beanutils.ConvertUtils; -import org.apache.commons.collections4.MultiValuedMap; -import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; -import org.apache.commons.collections4.multimap.HashSetValuedHashMap; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.Strings; -import org.apache.commons.lang3.mutable.MutableInt; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.poi.ss.usermodel.Workbook; -import org.apache.xmlbeans.XmlError; -import org.apache.xmlbeans.XmlException; -import org.apache.xmlbeans.XmlOptions; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONParserConfiguration; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.labkey.api.action.Action; -import org.labkey.api.action.ActionType; -import org.labkey.api.action.ApiJsonForm; -import org.labkey.api.action.ApiJsonWriter; -import org.labkey.api.action.ApiQueryResponse; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiResponseWriter; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ApiUsageException; -import org.labkey.api.action.ApiVersion; -import org.labkey.api.action.ConfirmAction; -import org.labkey.api.action.ExportAction; -import org.labkey.api.action.ExportException; -import org.labkey.api.action.ExtendedApiQueryResponse; -import org.labkey.api.action.FormHandlerAction; -import org.labkey.api.action.FormViewAction; -import org.labkey.api.action.HasBindParameters; -import org.labkey.api.action.JsonInputLimit; -import org.labkey.api.action.LabKeyError; -import org.labkey.api.action.Marshal; -import org.labkey.api.action.Marshaller; -import org.labkey.api.action.MutatingApiAction; -import org.labkey.api.action.NullSafeBindException; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.action.ReportingApiQueryResponse; -import org.labkey.api.action.SimpleApiJsonForm; -import org.labkey.api.action.SimpleErrorView; -import org.labkey.api.action.SimpleRedirectAction; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.AdminUrls; -import org.labkey.api.attachments.SpringAttachmentFile; -import org.labkey.api.audit.AbstractAuditTypeProvider; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.AuditTypeEvent; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.audit.provider.ContainerAuditProvider; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.collections.CaseInsensitiveHashSet; -import org.labkey.api.collections.IntHashMap; -import org.labkey.api.collections.LabKeyCollectors; -import org.labkey.api.collections.RowMapFactory; -import org.labkey.api.collections.Sets; -import org.labkey.api.data.AbstractTableInfo; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.Aggregate; -import org.labkey.api.data.AnalyticsProviderItem; -import org.labkey.api.data.BaseColumnInfo; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.CachedResultSets; -import org.labkey.api.data.ColumnHeaderType; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.ContainerType; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.DbSchema; -import org.labkey.api.data.DbSchemaType; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.ExcelWriter; -import org.labkey.api.data.ForeignKey; -import org.labkey.api.data.JdbcMetaDataSelector; -import org.labkey.api.data.JdbcType; -import org.labkey.api.data.JsonWriter; -import org.labkey.api.data.PHI; -import org.labkey.api.data.PropertyManager; -import org.labkey.api.data.PropertyManager.PropertyMap; -import org.labkey.api.data.PropertyManager.WritablePropertyMap; -import org.labkey.api.data.PropertyStorageSpec; -import org.labkey.api.data.QueryLogging; -import org.labkey.api.data.ResultSetView; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.SchemaTableInfo; -import org.labkey.api.data.ShowRows; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.SqlSelector; -import org.labkey.api.data.TSVWriter; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.data.VirtualTable; -import org.labkey.api.data.dialect.JdbcMetaDataLocator; -import org.labkey.api.data.dialect.SqlDialect; -import org.labkey.api.dataiterator.DataIteratorBuilder; -import org.labkey.api.dataiterator.DataIteratorContext; -import org.labkey.api.dataiterator.DataIteratorUtil; -import org.labkey.api.dataiterator.DetailedAuditLogDataIterator; -import org.labkey.api.dataiterator.ListofMapsDataIterator; -import org.labkey.api.exceptions.OptimisticConflictException; -import org.labkey.api.exp.ExperimentException; -import org.labkey.api.exp.api.ProvenanceRecordingParams; -import org.labkey.api.exp.api.ProvenanceService; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.exp.list.ListService; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainAuditProvider; -import org.labkey.api.exp.property.DomainKind; -import org.labkey.api.exp.property.PropertyService; -import org.labkey.api.files.FileContentService; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; -import org.labkey.api.module.ModuleHtmlView; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.pipeline.RecordedAction; -import org.labkey.api.query.AbstractQueryImportAction; -import org.labkey.api.query.AbstractQueryUpdateService; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.CustomView; -import org.labkey.api.query.DefaultSchema; -import org.labkey.api.query.DetailsURL; -import org.labkey.api.query.DuplicateKeyException; -import org.labkey.api.query.ExportScriptModel; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.FilteredTable; -import org.labkey.api.query.InvalidKeyException; -import org.labkey.api.query.MetadataUnavailableException; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryDefinition; -import org.labkey.api.query.QueryException; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryParam; -import org.labkey.api.query.QueryParseException; -import org.labkey.api.query.QuerySchema; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateForm; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.QueryUpdateServiceException; -import org.labkey.api.query.QueryUrls; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.RuntimeValidationException; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.query.SimpleSchemaTreeVisitor; -import org.labkey.api.query.TempQuerySettings; -import org.labkey.api.query.UserSchema; -import org.labkey.api.query.UserSchemaAction; -import org.labkey.api.query.ValidationException; -import org.labkey.api.reports.report.ReportDescriptor; -import org.labkey.api.security.ActionNames; -import org.labkey.api.security.AdminConsoleAction; -import org.labkey.api.security.CSRF; -import org.labkey.api.security.IgnoresTermsOfUse; -import org.labkey.api.security.MutableSecurityPolicy; -import org.labkey.api.security.RequiresAllOf; -import org.labkey.api.security.RequiresAnyOf; -import org.labkey.api.security.RequiresNoPermission; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.SecurityManager; -import org.labkey.api.security.SecurityPolicyManager; -import org.labkey.api.security.User; -import org.labkey.api.security.UserManager; -import org.labkey.api.security.ValidEmail; -import org.labkey.api.security.permissions.AbstractActionPermissionTest; -import org.labkey.api.security.permissions.AdminOperationsPermission; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.EditSharedViewPermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.MoveEntitiesPermission; -import org.labkey.api.security.permissions.Permission; -import org.labkey.api.security.permissions.PlatformDeveloperPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.security.roles.EditorRole; -import org.labkey.api.settings.AdminConsole; -import org.labkey.api.settings.AppProps; -import org.labkey.api.settings.LookAndFeelProperties; -import org.labkey.api.stats.BaseAggregatesAnalyticsProvider; -import org.labkey.api.stats.ColumnAnalyticsProvider; -import org.labkey.api.util.ButtonBuilder; -import org.labkey.api.util.DOM; -import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.JavaScriptFragment; -import org.labkey.api.util.JsonUtil; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.ResponseHelper; -import org.labkey.api.util.ReturnURLString; -import org.labkey.api.util.StringExpression; -import org.labkey.api.util.TestContext; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.UnexpectedException; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DetailsView; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.InsertView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.UpdateView; -import org.labkey.api.view.VBox; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.ViewServlet; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.PageConfig; -import org.labkey.api.writer.HtmlWriter; -import org.labkey.api.writer.ZipFile; -import org.labkey.data.xml.ColumnType; -import org.labkey.data.xml.ImportTemplateType; -import org.labkey.data.xml.TableType; -import org.labkey.data.xml.TablesDocument; -import org.labkey.data.xml.TablesType; -import org.labkey.data.xml.externalSchema.TemplateSchemaType; -import org.labkey.data.xml.queryCustomView.FilterType; -import org.labkey.query.AutoGeneratedDetailsCustomView; -import org.labkey.query.AutoGeneratedInsertCustomView; -import org.labkey.query.AutoGeneratedUpdateCustomView; -import org.labkey.query.CustomViewImpl; -import org.labkey.query.CustomViewUtil; -import org.labkey.query.EditQueriesPermission; -import org.labkey.query.EditableCustomView; -import org.labkey.query.LinkedTableInfo; -import org.labkey.query.MetadataTableJSON; -import org.labkey.query.ModuleCustomQueryDefinition; -import org.labkey.query.ModuleCustomView; -import org.labkey.query.QueryServiceImpl; -import org.labkey.query.TableXML; -import org.labkey.query.audit.QueryExportAuditProvider; -import org.labkey.query.audit.QueryUpdateAuditProvider; -import org.labkey.query.model.MetadataTableJSONMixin; -import org.labkey.query.persist.AbstractExternalSchemaDef; -import org.labkey.query.persist.CstmView; -import org.labkey.query.persist.ExternalSchemaDef; -import org.labkey.query.persist.ExternalSchemaDefCache; -import org.labkey.query.persist.LinkedSchemaDef; -import org.labkey.query.persist.QueryDef; -import org.labkey.query.persist.QueryManager; -import org.labkey.query.reports.ReportsController; -import org.labkey.query.reports.getdata.DataRequest; -import org.labkey.query.sql.QNode; -import org.labkey.query.sql.Query; -import org.labkey.query.sql.SqlParser; -import org.labkey.query.xml.ApiTestsDocument; -import org.labkey.query.xml.TestCaseType; -import org.labkey.remoteapi.RemoteConnections; -import org.labkey.remoteapi.SelectRowsStreamHack; -import org.labkey.remoteapi.query.SelectRowsCommand; -import org.labkey.vfs.FileLike; -import org.springframework.beans.MutablePropertyValues; -import org.springframework.beans.PropertyValue; -import org.springframework.beans.PropertyValues; -import org.springframework.dao.DataAccessException; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.ModelAndView; - -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.OutputStream; -import java.io.PrintWriter; -import java.nio.file.Path; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.apache.commons.lang3.StringUtils.isBlank; -import static org.apache.commons.lang3.StringUtils.trimToEmpty; -import static org.labkey.api.action.ApiJsonWriter.CONTENT_TYPE_JSON; -import static org.labkey.api.assay.AssayFileWriter.ensureUploadDirectory; -import static org.labkey.api.data.DbScope.NO_OP_TRANSACTION; -import static org.labkey.api.query.AbstractQueryUpdateService.saveFile; -import static org.labkey.api.util.DOM.BR; -import static org.labkey.api.util.DOM.DIV; -import static org.labkey.api.util.DOM.FONT; -import static org.labkey.api.util.DOM.Renderable; -import static org.labkey.api.util.DOM.TABLE; -import static org.labkey.api.util.DOM.TD; -import static org.labkey.api.util.DOM.TR; -import static org.labkey.api.util.DOM.at; -import static org.labkey.api.util.DOM.cl; -import static org.labkey.query.MetadataTableJSON.getTableType; -import static org.labkey.query.MetadataTableJSON.parseDocument; - -@SuppressWarnings("DefaultAnnotationParam") - -public class QueryController extends SpringActionController -{ - private static final Logger LOG = LogManager.getLogger(QueryController.class); - private static final String ROW_ATTACHMENT_INDEX_DELIM = "::"; - - private static final Set RESERVED_VIEW_NAMES = CaseInsensitiveHashSet.of( - "Default", - AutoGeneratedDetailsCustomView.NAME, - AutoGeneratedInsertCustomView.NAME, - AutoGeneratedUpdateCustomView.NAME - ); - - private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(QueryController.class, - ValidateQueryAction.class, - ValidateQueriesAction.class, - GetSchemaQueryTreeAction.class, - GetQueryDetailsAction.class, - ViewQuerySourceAction.class - ); - - public QueryController() - { - setActionResolver(_actionResolver); - } - - public static void registerAdminConsoleLinks() - { - AdminConsole.addLink(AdminConsole.SettingsLinkType.Diagnostics, "data sources", new ActionURL(DataSourceAdminAction.class, ContainerManager.getRoot())); - } - - public static class RemoteQueryConnectionUrls - { - public static ActionURL urlManageRemoteConnection(Container c) - { - return new ActionURL(ManageRemoteConnectionsAction.class, c); - } - - public static ActionURL urlCreateRemoteConnection(Container c) - { - return new ActionURL(EditRemoteConnectionAction.class, c); - } - - public static ActionURL urlEditRemoteConnection(Container c, String connectionName) - { - ActionURL url = new ActionURL(EditRemoteConnectionAction.class, c); - url.addParameter("connectionName", connectionName); - return url; - } - - public static ActionURL urlSaveRemoteConnection(Container c) - { - return new ActionURL(EditRemoteConnectionAction.class, c); - } - - public static ActionURL urlDeleteRemoteConnection(Container c, @Nullable String connectionName) - { - ActionURL url = new ActionURL(DeleteRemoteConnectionAction.class, c); - if (connectionName != null) - url.addParameter("connectionName", connectionName); - return url; - } - - public static ActionURL urlTestRemoteConnection(Container c, String connectionName) - { - ActionURL url = new ActionURL(TestRemoteConnectionAction.class, c); - url.addParameter("connectionName", connectionName); - return url; - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class EditRemoteConnectionAction extends FormViewAction - { - @Override - public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) - { - remoteConnectionForm.setConnectionKind(RemoteConnections.CONNECTION_KIND_QUERY); - if (!errors.hasErrors()) - { - String name = remoteConnectionForm.getConnectionName(); - // package the remote-connection properties into the remoteConnectionForm and pass them along - Map map1 = RemoteConnections.getRemoteConnection(RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY, name, getContainer()); - remoteConnectionForm.setUrl(map1.get("URL")); - remoteConnectionForm.setUserEmail(map1.get("user")); - remoteConnectionForm.setPassword(map1.get("password")); - remoteConnectionForm.setFolderPath(map1.get("container")); - } - setHelpTopic("remoteConnection"); - return new JspView<>("/org/labkey/query/view/createRemoteConnection.jsp", remoteConnectionForm, errors); - } - - @Override - public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) - { - return RemoteConnections.createOrEditRemoteConnection(remoteConnectionForm, getContainer(), errors); - } - - @Override - public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) - { - return RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Create/Edit Remote Connection", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class DeleteRemoteConnectionAction extends FormViewAction - { - @Override - public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/query/view/confirmDeleteConnection.jsp", remoteConnectionForm, errors); - } - - @Override - public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) - { - remoteConnectionForm.setConnectionKind(RemoteConnections.CONNECTION_KIND_QUERY); - return RemoteConnections.deleteRemoteConnection(remoteConnectionForm, getContainer()); - } - - @Override - public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) - { - return RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Confirm Delete Connection", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class TestRemoteConnectionAction extends FormViewAction - { - @Override - public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) - { - String name = remoteConnectionForm.getConnectionName(); - String schemaName = "core"; // test Schema Name - String queryName = "Users"; // test Query Name - - // Extract the username, password, and container from the secure property store - Map singleConnectionMap = RemoteConnections.getRemoteConnection(RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY, name, getContainer()); - if (singleConnectionMap.isEmpty()) - throw new NotFoundException(); - String url = singleConnectionMap.get(RemoteConnections.FIELD_URL); - String user = singleConnectionMap.get(RemoteConnections.FIELD_USER); - String password = singleConnectionMap.get(RemoteConnections.FIELD_PASSWORD); - String container = singleConnectionMap.get(RemoteConnections.FIELD_CONTAINER); - - // connect to the remote server and retrieve an input stream - org.labkey.remoteapi.Connection cn = new org.labkey.remoteapi.Connection(url, user, password); - final SelectRowsCommand cmd = new SelectRowsCommand(schemaName, queryName); - try - { - DataIteratorBuilder source = SelectRowsStreamHack.go(cn, container, cmd, getContainer()); - // immediately close the source after opening it, this is a test. - source.getDataIterator(new DataIteratorContext()).close(); - } - catch (Exception e) - { - errors.addError(new LabKeyError("The listed credentials for this remote connection failed to connect.")); - return new JspView<>("/org/labkey/query/view/testRemoteConnectionsFailure.jsp", remoteConnectionForm); - } - - return new JspView<>("/org/labkey/query/view/testRemoteConnectionsSuccess.jsp", remoteConnectionForm); - } - - @Override - public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) - { - return true; - } - - @Override - public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Manage Remote Connections", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - public static class QueryUrlsImpl implements QueryUrls - { - @Override - public ActionURL urlSchemaBrowser(Container c) - { - return new ActionURL(BeginAction.class, c); - } - - @Override - public ActionURL urlSchemaBrowser(Container c, @Nullable String schemaName) - { - ActionURL ret = urlSchemaBrowser(c); - if (schemaName != null) - { - ret.addParameter(QueryParam.schemaName.toString(), schemaName); - } - return ret; - } - - @Override - public ActionURL urlSchemaBrowser(Container c, @Nullable String schemaName, @Nullable String queryName) - { - if (StringUtils.isEmpty(queryName)) - return urlSchemaBrowser(c, schemaName); - ActionURL ret = urlSchemaBrowser(c); - ret.addParameter(QueryParam.schemaName.toString(), trimToEmpty(schemaName)); - ret.addParameter(QueryParam.queryName.toString(), trimToEmpty(queryName)); - return ret; - } - - public ActionURL urlExternalSchemaAdmin(Container c) - { - return urlExternalSchemaAdmin(c, null); - } - - public ActionURL urlExternalSchemaAdmin(Container c, @Nullable String message) - { - ActionURL url = new ActionURL(AdminAction.class, c); - - if (null != message) - url.addParameter("message", message); - - return url; - } - - public ActionURL urlInsertExternalSchema(Container c) - { - return new ActionURL(InsertExternalSchemaAction.class, c); - } - - public ActionURL urlNewQuery(Container c) - { - return new ActionURL(NewQueryAction.class, c); - } - - public ActionURL urlUpdateExternalSchema(Container c, AbstractExternalSchemaDef def) - { - ActionURL url = new ActionURL(EditExternalSchemaAction.class, c); - url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); - return url; - } - - public ActionURL urlReloadExternalSchema(Container c, AbstractExternalSchemaDef def) - { - ActionURL url = new ActionURL(ReloadExternalSchemaAction.class, c); - url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); - return url; - } - - public ActionURL urlDeleteSchema(Container c, AbstractExternalSchemaDef def) - { - ActionURL url = new ActionURL(DeleteSchemaAction.class, c); - url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); - return url; - } - - @Override - public ActionURL urlStartBackgroundRReport(@NotNull ActionURL baseURL, String reportId) - { - ActionURL result = baseURL.clone(); - result.setAction(ReportsController.StartBackgroundRReportAction.class); - result.replaceParameter(ReportDescriptor.Prop.reportId, reportId); - return result; - } - - @Override - public ActionURL urlExecuteQuery(@NotNull ActionURL baseURL) - { - ActionURL result = baseURL.clone(); - result.setAction(ExecuteQueryAction.class); - return result; - } - - @Override - public ActionURL urlExecuteQuery(Container c, String schemaName, String queryName) - { - return new ActionURL(ExecuteQueryAction.class, c) - .addParameter(QueryParam.schemaName, schemaName) - .addParameter(QueryParam.queryName, queryName); - } - - @Override - public @NotNull ActionURL urlCreateExcelTemplate(Container c, String schemaName, String queryName) - { - return new ActionURL(ExportExcelTemplateAction.class, c) - .addParameter(QueryParam.schemaName, schemaName) - .addParameter("query.queryName", queryName); - } - - @Override - public ActionURL urlMetadataQuery(Container c, String schemaName, String queryName) - { - return new ActionURL(MetadataQueryAction.class, c) - .addParameter(QueryParam.schemaName, schemaName) - .addParameter(QueryParam.queryName, queryName); - } - } - - @Override - public PageConfig defaultPageConfig() - { - // set default help topic for query controller - PageConfig config = super.defaultPageConfig(); - config.setHelpTopic("querySchemaBrowser"); - return config; - } - - @AdminConsoleAction(AdminOperationsPermission.class) - public static class DataSourceAdminAction extends SimpleViewAction - { - public DataSourceAdminAction() - { - } - - public DataSourceAdminAction(ViewContext viewContext) - { - setViewContext(viewContext); - } - - @Override - public ModelAndView getView(Object o, BindException errors) - { - // Site Admin or Troubleshooter? Troubleshooters can see all the information but can't test data sources. - boolean hasAdminOpsPerms = getContainer().hasPermission(getUser(), AdminOperationsPermission.class); - List allDefs = QueryManager.get().getExternalSchemaDefs(null); - - MultiValuedMap byDataSourceName = new ArrayListValuedHashMap<>(); - - for (ExternalSchemaDef def : allDefs) - byDataSourceName.put(def.getDataSource(), def); - - MutableInt row = new MutableInt(); - - Renderable r = DOM.DIV( - DIV("This page lists all the data sources defined in your " + AppProps.getInstance().getWebappConfigurationFilename() + " file that were available when first referenced and the external schemas defined in each."), - BR(), - TABLE(cl("labkey-data-region"), - TR(cl("labkey-show-borders"), - hasAdminOpsPerms ? TD(cl("labkey-column-header"), "Test") : null, - TD(cl("labkey-column-header"), "Data Source"), - TD(cl("labkey-column-header"), "Current Status"), - TD(cl("labkey-column-header"), "URL"), - TD(cl("labkey-column-header"), "Database Name"), - TD(cl("labkey-column-header"), "Product Name"), - TD(cl("labkey-column-header"), "Product Version"), - TD(cl("labkey-column-header"), "Max Connections"), - TD(cl("labkey-column-header"), "Active Connections"), - TD(cl("labkey-column-header"), "Idle Connections"), - TD(cl("labkey-column-header"), "Max Wait (ms)") - ), - DbScope.getDbScopes().stream() - .flatMap(scope -> { - String rowStyle = row.getAndIncrement() % 2 == 0 ? "labkey-alternate-row labkey-show-borders" : "labkey-row labkey-show-borders"; - Object status; - boolean connected = false; - try (Connection ignore = scope.getConnection()) - { - status = "connected"; - connected = true; - } - catch (Exception e) - { - status = FONT(cl("labkey-error"), "disconnected"); - } - - return Stream.of( - TR( - cl(rowStyle), - hasAdminOpsPerms ? TD(connected ? new ButtonBuilder("Test").href(new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", scope.getDataSourceName())) : "") : null, - TD(HtmlString.NBSP, scope.getDisplayName()), - TD(status), - TD(scope.getDatabaseUrl()), - TD(scope.getDatabaseName()), - TD(scope.getDatabaseProductName()), - TD(scope.getDatabaseProductVersion()), - TD(scope.getDataSourceProperties().getMaxTotal()), - TD(scope.getDataSourceProperties().getNumActive()), - TD(scope.getDataSourceProperties().getNumIdle()), - TD(scope.getDataSourceProperties().getMaxWaitMillis()) - ), - TR( - cl(rowStyle), - TD(HtmlString.NBSP), - TD(at(DOM.Attribute.colspan, 10), getDataSourceTable(byDataSourceName.get(scope.getDataSourceName()))) - ) - ); - }) - ) - ); - - return new HtmlView(r); - } - - private Renderable getDataSourceTable(Collection dsDefs) - { - if (dsDefs.isEmpty()) - return TABLE(TR(TD(HtmlString.NBSP))); - - MultiValuedMap byContainerPath = new ArrayListValuedHashMap<>(); - - for (ExternalSchemaDef def : dsDefs) - byContainerPath.put(def.getContainerPath(), def); - - TreeSet paths = new TreeSet<>(byContainerPath.keySet()); - - return TABLE(paths.stream() - .map(path -> TR(TD(at(DOM.Attribute.colspan, 4), getDataSourcePath(path, byContainerPath.get(path))))) - ); - } - - private Renderable getDataSourcePath(String path, Collection unsorted) - { - List defs = new ArrayList<>(unsorted); - defs.sort(Comparator.comparing(AbstractExternalSchemaDef::getUserSchemaName, String.CASE_INSENSITIVE_ORDER)); - Container c = ContainerManager.getForPath(path); - - if (null == c) - return TD(); - - boolean hasRead = c.hasPermission(getUser(), ReadPermission.class); - QueryUrlsImpl urls = new QueryUrlsImpl(); - - return - TD(TABLE( - TR(TD( - at(DOM.Attribute.colspan, 3), - hasRead ? LinkBuilder.simpleLink(path, urls.urlExternalSchemaAdmin(c)) : path - )), - TR(TD(TABLE( - defs.stream() - .map(def -> TR(TD( - at(DOM.Attribute.style, "padding-left:20px"), - hasRead ? LinkBuilder.simpleLink(def.getUserSchemaName() + - (!Strings.CS.equals(def.getSourceSchemaName(), def.getUserSchemaName()) ? " (" + def.getSourceSchemaName() + ")" : ""), urls.urlUpdateExternalSchema(c, def)) - : def.getUserSchemaName() - ))) - ))) - )); - } - - @Override - public void addNavTrail(NavTree root) - { - urlProvider(AdminUrls.class).addAdminNavTrail(root, "Data Source Administration", getClass(), getContainer()); - } - } - - public static class TestDataSourceForm - { - private String _dataSource; - - public String getDataSource() - { - return _dataSource; - } - - @SuppressWarnings("unused") - public void setDataSource(String dataSource) - { - _dataSource = dataSource; - } - } - - public static class TestDataSourceConfirmForm extends TestDataSourceForm - { - private String _excludeSchemas; - private String _excludeTables; - - public String getExcludeSchemas() - { - return _excludeSchemas; - } - - @SuppressWarnings("unused") - public void setExcludeSchemas(String excludeSchemas) - { - _excludeSchemas = excludeSchemas; - } - - public String getExcludeTables() - { - return _excludeTables; - } - - @SuppressWarnings("unused") - public void setExcludeTables(String excludeTables) - { - _excludeTables = excludeTables; - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class TestDataSourceConfirmAction extends FormViewAction - { - private DbScope _scope; - - @Override - public ModelAndView getView(TestDataSourceConfirmForm form, boolean reshow, BindException errors) throws Exception - { - validateCommand(form, errors); - return new JspView<>("/org/labkey/query/view/testDataSourceConfirm.jsp", _scope); - } - - @Override - public void validateCommand(TestDataSourceConfirmForm form, Errors errors) - { - _scope = DbScope.getDbScope(form.getDataSource()); - - if (null == _scope) - throw new NotFoundException("Could not resolve data source " + form.getDataSource()); - } - - @Override - public boolean handlePost(TestDataSourceConfirmForm form, BindException errors) throws Exception - { - saveTestDataSourceProperties(form); - return true; - } - - @Override - public URLHelper getSuccessURL(TestDataSourceConfirmForm form) - { - return new ActionURL(TestDataSourceAction.class, getContainer()).addParameter("dataSource", _scope.getDataSourceName()); - } - - @Override - public void addNavTrail(NavTree root) - { - new DataSourceAdminAction(getViewContext()).addNavTrail(root); - root.addChild("Prepare Test of " + _scope.getDataSourceName()); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class TestDataSourceAction extends SimpleViewAction - { - private DbScope _scope; - - @Override - public ModelAndView getView(TestDataSourceForm form, BindException errors) - { - _scope = DbScope.getDbScope(form.getDataSource()); - - if (null == _scope) - throw new NotFoundException("Could not resolve data source " + form.getDataSource()); - - return new JspView<>("/org/labkey/query/view/testDataSource.jsp", _scope); - } - - @Override - public void addNavTrail(NavTree root) - { - new DataSourceAdminAction(getViewContext()).addNavTrail(root); - root.addChild("Test " + _scope.getDataSourceName()); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class ResetDataSourcePropertiesAction extends FormHandlerAction - { - @Override - public void validateCommand(TestDataSourceForm target, Errors errors) - { - } - - @Override - public boolean handlePost(TestDataSourceForm form, BindException errors) throws Exception - { - WritablePropertyMap map = PropertyManager.getWritableProperties(getCategory(form.getDataSource()), false); - if (map != null) - map.delete(); - return true; - } - - @Override - public URLHelper getSuccessURL(TestDataSourceForm form) - { - return new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", form.getDataSource()) ; - } - } - - private static final String TEST_DATA_SOURCE_CATEGORY = "testDataSourceProperties"; - private static final String TEST_DATA_SOURCE_SCHEMAS_PROPERTY = "excludeSchemas"; - private static final String TEST_DATA_SOURCE_TABLES_PROPERTY = "excludeTables"; - - private static String getCategory(String dataSourceName) - { - return TEST_DATA_SOURCE_CATEGORY + "|" + dataSourceName; - } - - public static void saveTestDataSourceProperties(TestDataSourceConfirmForm form) - { - WritablePropertyMap map = PropertyManager.getWritableProperties(getCategory(form.getDataSource()), true); - // Save empty entries as empty string to distinguish from null (which results in default values) - map.put(TEST_DATA_SOURCE_SCHEMAS_PROPERTY, StringUtils.trimToEmpty(form.getExcludeSchemas())); - map.put(TEST_DATA_SOURCE_TABLES_PROPERTY, StringUtils.trimToEmpty(form.getExcludeTables())); - map.save(); - } - - public static TestDataSourceConfirmForm getTestDataSourceProperties(DbScope scope) - { - TestDataSourceConfirmForm form = new TestDataSourceConfirmForm(); - PropertyMap map = PropertyManager.getProperties(getCategory(scope.getDataSourceName())); - form.setExcludeSchemas(map.getOrDefault(TEST_DATA_SOURCE_SCHEMAS_PROPERTY, scope.getSqlDialect().getDefaultSchemasToExcludeFromTesting())); - form.setExcludeTables(map.getOrDefault(TEST_DATA_SOURCE_TABLES_PROPERTY, scope.getSqlDialect().getDefaultTablesToExcludeFromTesting())); - - return form; - } - - @RequiresPermission(ReadPermission.class) - public static class BrowseAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/query/view/browse.jsp", null); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Schema Browser"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class BeginAction extends QueryViewAction - { - @SuppressWarnings("UnusedDeclaration") - public BeginAction() - { - } - - public BeginAction(ViewContext ctx) - { - setViewContext(ctx); - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - JspView view = new JspView<>("/org/labkey/query/view/browse.jsp", form); - view.setFrame(WebPartView.FrameType.NONE); - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Query Schema Browser", new QueryUrlsImpl().urlSchemaBrowser(getContainer())); - } - } - - @RequiresPermission(ReadPermission.class) - public class SchemaAction extends QueryViewAction - { - public SchemaAction() {} - - SchemaAction(QueryForm form) - { - _form = form; - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - _form = form; - return new JspView<>("/org/labkey/query/view/browse.jsp", form); - } - - @Override - public void addNavTrail(NavTree root) - { - if (_form != null && _form.getSchema() != null) - addSchemaActionNavTrail(root, _form.getSchema().getSchemaPath(), _form.getQueryName()); - } - } - - - void addSchemaActionNavTrail(NavTree root, SchemaKey schemaKey, String queryName) - { - if (getContainer().hasOneOf(getUser(), AdminPermission.class, PlatformDeveloperPermission.class)) - { - // Don't show the full query nav trail to non-admin/non-developer users as they almost certainly don't - // want it - try - { - String schemaName = schemaKey.toDisplayString(); - ActionURL url = new ActionURL(BeginAction.class, getContainer()); - url.addParameter("schemaName", schemaKey.toString()); - url.addParameter("queryName", queryName); - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild(schemaName + " Schema", url); - } - catch (NullPointerException e) - { - LOG.error("NullPointerException in addNavTrail", e); - } - } - } - - - // Trusted analysts who are editors can create and modify queries - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - @Action(ActionType.SelectData.class) - public class NewQueryAction extends FormViewAction - { - private NewQueryForm _form; - private ActionURL _successUrl; - - @Override - public void validateCommand(NewQueryForm target, org.springframework.validation.Errors errors) - { - target.ff_newQueryName = StringUtils.trimToNull(target.ff_newQueryName); - if (null == target.ff_newQueryName) - errors.reject(ERROR_MSG, "QueryName is required"); - } - - @Override - public ModelAndView getView(NewQueryForm form, boolean reshow, BindException errors) - { - form.ensureSchemaExists(); - - if (!form.getSchema().canCreate()) - { - throw new UnauthorizedException(); - } - - getPageConfig().setFocusId("ff_newQueryName"); - _form = form; - setHelpTopic("sqlTutorial"); - return new JspView<>("/org/labkey/query/view/newQuery.jsp", form, errors); - } - - @Override - public boolean handlePost(NewQueryForm form, BindException errors) - { - form.ensureSchemaExists(); - - if (!form.getSchema().canCreate()) - { - throw new UnauthorizedException(); - } - - try - { - if (StringUtils.isEmpty(form.ff_baseTableName)) - { - errors.reject(ERROR_MSG, "You must select a base table or query name."); - return false; - } - - UserSchema schema = form.getSchema(); - String newQueryName = form.ff_newQueryName; - QueryDef existing = QueryManager.get().getQueryDef(getContainer(), form.getSchemaName(), newQueryName, true); - if (existing != null) - { - errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists."); - return false; - } - TableInfo existingTable = form.getSchema().getTable(newQueryName, null); - if (existingTable != null) - { - errors.reject(ERROR_MSG, "A table with the name '" + newQueryName + "' already exists."); - return false; - } - // bug 6095 -- conflicting query and dataset names - if (form.getSchema().getTableNames().contains(newQueryName)) - { - errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists as a table"); - return false; - } - QueryDefinition newDef = QueryService.get().createQueryDef(getUser(), getContainer(), form.getSchemaKey(), form.ff_newQueryName); - Query query = new Query(schema); - query.setRootTable(FieldKey.fromParts(form.ff_baseTableName)); - String sql = query.getQueryText(); - if (null == sql) - sql = "SELECT * FROM \"" + form.ff_baseTableName + "\""; - newDef.setSql(sql); - - try - { - newDef.save(getUser(), getContainer()); - } - catch (SQLException x) - { - if (RuntimeSQLException.isConstraintException(x)) - { - errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists."); - return false; - } - else - { - throw x; - } - } - - _successUrl = newDef.urlFor(form.ff_redirect); - return true; - } - catch (Exception e) - { - ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), e); - errors.reject(ERROR_MSG, Objects.toString(e.getMessage(), e.toString())); - return false; - } - } - - @Override - public ActionURL getSuccessURL(NewQueryForm newQueryForm) - { - return _successUrl; - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - root.addChild("New Query", new QueryUrlsImpl().urlNewQuery(getContainer())); - } - } - - // CONSIDER : deleting this action after the SQL editor UI changes are finalized, keep in mind that built-in views - // use this view as well via the edit metadata page. - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) // Note: This action deals with just meta data; it AJAXes data into place using GetWebPartAction - public class SourceQueryAction extends SimpleViewAction - { - public SourceForm _form; - public UserSchema _schema; - public QueryDefinition _queryDef; - - - @Override - public void validate(SourceForm target, BindException errors) - { - _form = target; - if (StringUtils.isEmpty(target.getSchemaName())) - throw new NotFoundException("schema name not specified"); - if (StringUtils.isEmpty(target.getQueryName())) - throw new NotFoundException("query name not specified"); - - QuerySchema querySchema = DefaultSchema.get(getUser(), getContainer(), _form.getSchemaKey()); - if (null == querySchema) - throw new NotFoundException("schema not found: " + _form.getSchemaKey().toDisplayString()); - if (!(querySchema instanceof UserSchema)) - throw new NotFoundException("Could not find the schema '" + _form.getSchemaName() + "' in the folder '" + getContainer().getPath() + "'"); - _schema = (UserSchema)querySchema; - } - - - @Override - public ModelAndView getView(SourceForm form, BindException errors) - { - _queryDef = _schema.getQueryDef(form.getQueryName()); - if (null == _queryDef) - _queryDef = _schema.getQueryDefForTable(form.getQueryName()); - if (null == _queryDef) - throw new NotFoundException("Could not find the query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); - - try - { - if (form.ff_queryText == null) - { - form.ff_queryText = _queryDef.getSql(); - form.ff_metadataText = _queryDef.getMetadataXml(); - if (null == form.ff_metadataText) - form.ff_metadataText = form.getDefaultMetadataText(); - } - - for (QueryException qpe : _queryDef.getParseErrors(_schema)) - { - errors.reject(ERROR_MSG, Objects.toString(qpe.getMessage(), qpe.toString())); - } - } - catch (Exception e) - { - try - { - ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), e); - } - catch (Throwable t) - { - // - } - errors.reject("ERROR_MSG", e.toString()); - LOG.error("Error", e); - } - - Renderable moduleWarning = null; - if (_queryDef instanceof ModuleCustomQueryDefinition mcqd && _queryDef.canEdit(getUser())) - { - moduleWarning = DIV(cl("labkey-warning-messages"), - "This SQL query is defined in the '" + mcqd.getModuleName() + "' module in directory '" + mcqd.getSqlFile().getParent() + "'.", - BR(), - "Changes to this query will be reflected in all usages across different folders on the server." - ); - } - - var sourceQueryView = new JspView<>("/org/labkey/query/view/sourceQuery.jsp", this, errors); - WebPartView ret = sourceQueryView; - if (null != moduleWarning) - ret = new VBox(new HtmlView(moduleWarning), sourceQueryView); - return ret; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("useSqlEditor"); - - addSchemaActionNavTrail(root, _form.getSchemaKey(), _form.getQueryName()); - - root.addChild("Edit " + _form.getQueryName()); - } - } - - - /** - * Ajax action to save a query. If the save is successful the request will return successfully. A query - * with SQL syntax errors can still be saved successfully. - * - * If the SQL contains parse errors, a parseErrors object will be returned which contains an array of - * JSON serialized error information. - */ - // Trusted analysts who are editors can create and modify queries - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - @Action(ActionType.Configure.class) - public static class SaveSourceQueryAction extends MutatingApiAction - { - private UserSchema _schema; - - @Override - public void validateForm(SourceForm form, Errors errors) - { - if (StringUtils.isEmpty(form.getSchemaName())) - throw new NotFoundException("Query definition not found, schemaName and queryName are required."); - if (StringUtils.isEmpty(form.getQueryName())) - throw new NotFoundException("Query definition not found, schemaName and queryName are required."); - - QuerySchema querySchema = DefaultSchema.get(getUser(), getContainer(), form.getSchemaKey()); - if (null == querySchema) - throw new NotFoundException("schema not found: " + form.getSchemaKey().toDisplayString()); - if (!(querySchema instanceof UserSchema)) - throw new NotFoundException("Could not find the schema '" + form.getSchemaName() + "' in the folder '" + getContainer().getPath() + "'"); - _schema = (UserSchema)querySchema; - - XmlOptions options = XmlBeansUtil.getDefaultParseOptions(); - List xmlErrors = new ArrayList<>(); - options.setErrorListener(xmlErrors); - try - { - // had a couple of real-world failures due to null pointers in this code, so it's time to be paranoid - if (form.ff_metadataText != null) - { - TablesDocument tablesDoc = TablesDocument.Factory.parse(form.ff_metadataText, options); - if (tablesDoc != null) - { - tablesDoc.validate(options); - TablesType tablesType = tablesDoc.getTables(); - if (tablesType != null) - { - for (TableType tableType : tablesType.getTableArray()) - { - if (null != tableType) - { - if (!Objects.equals(tableType.getTableName(), form.getQueryName())) - { - errors.reject(ERROR_MSG, "Table name in the XML metadata must match the table/query name: " + form.getQueryName()); - } - - TableType.Columns tableColumns = tableType.getColumns(); - if (null != tableColumns) - { - ColumnType[] tableColumnArray = tableColumns.getColumnArray(); - for (ColumnType column : tableColumnArray) - { - if (column.isSetPhi() || column.isSetProtected()) - { - throw new IllegalArgumentException("PHI/protected metadata must not be set here."); - } - - ColumnType.Fk fk = column.getFk(); - if (null != fk) - { - try - { - validateForeignKey(fk, column, errors); - validateLookupFilter(AbstractTableInfo.parseXMLLookupFilters(fk.getFilters()), errors); - } - catch (ValidationException e) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - } - } - } - } - } - } - } - } - } - catch (XmlException e) - { - throw new RuntimeValidationException(e); - } - - for (XmlError xmle : xmlErrors) - { - errors.reject(ERROR_MSG, XmlBeansUtil.getErrorMessage(xmle)); - } - } - - private void validateForeignKey(ColumnType.Fk fk, ColumnType column, Errors errors) - { - if (fk.isSetFkMultiValued()) - { - // issue 51695 : don't let users create unsupported MVFK types - String type = fk.getFkMultiValued(); - if (!AbstractTableInfo.MultiValuedFkType.junction.name().equals(type)) - { - errors.reject(ERROR_MSG, String.format("Column : \"%s\" has an invalid fkMultiValued value : \"%s\" is not supported.", column.getColumnName(), type)); - } - } - } - - private void validateLookupFilter(Map> filterMap, Errors errors) - { - filterMap.forEach((operation, filters) -> { - - String displayStr = "Filter for operation : " + operation.name(); - for (FilterType filter : filters) - { - if (isBlank(filter.getColumn())) - errors.reject(ERROR_MSG, displayStr + " requires columnName"); - - if (null == filter.getOperator()) - { - errors.reject(ERROR_MSG, displayStr + " requires operator"); - } - else - { - CompareType compareType = CompareType.getByURLKey(filter.getOperator().toString()); - if (null == compareType) - { - errors.reject(ERROR_MSG, displayStr + " operator is invalid"); - } - else - { - if (compareType.isDataValueRequired() && null == filter.getValue()) - errors.reject(ERROR_MSG, displayStr + " requires a value but none is specified"); - } - } - } - - try - { - // attempt to convert to something we can query against - SimpleFilter.fromXml(filters.toArray(new FilterType[0])); - } - catch (Exception e) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - }); - } - - @Override - public ApiResponse execute(SourceForm form, BindException errors) - { - var queryDef = _schema.getQueryDef(form.getQueryName()); - if (null == queryDef) - queryDef = _schema.getQueryDefForTable(form.getQueryName()); - if (null == queryDef) - throw new NotFoundException("Could not find the query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); - - ApiSimpleResponse response = new ApiSimpleResponse(); - - try - { - if (form.ff_queryText != null) - { - if (!queryDef.isSqlEditable()) - throw new UnauthorizedException("Query SQL is not editable."); - - if (!queryDef.canEdit(getUser())) - throw new UnauthorizedException("Edit permissions are required."); - - queryDef.setSql(form.ff_queryText); - } - - String metadataText = StringUtils.trimToNull(form.ff_metadataText); - if (!Objects.equals(metadataText, queryDef.getMetadataXml())) - { - if (queryDef.isMetadataEditable()) - { - if (!queryDef.canEditMetadata(getUser())) - throw new UnauthorizedException("Edit metadata permissions are required."); - - if (!getUser().isTrustedBrowserDev()) - { - JavaScriptFragment.ensureXMLMetadataNoJavaScript(metadataText); - } - - queryDef.setMetadataXml(metadataText); - } - else - { - if (metadataText != null) - throw new UnsupportedOperationException("Query metadata is not editable."); - } - } - - queryDef.save(getUser(), getContainer()); - - // the query was successfully saved, validate the query but return any errors in the success response - List parseErrors = new ArrayList<>(); - List parseWarnings = new ArrayList<>(); - queryDef.validateQuery(_schema, parseErrors, parseWarnings); - if (!parseErrors.isEmpty()) - { - JSONArray errorArray = new JSONArray(); - - for (QueryException e : parseErrors) - { - errorArray.put(e.toJSON(form.ff_queryText)); - } - response.put("parseErrors", errorArray); - } - else if (!parseWarnings.isEmpty()) - { - JSONArray errorArray = new JSONArray(); - - for (QueryException e : parseWarnings) - { - errorArray.put(e.toJSON(form.ff_queryText)); - } - response.put("parseWarnings", errorArray); - } - } - catch (SQLException e) - { - errors.reject(ERROR_MSG, "An exception occurred: " + e); - LOG.error("Error", e); - } - catch (RuntimeException e) - { - errors.reject(ERROR_MSG, "An exception occurred: " + e.getMessage()); - LOG.error("Error", e); - } - - if (errors.hasErrors()) - return null; - - //if we got here, the query is OK - response.put("success", true); - return response; - } - - } - - - // Trusted analysts who are editors can create and modify queries - @RequiresAllOf({EditQueriesPermission.class, DeletePermission.class}) - @Action(ActionType.Configure.class) - public static class DeleteQueryAction extends ConfirmAction - { - public SourceForm _form; - public QuerySchema _baseSchema; - public QueryDefinition _queryDef; - - - @Override - public void validateCommand(SourceForm target, Errors errors) - { - _form = target; - if (StringUtils.isEmpty(target.getSchemaName())) - throw new NotFoundException("Query definition not found, schemaName and queryName are required."); - if (StringUtils.isEmpty(target.getQueryName())) - throw new NotFoundException("Query definition not found, schemaName and queryName are required."); - - _baseSchema = DefaultSchema.get(getUser(), getContainer(), _form.getSchemaKey()); - if (null == _baseSchema) - throw new NotFoundException("Schema not found: " + _form.getSchemaKey().toDisplayString()); - } - - - @Override - public ModelAndView getConfirmView(SourceForm form, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Delete Query"); - _queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), _baseSchema.getSchemaName(), form.getQueryName()); - - if (null == _queryDef) - throw new NotFoundException("Query not found: " + form.getQueryName()); - - if (!_queryDef.canDelete(getUser())) - { - errors.reject(ERROR_MSG, "Sorry, this query can not be deleted"); - } - - return new JspView<>("/org/labkey/query/view/deleteQuery.jsp", this, errors); - } - - - @Override - public boolean handlePost(SourceForm form, BindException errors) throws Exception - { - _queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), _baseSchema.getSchemaName(), form.getQueryName()); - - if (null == _queryDef) - return false; - try - { - _queryDef.delete(getUser()); - } - catch (OptimisticConflictException x) - { - /* reshow will throw NotFound, so just ignore */ - } - return true; - } - - @Override - @NotNull - public ActionURL getSuccessURL(SourceForm queryForm) - { - return ((UserSchema)_baseSchema).urlFor(QueryAction.schema); - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public class ExecuteQueryAction extends QueryViewAction - { - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - _form = form; - - if (errors.hasErrors()) - return new SimpleErrorView(errors, true); - - QueryView queryView = Objects.requireNonNull(form.getQueryView()); - - var t = queryView.getTable(); - if (null != t && !t.allowRobotsIndex()) - { - getPageConfig().setRobotsNone(); - } - - if (isPrint()) - { - queryView.setPrintView(true); - getPageConfig().setTemplate(PageConfig.Template.Print); - getPageConfig().setShowPrintDialog(true); - } - - queryView.setShadeAlternatingRows(true); - queryView.setShowBorders(true); - setHelpTopic("customSQL"); - _queryView = queryView; - return queryView; - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - TableInfo ti = null; - try - { - if (null != _queryView) - ti = _queryView.getTable(); - } - catch (QueryParseException x) - { - /* */ - } - String display = ti == null ? _form.getQueryName() : ti.getTitle(); - root.addChild(display); - } - } - - - @RequiresPermission(AdminOperationsPermission.class) - public class RawTableMetaDataAction extends QueryViewAction - { - private String _dbSchemaName; - private String _dbTableName; - - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - _form = form; - - QueryView queryView = form.getQueryView(); - String userSchemaName = queryView.getSchema().getName(); - TableInfo ti = queryView.getTable(); - if (null == ti) - throw new NotFoundException(); - - DbScope scope = ti.getSchema().getScope(); - - // Test for provisioned table - if (ti.getDomain() != null) - { - Domain domain = ti.getDomain(); - if (domain.getStorageTableName() != null) - { - // Use the real table and schema names for getting the metadata - _dbTableName = domain.getStorageTableName(); - _dbSchemaName = domain.getDomainKind().getStorageSchemaName(); - } - } - - // No domain or domain with non-provisioned storage (e.g., core.Users) - if (null == _dbSchemaName || null == _dbTableName) - { - DbSchema dbSchema = ti.getSchema(); - _dbSchemaName = dbSchema.getName(); - - // Try to get the underlying schema table and use the meta data name, #12015 - if (ti instanceof FilteredTable fti) - ti = fti.getRealTable(); - - if (ti instanceof SchemaTableInfo) - _dbTableName = ti.getMetaDataIdentifier().getId(); - else if (ti instanceof LinkedTableInfo) - _dbTableName = ti.getName(); - - if (null == _dbTableName) - { - TableInfo tableInfo = dbSchema.getTable(ti.getName()); - if (null != tableInfo) - _dbTableName = tableInfo.getMetaDataIdentifier().getId(); - } - } - - if (null != _dbTableName) - { - VBox result = new VBox(); - - ActionURL url = null; - QuerySchema qs = DefaultSchema.get(getUser(), getContainer()).getSchema(userSchemaName); - if (qs != null) - { - url = new ActionURL(RawSchemaMetaDataAction.class, getContainer()); - url.addParameter("schemaName", userSchemaName); - } - - SqlDialect dialect = scope.getSqlDialect(); - ScopeView scopeInfo = new ScopeView("Scope and Schema Information", scope, _dbSchemaName, url, _dbTableName); - - result.addView(scopeInfo); - - try (JdbcMetaDataLocator locator = dialect.getTableResolver().getSingleTableLocator(scope, _dbSchemaName, _dbTableName)) - { - JdbcMetaDataSelector columnSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getColumns(l.getCatalogName(), l.getSchemaNamePattern(), l.getTableNamePattern(), null)); - result.addView(new ResultSetView(CachedResultSets.create(columnSelector.getResultSet(), true, Table.ALL_ROWS), "Table Meta Data")); - - JdbcMetaDataSelector pkSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getPrimaryKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); - result.addView(new ResultSetView(CachedResultSets.create(pkSelector.getResultSet(), true, Table.ALL_ROWS), "Primary Key Meta Data")); - - if (dialect.canCheckIndices(ti)) - { - JdbcMetaDataSelector indexSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getIndexInfo(l.getCatalogName(), l.getSchemaName(), l.getTableName(), false, false)); - result.addView(new ResultSetView(CachedResultSets.create(indexSelector.getResultSet(), true, Table.ALL_ROWS), "Other Index Meta Data")); - } - - JdbcMetaDataSelector ikSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getImportedKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); - result.addView(new ResultSetView(CachedResultSets.create(ikSelector.getResultSet(), true, Table.ALL_ROWS), "Imported Keys Meta Data")); - - JdbcMetaDataSelector ekSelector = new JdbcMetaDataSelector(locator, - (dbmd, l) -> dbmd.getExportedKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); - result.addView(new ResultSetView(CachedResultSets.create(ekSelector.getResultSet(), true, Table.ALL_ROWS), "Exported Keys Meta Data")); - } - return result; - } - else - { - errors.reject(ERROR_MSG, "Raw metadata not accessible for table " + ti.getName()); - return new SimpleErrorView(errors); - } - } - - @Override - public void addNavTrail(NavTree root) - { - (new SchemaAction(_form)).addNavTrail(root); - if (null != _dbTableName) - root.addChild("JDBC Meta Data For Table \"" + _dbSchemaName + "." + _dbTableName + "\""); - } - } - - - @RequiresPermission(AdminOperationsPermission.class) - public class RawSchemaMetaDataAction extends SimpleViewAction - { - private String _schemaName; - - @Override - public ModelAndView getView(Object form, BindException errors) throws Exception - { - _schemaName = getViewContext().getActionURL().getParameter("schemaName"); - if (null == _schemaName) - throw new NotFoundException(); - QuerySchema qs = DefaultSchema.get(getUser(), getContainer()).getSchema(_schemaName); - if (null == qs) - throw new NotFoundException(_schemaName); - DbSchema schema = qs.getDbSchema(); - String dbSchemaName = schema.getName(); - DbScope scope = schema.getScope(); - SqlDialect dialect = scope.getSqlDialect(); - - HttpView scopeInfo = new ScopeView("Scope Information", scope); - - ModelAndView tablesView; - - try (JdbcMetaDataLocator locator = dialect.getTableResolver().getAllTablesLocator(scope, dbSchemaName)) - { - JdbcMetaDataSelector selector = new JdbcMetaDataSelector(locator, - (dbmd, locator1) -> dbmd.getTables(locator1.getCatalogName(), locator1.getSchemaNamePattern(), locator1.getTableNamePattern(), null)); - Set tableNames = Sets.newCaseInsensitiveHashSet(qs.getTableNames()); - - ActionURL url = new ActionURL(RawTableMetaDataAction.class, getContainer()) - .addParameter("schemaName", _schemaName) - .addParameter("query.queryName", null); - tablesView = new ResultSetView(CachedResultSets.create(selector.getResultSet(), true, Table.ALL_ROWS), "Tables", "TABLE_NAME", url) - { - @Override - protected boolean shouldLink(ResultSet rs) throws SQLException - { - // Only link to tables and views (not indexes or sequences). And only if they're defined in the query schema. - String name = rs.getString("TABLE_NAME"); - String type = rs.getString("TABLE_TYPE"); - return ("TABLE".equalsIgnoreCase(type) || "VIEW".equalsIgnoreCase(type)) && tableNames.contains(name); - } - }; - } - - return new VBox(scopeInfo, tablesView); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("JDBC Meta Data For Schema \"" + _schemaName + "\""); - } - } - - - public static class ScopeView extends WebPartView - { - private final DbScope _scope; - private final String _schemaName; - private final String _tableName; - private final ActionURL _url; - - private ScopeView(String title, DbScope scope) - { - this(title, scope, null, null, null); - } - - private ScopeView(String title, DbScope scope, String schemaName, ActionURL url, String tableName) - { - super(title); - _scope = scope; - _schemaName = schemaName; - _tableName = tableName; - _url = url; - } - - @Override - protected void renderView(Object model, HtmlWriter out) - { - TABLE( - null != _schemaName ? getLabelAndContents("Schema", _url == null ? _schemaName : LinkBuilder.simpleLink(_schemaName, _url)) : null, - null != _tableName ? getLabelAndContents("Table", _tableName) : null, - getLabelAndContents("Scope", _scope.getDisplayName()), - getLabelAndContents("Dialect", _scope.getSqlDialect().getClass().getSimpleName()), - getLabelAndContents("URL", _scope.getDatabaseUrl()) - ).appendTo(out); - } - - // Return a single row (TR) with styled label and contents in separate TDs - private Renderable getLabelAndContents(String label, Object contents) - { - return TR( - TD( - cl("labkey-form-label"), - label - ), - TD( - contents - ) - ); - } - } - - // for backwards compat same as _executeQuery.view ?_print=1 - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public class PrintRowsAction extends ExecuteQueryAction - { - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - _print = true; - ModelAndView result = super.getView(form, errors); - String title = form.getQueryName(); - if (StringUtils.isEmpty(title)) - title = form.getSchemaName(); - getPageConfig().setTitle(title, true); - return result; - } - } - - - abstract static class _ExportQuery extends SimpleViewAction - { - @Override - public ModelAndView getView(K form, BindException errors) throws Exception - { - QueryView view = form.getQueryView(); - getPageConfig().setTemplate(PageConfig.Template.None); - HttpServletResponse response = getViewContext().getResponse(); - response.setHeader("X-Robots-Tag", "noindex"); - try - { - _export(form, view); - return null; - } - catch (QueryService.NamedParameterNotProvided | QueryParseException x) - { - ExceptionUtil.decorateException(x, ExceptionUtil.ExceptionInfo.SkipMothershipLogging, "true", true); - throw x; - } - } - - abstract void _export(K form, QueryView view) throws Exception; - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportScriptForm extends QueryForm - { - private String _type; - - public String getScriptType() - { - return _type; - } - - public void setScriptType(String type) - { - _type = type; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) // This is called "export" but it doesn't export any data - @CSRF(CSRF.Method.ALL) - public static class ExportScriptAction extends SimpleViewAction - { - @Override - public void validate(ExportScriptForm form, BindException errors) - { - // calling form.getQueryView() as a validation check as it will throw if schema/query missing - form.getQueryView(); - - if (StringUtils.isEmpty(form.getScriptType())) - throw new NotFoundException("Missing required parameter: scriptType."); - } - - @Override - public ModelAndView getView(ExportScriptForm form, BindException errors) - { - return ExportScriptModel.getExportScriptView(QueryView.create(form, errors), form.getScriptType(), getPageConfig(), getViewContext().getResponse()); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportRowsExcelAction extends _ExportQuery - { - @Override - void _export(ExportQueryForm form, QueryView view) throws Exception - { - view.exportToExcel(getViewContext().getResponse(), form.getHeaderType(), ExcelWriter.ExcelDocumentType.xls, form.getRenameColumnMap()); - } - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportRowsXLSXAction extends _ExportQuery - { - @Override - void _export(ExportQueryForm form, QueryView view) throws Exception - { - view.exportToExcel(getViewContext().getResponse(), form.getHeaderType(), ExcelWriter.ExcelDocumentType.xlsx, form.getRenameColumnMap()); - } - } - - public static class ExportQueriesForm extends ExportQueryForm implements ApiJsonForm - { - private String filename; - private List queryForms; - - public void setFilename(String filename) - { - this.filename = filename; - } - - public String getFilename() - { - return filename; - } - - public void setQueryForms(List queryForms) - { - this.queryForms = queryForms; - } - - public List getQueryForms() - { - return queryForms; - } - - /** - * Map JSON to Spring PropertyValue objects. - * @param json the properties - */ - private MutablePropertyValues getPropertyValues(JSONObject json) - { - // Collecting mapped properties as a list because adding them to an existing MutablePropertyValues object replaces existing values - List properties = new ArrayList<>(); - - for (String key : json.keySet()) - { - Object value = json.get(key); - if (value instanceof JSONArray val) - { - // Split arrays into individual pairs to be bound (Issue #45452) - for (int i = 0; i < val.length(); i++) - { - properties.add(new PropertyValue(key, val.get(i).toString())); - } - } - else - { - properties.add(new PropertyValue(key, value)); - } - } - - return new MutablePropertyValues(properties); - } - - @Override - public void bindJson(JSONObject json) - { - setFilename(json.get("filename").toString()); - List forms = new ArrayList<>(); - - JSONArray models = json.optJSONArray("queryForms"); - if (models == null) - { - QueryController.LOG.error("No models to export; Form's `queryForms` property was null"); - throw new RuntimeValidationException("No queries to export; Form's `queryForms` property was null"); - } - - for (JSONObject queryModel : JsonUtil.toJSONObjectList(models)) - { - ExportQueryForm qf = new ExportQueryForm(); - qf.setViewContext(getViewContext()); - - qf.bindParameters(getPropertyValues(queryModel)); - forms.add(qf); - } - - setQueryForms(forms); - } - } - - /** - * Export multiple query forms - */ - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportQueriesXLSXAction extends ReadOnlyApiAction - { - @Override - public Object execute(ExportQueriesForm form, BindException errors) throws Exception - { - getPageConfig().setTemplate(PageConfig.Template.None); - HttpServletResponse response = getViewContext().getResponse(); - response.setHeader("X-Robots-Tag", "noindex"); - ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment); - ViewContext viewContext = getViewContext(); - - Map> nameFormMap = new CaseInsensitiveHashMap<>(); - Map sheetNames = new HashMap<>(); - form.getQueryForms().forEach(qf -> { - String sheetName = qf.getSheetName(); - QueryView qv = qf.getQueryView(); - // use the given sheet name if provided, otherwise try the query definition name - String name = StringUtils.isNotBlank(sheetName) ? sheetName : qv.getQueryDef().getName(); - // if there is no sheet name or queryDefinition name, use a data region name if provided. Otherwise, use "Data" - name = StringUtils.isNotBlank(name) ? name : StringUtils.isNotBlank(qv.getDataRegionName()) ? qv.getDataRegionName() : "Data"; - // clean it to remove undesirable characters and make it of an acceptable length - name = ExcelWriter.cleanSheetName(name); - nameFormMap.computeIfAbsent(name, k -> new ArrayList<>()).add(qf); - }); - // Issue 53722: Need to assure unique names for the sheets in the presence of really long names - for (Map.Entry> entry : nameFormMap.entrySet()) { - String name = entry.getKey(); - if (entry.getValue().size() > 1) - { - List queryForms = entry.getValue(); - int countLength = String.valueOf(queryForms.size()).length() + 2; - if (countLength > name.length()) - throw new IllegalArgumentException("Cannot create sheet names from overlapping query names."); - for (int i = 0; i < queryForms.size(); i++) - { - sheetNames.put(entry.getValue().get(i), name.substring(0, name.length() - countLength) + "(" + i + ")"); - } - } - else - { - sheetNames.put(entry.getValue().get(0), name); - } - } - ExcelWriter writer = new ExcelWriter(ExcelWriter.ExcelDocumentType.xlsx) { - @Override - protected void renderSheets(Workbook workbook) - { - for (ExportQueryForm qf : form.getQueryForms()) - { - qf.setViewContext(viewContext); - qf.getSchema(); - - QueryView qv = qf.getQueryView(); - QueryView.ExcelExportConfig config = new QueryView.ExcelExportConfig(response, qf.getHeaderType()) - .setExcludeColumns(qf.getExcludeColumns()) - .setRenamedColumns(qf.getRenameColumnMap()); - qv.configureExcelWriter(this, config); - setSheetName(sheetNames.get(qf)); - setAutoSize(true); - renderNewSheet(workbook); - qv.logAuditEvent("Exported to Excel", getDataRowCount()); - } - - workbook.setActiveSheet(0); - } - }; - writer.setFilenamePrefix(form.getFilename()); - writer.renderWorkbook(response); - return null; //Returning anything here will cause error as excel writer will close the response stream - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class TemplateForm extends ExportQueryForm - { - boolean insertColumnsOnly = true; - String filenamePrefix; - FieldKey[] includeColumn; - String fileType; - - public TemplateForm() - { - _headerType = ColumnHeaderType.Caption; - } - - // "captionType" field backwards compatibility - public void setCaptionType(ColumnHeaderType headerType) - { - _headerType = headerType; - } - - public ColumnHeaderType getCaptionType() - { - return _headerType; - } - - public List getIncludeColumns() - { - if (includeColumn == null || includeColumn.length == 0) - return Collections.emptyList(); - return Arrays.asList(includeColumn); - } - - public FieldKey[] getIncludeColumn() - { - return includeColumn; - } - - public void setIncludeColumn(FieldKey[] includeColumn) - { - this.includeColumn = includeColumn; - } - - @NotNull - public String getFilenamePrefix() - { - return filenamePrefix == null ? getQueryName() : filenamePrefix; - } - - public void setFilenamePrefix(String prefix) - { - filenamePrefix = prefix; - } - - public String getFileType() - { - return fileType; - } - - public void setFileType(String fileType) - { - this.fileType = fileType; - } - } - - - /** - * Can be used to generate an Excel template for import into a table. Supported URL params include: - *
- *
filenamePrefix
- *
the prefix of the excel file that is generated, defaults to '_data'
- * - *
query.viewName
- *
if provided, the resulting excel file will use the fields present in this view. - * Non-usereditable columns will be skipped. - * Non-existent columns (like a lookup) unless includeMissingColumns is true. - * Any required columns missing from this view will be appended to the end of the query. - *
- * - *
includeColumn
- *
List of column names to include, even if the column doesn't exist or is non-userEditable. - * For example, this can be used to add a fake column that is only supported during the import process. - *
- * - *
excludeColumn
- *
List of column names to exclude. - *
- * - *
exportAlias.columns
- *
Use alternative column name in excel: exportAlias.originalColumnName=aliasColumnName - *
- * - *
captionType
- *
determines which column property is used in the header, either Label or Name
- *
- */ - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportExcelTemplateAction extends _ExportQuery - { - public ExportExcelTemplateAction() - { - setCommandClass(TemplateForm.class); - } - - @Override - void _export(TemplateForm form, QueryView view) throws Exception - { - boolean respectView = form.getViewName() != null; - ExcelWriter.ExcelDocumentType fileType = ExcelWriter.ExcelDocumentType.xlsx; - if (form.getFileType() != null) - { - try - { - fileType = ExcelWriter.ExcelDocumentType.valueOf(form.getFileType().toLowerCase()); - } - catch (IllegalArgumentException ignored) {} - } - view.exportToExcel( new QueryView.ExcelExportConfig(getViewContext().getResponse(), form.getHeaderType()) - .setTemplateOnly(true) - .setInsertColumnsOnly(form.insertColumnsOnly) - .setDocType(fileType) - .setRespectView(respectView) - .setIncludeColumns(form.getIncludeColumns()) - .setExcludeColumns(form.getExcludeColumns()) - .setRenamedColumns(form.getRenameColumnMap()) - .setPrefix((StringUtils.isEmpty(form.getFilenamePrefix()) ? "Import" : form.getFilenamePrefix()) + "_Template") // Issue 48028: Change template file names - ); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportQueryForm extends QueryForm - { - protected ColumnHeaderType _headerType = null; // QueryView will provide a default header type if the user doesn't select one - FieldKey[] excludeColumn; - Map renameColumns = null; - private String sheetName; - - public void setSheetName(String sheetName) - { - this.sheetName = sheetName; - } - - public String getSheetName() - { - return sheetName; - } - - public ColumnHeaderType getHeaderType() - { - return _headerType; - } - - public void setHeaderType(ColumnHeaderType headerType) - { - _headerType = headerType; - } - - public List getExcludeColumns() - { - if (excludeColumn == null || excludeColumn.length == 0) - return Collections.emptyList(); - return Arrays.asList(excludeColumn); - } - - public void setExcludeColumn(FieldKey[] excludeColumn) - { - this.excludeColumn = excludeColumn; - } - - public Map getRenameColumnMap() - { - if (renameColumns != null) - return renameColumns; - - renameColumns = new CaseInsensitiveHashMap<>(); - final String renameParamPrefix = "exportAlias."; - PropertyValue[] pvs = getInitParameters().getPropertyValues(); - for (PropertyValue pv : pvs) - { - String paramName = pv.getName(); - if (!paramName.startsWith(renameParamPrefix) || pv.getValue() == null) - continue; - - renameColumns.put(paramName.substring(renameParamPrefix.length()), (String) pv.getValue()); - } - - return renameColumns; - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportRowsTsvForm extends ExportQueryForm - { - private TSVWriter.DELIM _delim = TSVWriter.DELIM.TAB; - private TSVWriter.QUOTE _quote = TSVWriter.QUOTE.DOUBLE; - - public TSVWriter.DELIM getDelim() - { - return _delim; - } - - public void setDelim(TSVWriter.DELIM delim) - { - _delim = delim; - } - - public TSVWriter.QUOTE getQuote() - { - return _quote; - } - - public void setQuote(TSVWriter.QUOTE quote) - { - _quote = quote; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportRowsTsvAction extends _ExportQuery - { - public ExportRowsTsvAction() - { - setCommandClass(ExportRowsTsvForm.class); - } - - @Override - void _export(ExportRowsTsvForm form, QueryView view) throws Exception - { - view.exportToTsv(getViewContext().getResponse(), form.getDelim(), form.getQuote(), form.getHeaderType(), form.getRenameColumnMap()); - } - } - - - @RequiresNoPermission - @IgnoresTermsOfUse - @Action(ActionType.Export.class) - public static class ExcelWebQueryAction extends ExportRowsTsvAction - { - @Override - public ModelAndView getView(ExportRowsTsvForm form, BindException errors) throws Exception - { - if (!getContainer().hasPermission(getUser(), ReadPermission.class)) - { - if (!getUser().isGuest()) - { - throw new UnauthorizedException(); - } - getViewContext().getResponse().setHeader("WWW-Authenticate", "Basic realm=\"" + LookAndFeelProperties.getInstance(ContainerManager.getRoot()).getDescription() + "\""); - getViewContext().getResponse().setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return null; - } - - // Bug 5610. Excel web queries don't work over SSL if caching is disabled, - // so we need to allow caching so that Excel can read from IE on Windows. - HttpServletResponse response = getViewContext().getResponse(); - // Set the headers to allow the client to cache, but not proxies - ResponseHelper.setPrivate(response); - - QueryView view = form.getQueryView(); - getPageConfig().setTemplate(PageConfig.Template.None); - view.exportToExcelWebQuery(getViewContext().getResponse()); - return null; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExcelWebQueryDefinitionAction extends SimpleViewAction - { - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - getPageConfig().setTemplate(PageConfig.Template.None); - form.getQueryView(); - String queryViewActionURL = form.getQueryViewActionURL(); - ActionURL url; - if (queryViewActionURL != null) - { - url = new ActionURL(queryViewActionURL); - } - else - { - url = getViewContext().cloneActionURL(); - url.setAction(ExcelWebQueryAction.class); - } - getViewContext().getResponse().setContentType("text/x-ms-iqy"); - String filename = FileUtil.makeFileNameWithTimestamp(form.getQueryName(), "iqy"); - ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, filename); - PrintWriter writer = getViewContext().getResponse().getWriter(); - writer.println("WEB"); - writer.println("1"); - writer.println(url.getURIString()); - - QueryService.get().addAuditEvent(getUser(), getContainer(), form.getSchemaName(), form.getQueryName(), url, "Exported to Excel Web Query definition", null); - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - // Trusted analysts who are editors can create and modify queries - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - @Action(ActionType.SelectMetaData.class) - public class MetadataQueryAction extends SimpleViewAction - { - QueryForm _form = null; - - @Override - public ModelAndView getView(QueryForm queryForm, BindException errors) throws Exception - { - String schemaName = queryForm.getSchemaName(); - String queryName = queryForm.getQueryName(); - - _form = queryForm; - - if (schemaName.isEmpty() && (null == queryName || queryName.isEmpty())) - { - throw new NotFoundException("Must provide schemaName and queryName."); - } - - if (schemaName.isEmpty()) - { - throw new NotFoundException("Must provide schemaName."); - } - - if (null == queryName || queryName.isEmpty()) - { - throw new NotFoundException("Must provide queryName."); - } - - if (!queryForm.getQueryDef().isMetadataEditable()) - throw new UnauthorizedException("Query metadata is not editable"); - - if (!queryForm.canEditMetadata()) - throw new UnauthorizedException("You do not have permission to edit the query metadata"); - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("queryMetadataEditor")); - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - var metadataQuery = _form.getQueryDef().getName(); - if (null != metadataQuery) - root.addChild("Edit Metadata: " + _form.getQueryName(), metadataQuery); - else - root.addChild("Edit Metadata: " + _form.getQueryName()); - } - } - - // Uck. Supports the old and new view designer. - protected JSONObject saveCustomView(Container container, QueryDefinition queryDef, - String regionName, String viewName, boolean replaceExisting, - boolean share, boolean inherit, - boolean session, boolean saveFilter, - boolean hidden, JSONObject jsonView, - ActionURL returnUrl, - BindException errors) - { - User owner = getUser(); - boolean canSaveForAllUsers = container.hasPermission(getUser(), EditSharedViewPermission.class); - if (share && canSaveForAllUsers && !session) - { - owner = null; - } - String name = StringUtils.trimToNull(viewName); - - if (name != null && RESERVED_VIEW_NAMES.contains(name.toLowerCase())) - errors.reject(ERROR_MSG, "The grid view name '" + name + "' is not allowed."); - - boolean isHidden = hidden; - CustomView view; - if (owner == null) - view = queryDef.getSharedCustomView(name); - else - view = queryDef.getCustomView(owner, getViewContext().getRequest(), name); - - if (view != null && !replaceExisting && !StringUtils.isEmpty(name)) - errors.reject(ERROR_MSG, "A saved view by the name \"" + viewName + "\" already exists. "); - - // 11179: Allow editing the view if we're saving to session. - // NOTE: Check for session flag first otherwise the call to canEdit() will add errors to the errors collection. - boolean canEdit = view == null || session || view.canEdit(container, errors); - if (errors.hasErrors()) - return null; - - if (canEdit) - { - // Issue 13594: Disallow setting of the customview inherit bit for query views - // that have no available container filter types. Unfortunately, the only way - // to get the container filters is from the QueryView. Ideally, the query def - // would know if it was container filterable or not instead of using the QueryView. - if (inherit && canSaveForAllUsers && !session) - { - UserSchema schema = queryDef.getSchema(); - QueryView queryView = schema.createView(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, queryDef.getName(), errors); - if (queryView != null) - { - Set allowableContainerFilterTypes = queryView.getAllowableContainerFilterTypes(); - if (allowableContainerFilterTypes.size() <= 1) - { - errors.reject(ERROR_MSG, "QueryView doesn't support inherited custom views"); - return null; - } - } - } - - // Create a new view if none exists or the current view is a shared view - // and the user wants to override the shared view with a personal view. - if (view == null || (owner != null && view.isShared())) - { - if (owner == null) - view = queryDef.createSharedCustomView(name); - else - view = queryDef.createCustomView(owner, name); - - if (owner != null && session) - ((CustomViewImpl) view).isSession(true); - view.setIsHidden(hidden); - } - else if (session != view.isSession()) - { - if (session) - { - assert !view.isSession(); - if (owner == null) - { - errors.reject(ERROR_MSG, "Session views can't be saved for all users"); - return null; - } - - // The form is saving to session but the view is in the database. - // Make a copy in case it's a read-only version from an XML file - view = queryDef.createCustomView(owner, name); - ((CustomViewImpl) view).isSession(true); - } - else - { - // Remove the session view and call saveCustomView again to either create a new view or update an existing view. - assert view.isSession(); - boolean success = false; - try - { - view.delete(getUser(), getViewContext().getRequest()); - JSONObject ret = saveCustomView(container, queryDef, regionName, viewName, replaceExisting, share, inherit, session, saveFilter, hidden, jsonView, returnUrl, errors); - success = !errors.hasErrors() && ret != null; - return success ? ret : null; - } - finally - { - if (!success) - { - // dirty the view then save the deleted session view back in session state - view.setName(view.getName()); - view.save(getUser(), getViewContext().getRequest()); - } - } - } - } - - // NOTE: Updating, saving, and deleting the view may throw an exception - CustomViewImpl cview = null; - if (view instanceof EditableCustomView && view.isOverridable()) - { - cview = ((EditableCustomView)view).getEditableViewInfo(owner, session); - } - if (null == cview) - { - throw new IllegalArgumentException("View cannot be edited"); - } - - cview.update(jsonView, saveFilter); - if (canSaveForAllUsers && !session) - { - cview.setCanInherit(inherit); - } - isHidden = view.isHidden(); - cview.setContainer(container); - cview.save(getUser(), getViewContext().getRequest()); - if (owner == null) - { - // New view is shared so delete any previous custom view owned by the user with the same name. - CustomView personalView = queryDef.getCustomView(getUser(), getViewContext().getRequest(), name); - if (personalView != null && !personalView.isShared()) - { - personalView.delete(getUser(), getViewContext().getRequest()); - } - } - } - - if (null == returnUrl) - { - returnUrl = getViewContext().cloneActionURL().setAction(ExecuteQueryAction.class); - } - else - { - returnUrl = returnUrl.clone(); - if (name == null || !canEdit) - { - returnUrl.deleteParameter(regionName + "." + QueryParam.viewName); - } - else if (!isHidden) - { - returnUrl.replaceParameter(regionName + "." + QueryParam.viewName, name); - } - returnUrl.deleteParameter(regionName + "." + QueryParam.ignoreFilter); - if (saveFilter) - { - for (String key : returnUrl.getKeysByPrefix(regionName + ".")) - { - if (isFilterOrSort(regionName, key)) - returnUrl.deleteFilterParameters(key); - } - } - } - - JSONObject ret = new JSONObject(); - ret.put("redirect", returnUrl); - Map viewAsMap = CustomViewUtil.toMap(view, getUser(), true); - try - { - ret.put("view", new JSONObject(viewAsMap, new JSONParserConfiguration().withMaxNestingDepth(10))); - } - catch (JSONException e) - { - LOG.error("Failed to save view: {}", jsonView, e); - } - return ret; - } - - private boolean isFilterOrSort(String dataRegionName, String param) - { - assert param.startsWith(dataRegionName + "."); - String check = param.substring(dataRegionName.length() + 1); - if (check.contains("~")) - return true; - if ("sort".equals(check)) - return true; - if (check.equals("containerFilterName")) - return true; - return false; - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Configure.class) - @JsonInputLimit(100_000) - public class SaveQueryViewsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(SimpleApiJsonForm form, BindException errors) - { - JSONObject json = form.getJsonObject(); - if (json == null) - throw new NotFoundException("Empty request"); - - String schemaName = json.optString(QueryParam.schemaName.toString(), null); - String queryName = json.optString(QueryParam.queryName.toString(), null); - if (schemaName == null || queryName == null) - throw new NotFoundException("schemaName and queryName are required"); - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); - if (schema == null) - throw new NotFoundException("schema not found"); - - QueryDefinition queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), schemaName, queryName); - if (queryDef == null) - queryDef = schema.getQueryDefForTable(queryName); - if (queryDef == null) - throw new NotFoundException("query not found"); - - JSONObject response = new JSONObject(); - response.put(QueryParam.schemaName.toString(), schemaName); - response.put(QueryParam.queryName.toString(), queryName); - JSONArray views = new JSONArray(); - response.put("views", views); - - ActionURL redirect = null; - JSONArray jsonViews = json.getJSONArray("views"); - for (int i = 0; i < jsonViews.length(); i++) - { - final JSONObject jsonView = jsonViews.getJSONObject(i); - String viewName = jsonView.optString("name", null); - if (viewName == null) - throw new NotFoundException("'name' is required all views'"); - - boolean shared = jsonView.optBoolean("shared", false); - boolean replace = jsonView.optBoolean("replace", true); // "replace" was the default before the flag is introduced - boolean inherit = jsonView.optBoolean("inherit", false); - boolean session = jsonView.optBoolean("session", false); - boolean hidden = jsonView.optBoolean("hidden", false); - // Users may save views to a location other than the current container - String containerPath = jsonView.optString("containerPath", getContainer().getPath()); - Container container; - if (inherit) - { - // Only respect this request if it's a view that is inheritable in subfolders - container = ContainerManager.getForPath(containerPath); - } - else - { - // Otherwise, save it in the current container - container = getContainer().getContainerFor(ContainerType.DataType.customQueryViews); - } - - if (container == null) - { - throw new NotFoundException("No such container: " + containerPath); - } - - JSONObject savedView = saveCustomView( - container, queryDef, QueryView.DATAREGIONNAME_DEFAULT, viewName, replace, - shared, inherit, session, true, hidden, jsonView, null, errors); - - if (savedView != null) - { - if (redirect == null) - redirect = (ActionURL)savedView.get("redirect"); - views.put(savedView.getJSONObject("view")); - } - } - - if (redirect != null) - response.put("redirect", redirect); - - if (errors.hasErrors()) - return null; - else - return new ApiSimpleResponse(response); - } - } - - public static class RenameQueryViewForm extends QueryForm - { - private String newName; - - public String getNewName() - { - return newName; - } - - public void setNewName(String newName) - { - this.newName = newName; - } - } - - @RequiresPermission(ReadPermission.class) - public class RenameQueryViewAction extends MutatingApiAction - { - @Override - public ApiResponse execute(RenameQueryViewForm form, BindException errors) - { - CustomView view = form.getCustomView(); - if (view == null) - { - throw new NotFoundException(); - } - - Container container = getContainer(); - User user = getUser(); - - String schemaName = form.getSchemaName(); - String queryName = form.getQueryName(); - if (schemaName == null || queryName == null) - throw new NotFoundException("schemaName and queryName are required"); - - UserSchema schema = QueryService.get().getUserSchema(user, container, schemaName); - if (schema == null) - throw new NotFoundException("schema not found"); - - QueryDefinition queryDef = QueryService.get().getQueryDef(user, container, schemaName, queryName); - if (queryDef == null) - queryDef = schema.getQueryDefForTable(queryName); - if (queryDef == null) - throw new NotFoundException("query not found"); - - renameCustomView(container, queryDef, view, form.getNewName(), errors); - - if (errors.hasErrors()) - return null; - else - return new ApiSimpleResponse("success", true); - } - } - - protected void renameCustomView(Container container, QueryDefinition queryDef, CustomView fromView, String newViewName, BindException errors) - { - if (newViewName != null && RESERVED_VIEW_NAMES.contains(newViewName.toLowerCase())) - errors.reject(ERROR_MSG, "The grid view name '" + newViewName + "' is not allowed."); - - String newName = StringUtils.trimToNull(newViewName); - if (StringUtils.isEmpty(newName)) - errors.reject(ERROR_MSG, "View name cannot be blank."); - - if (errors.hasErrors()) - return; - - User owner = getUser(); - boolean canSaveForAllUsers = container.hasPermission(getUser(), EditSharedViewPermission.class); - - if (!fromView.canEdit(container, errors)) - return; - - if (fromView.isSession()) - { - errors.reject(ERROR_MSG, "Cannot rename a session view."); - return; - } - - CustomView duplicateView = queryDef.getCustomView(owner, getViewContext().getRequest(), newName); - if (duplicateView == null && canSaveForAllUsers) - duplicateView = queryDef.getSharedCustomView(newName); - if (duplicateView != null) - { - // only allow duplicate view name if creating a new private view to shadow an existing shared view - if (!(!fromView.isShared() && duplicateView.isShared())) - { - errors.reject(ERROR_MSG, "Another saved view by the name \"" + newName + "\" already exists. "); - return; - } - } - - fromView.setName(newViewName); - fromView.save(getUser(), getViewContext().getRequest()); - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Configure.class) - public class PropertiesQueryAction extends FormViewAction - { - PropertiesForm _form = null; - private String _queryName; - - @Override - public void validateCommand(PropertiesForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(PropertiesForm form, boolean reshow, BindException errors) - { - // assertQueryExists requires that it be well-formed - // assertQueryExists(form); - QueryDefinition queryDef = form.getQueryDef(); - _form = form; - _form.setDescription(queryDef.getDescription()); - _form.setInheritable(queryDef.canInherit()); - _form.setHidden(queryDef.isHidden()); - setHelpTopic("editQueryProperties"); - _queryName = form.getQueryName(); - - return new JspView<>("/org/labkey/query/view/propertiesQuery.jsp", form, errors); - } - - @Override - public boolean handlePost(PropertiesForm form, BindException errors) throws Exception - { - // assertQueryExists requires that it be well-formed - // assertQueryExists(form); - if (!form.canEdit()) - { - throw new UnauthorizedException(); - } - QueryDefinition queryDef = form.getQueryDef(); - _queryName = form.getQueryName(); - if (!queryDef.getDefinitionContainer().getId().equals(getContainer().getId())) - throw new NotFoundException("Query not found"); - - _form = form; - - if (!StringUtils.isEmpty(form.rename) && !form.rename.equalsIgnoreCase(queryDef.getName())) - { - // issue 17766: check if query or table exist with this name - if (null != QueryManager.get().getQueryDef(getContainer(), form.getSchemaName(), form.rename, true) - || null != form.getSchema().getTable(form.rename,null)) - { - errors.reject(ERROR_MSG, "A query or table with the name \"" + form.rename + "\" already exists."); - return false; - } - - // Issue 40895: update queryName in xml metadata - updateXmlMetadata(queryDef); - queryDef.setName(form.rename); - // update form so getSuccessURL() works - _form = new PropertiesForm(form.getSchemaName(), form.rename); - _form.setViewContext(form.getViewContext()); - _queryName = form.rename; - } - - queryDef.setDescription(form.description); - queryDef.setCanInherit(form.inheritable); - queryDef.setIsHidden(form.hidden); - queryDef.save(getUser(), getContainer()); - return true; - } - - private void updateXmlMetadata(QueryDefinition queryDef) throws XmlException - { - if (null != queryDef.getMetadataXml()) - { - TablesDocument doc = TablesDocument.Factory.parse(queryDef.getMetadataXml()); - if (null != doc) - { - for (TableType tableType : doc.getTables().getTableArray()) - { - if (tableType.getTableName().equalsIgnoreCase(queryDef.getName())) - { - // update tableName in xml - tableType.setTableName(_form.rename); - } - } - XmlOptions xmlOptions = new XmlOptions(); - xmlOptions.setSavePrettyPrint(); - // Don't use an explicit namespace, making the XML much more readable - xmlOptions.setUseDefaultNamespace(); - queryDef.setMetadataXml(doc.xmlText(xmlOptions)); - } - } - } - - @Override - public ActionURL getSuccessURL(PropertiesForm propertiesForm) - { - ActionURL url = new ActionURL(BeginAction.class, propertiesForm.getViewContext().getContainer()); - url.addParameter("schemaName", propertiesForm.getSchemaName()); - if (null != _queryName) - url.addParameter("queryName", _queryName); - return url; - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - root.addChild("Edit query properties"); - } - } - - @ActionNames("truncateTable") - @RequiresPermission(AdminPermission.class) - public static class TruncateTableAction extends MutatingApiAction - { - UserSchema schema; - TableInfo table; - - @Override - public void validateForm(QueryForm form, Errors errors) - { - String schemaName = form.getSchemaName(); - String queryName = form.getQueryName(); - - if (isBlank(schemaName) || isBlank(queryName)) - throw new NotFoundException("schemaName and queryName are required"); - - schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); - if (null == schema) - throw new NotFoundException("The schema '" + schemaName + "' does not exist."); - - table = schema.getTable(queryName, null); - if (null == table) - throw new NotFoundException("The query '" + queryName + "' in the schema '" + schemaName + "' does not exist."); - } - - @Override - public ApiResponse execute(QueryForm form, BindException errors) throws Exception - { - int deletedRows; - QueryUpdateService qus = table.getUpdateService(); - - if (null == qus) - throw new IllegalArgumentException("The query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "' is not truncatable."); - - try (DbScope.Transaction transaction = table.getSchema().getScope().ensureTransaction()) - { - deletedRows = qus.truncateRows(getUser(), getContainer(), null, null); - transaction.commit(); - } - - ApiSimpleResponse response = new ApiSimpleResponse(); - - response.put("success", true); - response.put(BaseSaveRowsAction.PROP_SCHEMA_NAME, form.getSchemaName()); - response.put(BaseSaveRowsAction.PROP_QUERY_NAME, form.getQueryName()); - response.put("deletedRows", deletedRows); - - return response; - } - } - - - @RequiresPermission(DeletePermission.class) - public static class DeleteQueryRowsAction extends FormHandlerAction - { - @Override - public void validateCommand(QueryForm target, Errors errors) - { - } - - @Override - public boolean handlePost(QueryForm form, BindException errors) - { - TableInfo table = form.getQueryView().getTable(); - - if (!table.hasPermission(getUser(), DeletePermission.class)) - { - throw new UnauthorizedException(); - } - - QueryUpdateService updateService = table.getUpdateService(); - if (updateService == null) - throw new UnsupportedOperationException("Unable to delete - no QueryUpdateService registered for " + form.getSchemaName() + "." + form.getQueryName()); - - Set ids = DataRegionSelection.getSelected(form.getViewContext(), null, true); - List pks = table.getPkColumns(); - int numPks = pks.size(); - - //normalize the pks to arrays of correctly-typed objects - List> keyValues = new ArrayList<>(ids.size()); - for (String id : ids) - { - String[] stringValues; - if (numPks > 1) - { - stringValues = id.split(","); - if (stringValues.length != numPks) - throw new IllegalStateException("This table has " + numPks + " primary-key columns, but " + stringValues.length + " primary-key values were provided!"); - } - else - stringValues = new String[]{id}; - - Map rowKeyValues = new CaseInsensitiveHashMap<>(); - for (int idx = 0; idx < numPks; ++idx) - { - ColumnInfo keyColumn = pks.get(idx); - Object keyValue = keyColumn.getJavaClass() == String.class ? stringValues[idx] : ConvertUtils.convert(stringValues[idx], keyColumn.getJavaClass()); - rowKeyValues.put(keyColumn.getName(), keyValue); - } - keyValues.add(rowKeyValues); - } - - DbSchema dbSchema = table.getSchema(); - try - { - dbSchema.getScope().executeWithRetry(tx -> - { - try - { - updateService.deleteRows(getUser(), getContainer(), keyValues, null, null); - } - catch (SQLException x) - { - if (!RuntimeSQLException.isConstraintException(x)) - throw new RuntimeSQLException(x); - errors.reject(ERROR_MSG, getMessage(table.getSchema().getSqlDialect(), x)); - } - catch (DataIntegrityViolationException | OptimisticConflictException e) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - catch (BatchValidationException x) - { - x.addToErrors(errors); - } - catch (Exception x) - { - errors.reject(ERROR_MSG, null == x.getMessage() ? x.toString() : x.getMessage()); - ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), x); - } - // need to throw here to avoid committing tx - if (errors.hasErrors()) - throw new DbScope.RetryPassthroughException(errors); - return true; - }); - } - catch (DbScope.RetryPassthroughException x) - { - if (x.getCause() != errors) - x.throwRuntimeException(); - } - return !errors.hasErrors(); - } - - @Override - public ActionURL getSuccessURL(QueryForm form) - { - return form.getReturnActionURL(); - } - } - - @RequiresPermission(ReadPermission.class) - public static class DetailsQueryRowAction extends UserSchemaAction - { - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - - if (_schema != null && _table != null) - { - if (_table.hasPermission(getUser(), UpdatePermission.class)) - { - StringExpression updateExpr = _form.getQueryDef().urlExpr(QueryAction.updateQueryRow, _schema.getContainer()); - if (updateExpr != null) - { - String url = updateExpr.eval(tableForm.getTypedValues()); - if (url != null) - { - ActionURL updateUrl = new ActionURL(url); - ActionButton editButton = new ActionButton("Edit", updateUrl); - bb.add(editButton); - } - } - } - - - ActionURL gridUrl; - if (_form.getReturnActionURL() != null) - { - // If we have a specific return URL requested, use that - gridUrl = _form.getReturnActionURL(); - } - else - { - // Otherwise go back to the default grid view - gridUrl = _schema.urlFor(QueryAction.executeQuery, _form.getQueryDef()); - } - if (gridUrl != null) - { - ActionButton gridButton = new ActionButton("Show Grid", gridUrl); - bb.add(gridButton); - } - } - - DetailsView detailsView = new DetailsView(tableForm); - detailsView.setFrame(WebPartView.FrameType.PORTAL); - detailsView.getDataRegion().setButtonBar(bb); - - VBox view = new VBox(detailsView); - - DetailsURL detailsURL = QueryService.get().getAuditDetailsURL(getUser(), getContainer(), _table); - - if (detailsURL != null) - { - String url = detailsURL.eval(tableForm.getTypedValues()); - if (url != null) - { - ActionURL auditURL = new ActionURL(url); - - QueryView historyView = QueryUpdateAuditProvider.createDetailsQueryView(getViewContext(), - auditURL.getParameter(QueryParam.schemaName), - auditURL.getParameter(QueryParam.queryName), - auditURL.getParameter("keyValue"), errors); - - if (null != historyView) - { - historyView.setFrame(WebPartView.FrameType.PORTAL); - historyView.setTitle("History"); - - view.addView(historyView); - } - } - } - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) - { - return false; - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Details"); - } - } - - @RequiresPermission(InsertPermission.class) - public static class InsertQueryRowAction extends UserSchemaAction - { - @Override - public BindException bindParameters(PropertyValues m) throws Exception - { - BindException bind = super.bindParameters(m); - - // what is going on with UserSchemaAction and form binding? Why doesn't successUrl bind? - QueryUpdateForm form = (QueryUpdateForm)bind.getTarget(); - if (null == form.getSuccessUrl() && null != m.getPropertyValue(ActionURL.Param.successUrl.name())) - form.setSuccessUrl(new ReturnURLString(m.getPropertyValue(ActionURL.Param.successUrl.name()).getValue().toString())); - return bind; - } - - Map insertedRow = null; - - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Insert Row"); - - InsertView view = new InsertView(tableForm, errors); - view.getDataRegion().setButtonBar(createSubmitCancelButtonBar(tableForm)); - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) - { - List> list = doInsertUpdate(tableForm, errors, true); - if (null != list && list.size() == 1) - insertedRow = list.get(0); - return 0 == errors.getErrorCount(); - } - - /** - * NOTE: UserSchemaAction.addNavTrail() uses this method getSuccessURL() for the nav trail link (form==null). - * It is used for where to go on success, and also as a "back" link in the nav trail - * If there is a setSuccessUrl specified, we will use that for successful submit - */ - @Override - public ActionURL getSuccessURL(QueryUpdateForm form) - { - if (null == form) - return super.getSuccessURL(null); - - String str = null; - if (form.getSuccessUrl() != null) - str = form.getSuccessUrl().toString(); - if (isBlank(str)) - str = form.getReturnUrl(); - - if ("details.view".equals(str)) - { - if (null == insertedRow) - return super.getSuccessURL(form); - StringExpression se = form.getTable().getDetailsURL(null, getContainer()); - if (null == se) - return super.getSuccessURL(form); - str = se.eval(insertedRow); - } - try - { - if (!isBlank(str)) - return new ActionURL(str); - } - catch (IllegalArgumentException x) - { - // pass - } - return super.getSuccessURL(form); - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Insert " + _table.getName()); - } - } - - @RequiresPermission(UpdatePermission.class) - public static class UpdateQueryRowAction extends UserSchemaAction - { - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - ButtonBar bb = createSubmitCancelButtonBar(tableForm); - UpdateView view = new UpdateView(tableForm, errors); - view.getDataRegion().setButtonBar(bb); - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) throws Exception - { - doInsertUpdate(tableForm, errors, false); - return 0 == errors.getErrorCount(); - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Edit " + _table.getName()); - } - } - - @RequiresPermission(UpdatePermission.class) - public static class UpdateQueryRowsAction extends UpdateQueryRowAction - { - @Override - public ModelAndView handleRequest(QueryUpdateForm tableForm, BindException errors) throws Exception - { - tableForm.setBulkUpdate(true); - return super.handleRequest(tableForm, errors); - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) throws Exception - { - boolean ret; - - if (tableForm.isDataSubmit()) - { - ret = super.handlePost(tableForm, errors); - if (ret) - DataRegionSelection.clearAll(getViewContext(), null); // in case we altered primary keys, see issue #35055 - return ret; - } - - return false; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Edit Multiple " + _table.getName()); - } - } - - // alias - public static class DeleteAction extends DeleteQueryRowsAction - { - } - - public abstract static class QueryViewAction extends SimpleViewAction - { - QueryForm _form; - QueryView _queryView; - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class APIQueryForm extends ContainerFilterQueryForm - { - private Integer _start; - private Integer _limit; - private boolean _includeDetailsColumn = false; - private boolean _includeUpdateColumn = false; - private boolean _includeTotalCount = true; - private boolean _includeStyle = false; - private boolean _includeDisplayValues = false; - private boolean _minimalColumns = true; - private boolean _includeMetadata = true; - - public Integer getStart() - { - return _start; - } - - public void setStart(Integer start) - { - _start = start; - } - - public Integer getLimit() - { - return _limit; - } - - public void setLimit(Integer limit) - { - _limit = limit; - } - - public boolean isIncludeTotalCount() - { - return _includeTotalCount; - } - - public void setIncludeTotalCount(boolean includeTotalCount) - { - _includeTotalCount = includeTotalCount; - } - - public boolean isIncludeStyle() - { - return _includeStyle; - } - - public void setIncludeStyle(boolean includeStyle) - { - _includeStyle = includeStyle; - } - - public boolean isIncludeDetailsColumn() - { - return _includeDetailsColumn; - } - - public void setIncludeDetailsColumn(boolean includeDetailsColumn) - { - _includeDetailsColumn = includeDetailsColumn; - } - - public boolean isIncludeUpdateColumn() - { - return _includeUpdateColumn; - } - - public void setIncludeUpdateColumn(boolean includeUpdateColumn) - { - _includeUpdateColumn = includeUpdateColumn; - } - - public boolean isIncludeDisplayValues() - { - return _includeDisplayValues; - } - - public void setIncludeDisplayValues(boolean includeDisplayValues) - { - _includeDisplayValues = includeDisplayValues; - } - - public boolean isMinimalColumns() - { - return _minimalColumns; - } - - public void setMinimalColumns(boolean minimalColumns) - { - _minimalColumns = minimalColumns; - } - - public boolean isIncludeMetadata() - { - return _includeMetadata; - } - - public void setIncludeMetadata(boolean includeMetadata) - { - _includeMetadata = includeMetadata; - } - - @Override - protected QuerySettings createQuerySettings(UserSchema schema) - { - QuerySettings results = super.createQuerySettings(schema); - - // See dataintegration/202: The java client api / remote ETL calls selectRows with showRows=all. We need to test _initParameters to properly read this - boolean missingShowRows = null == getViewContext().getRequest().getParameter(getDataRegionName() + "." + QueryParam.showRows) && null == _initParameters.getPropertyValue(getDataRegionName() + "." + QueryParam.showRows); - if (null == getLimit() && !results.isMaxRowsSet() && missingShowRows) - { - results.setShowRows(ShowRows.PAGINATED); - results.setMaxRows(DEFAULT_API_MAX_ROWS); - } - - if (getLimit() != null) - { - results.setShowRows(ShowRows.PAGINATED); - results.setMaxRows(getLimit()); - } - if (getStart() != null) - results.setOffset(getStart()); - - return results; - } - } - - public static final int DEFAULT_API_MAX_ROWS = 100000; - - @CSRF(CSRF.Method.NONE) // No need for CSRF token --- this is a non-mutating action that supports POST to allow for large payloads, see #36056 - @ActionNames("selectRows, getQuery") - @RequiresPermission(ReadPermission.class) - @ApiVersion(9.1) - @Action(ActionType.SelectData.class) - public class SelectRowsAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(APIQueryForm form, BindException errors) - { - // Issue 12233: add implicit maxRows=100k when using client API - QueryView view = form.getQueryView(); - - view.setShowPagination(form.isIncludeTotalCount()); - - //if viewName was specified, ensure that it was actually found and used - //QueryView.create() will happily ignore an invalid view name and just return the default view - if (null != StringUtils.trimToNull(form.getViewName()) && - null == view.getQueryDef().getCustomView(getUser(), getViewContext().getRequest(), form.getViewName())) - { - throw new NotFoundException("The requested view '" + form.getViewName() + "' does not exist for this user."); - } - - TableInfo t = view.getTable(); - if (null == t) - { - List qpes = view.getParseErrors(); - if (!qpes.isEmpty()) - throw qpes.get(0); - throw new NotFoundException(form.getQueryName()); - } - - boolean isEditable = isQueryEditable(view.getTable()); - boolean metaDataOnly = form.getQuerySettings().getMaxRows() == 0; - boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; - boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; - - ApiQueryResponse response; - - // 13.2 introduced the getData API action, a condensed response wire format, and a js wrapper to consume the wire format. Support this as an option for legacy APIs. - if (getRequestedApiVersion() >= 13.2) - { - ReportingApiQueryResponse fancyResponse = new ReportingApiQueryResponse(view, isEditable, true, view.getQueryDef().getName(), form.getQuerySettings().getOffset(), null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); - fancyResponse.arrayMultiValueColumns(arrayMultiValueColumns); - fancyResponse.includeFormattedValue(includeFormattedValue); - response = fancyResponse; - } - //if requested version is >= 9.1, use the extended api query response - else if (getRequestedApiVersion() >= 9.1) - { - response = new ExtendedApiQueryResponse(view, isEditable, true, - form.getSchemaName(), form.getQueryName(), form.getQuerySettings().getOffset(), null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); - } - else - { - response = new ApiQueryResponse(view, isEditable, true, - form.getSchemaName(), form.getQueryName(), form.getQuerySettings().getOffset(), null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), - form.isIncludeDisplayValues(), form.isIncludeMetadata()); - } - response.includeStyle(form.isIncludeStyle()); - - // Issues 29515 and 32269 - force key and other non-requested columns to be sent back, but only if the client has - // requested minimal columns, as we now do for ExtJS stores - if (form.isMinimalColumns()) - { - // Be sure to use the settings from the view, as it may have swapped it out with a customized version. - // See issue 38747. - response.setColumnFilter(view.getSettings().getFieldKeys()); - } - - return response; - } - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public static class GetDataAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws Exception - { - ObjectMapper mapper = JsonUtil.createDefaultMapper(); - mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - JSONObject object = form.getJsonObject(); - if (object == null) - { - object = new JSONObject(); - } - DataRequest builder = mapper.readValue(object.toString(), DataRequest.class); - - return builder.render(getViewContext(), errors); - } - } - - protected boolean isQueryEditable(TableInfo table) - { - if (!getContainer().hasPermission("isQueryEditable", getUser(), DeletePermission.class)) - return false; - QueryUpdateService updateService = null; - try - { - updateService = table.getUpdateService(); - } - catch(Exception ignore) {} - return null != table && null != updateService; - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExecuteSqlForm extends APIQueryForm - { - private String _sql; - private Integer _maxRows; - private Integer _offset; - private boolean _saveInSession; - - public String getSql() - { - return _sql; - } - - public void setSql(String sql) - { - _sql = PageFlowUtil.wafDecode(StringUtils.trim(sql)); - } - - public Integer getMaxRows() - { - return _maxRows; - } - - public void setMaxRows(Integer maxRows) - { - _maxRows = maxRows; - } - - public Integer getOffset() - { - return _offset; - } - - public void setOffset(Integer offset) - { - _offset = offset; - } - - @Override - public void setLimit(Integer limit) - { - _maxRows = limit; - } - - @Override - public void setStart(Integer start) - { - _offset = start; - } - - public boolean isSaveInSession() - { - return _saveInSession; - } - - public void setSaveInSession(boolean saveInSession) - { - _saveInSession = saveInSession; - } - - @Override - public String getQueryName() - { - // ExecuteSqlAction doesn't allow setting query name parameter. - return null; - } - - @Override - public void setQueryName(String name) - { - // ExecuteSqlAction doesn't allow setting query name parameter. - } - } - - @CSRF(CSRF.Method.NONE) // No need for CSRF token --- this is a non-mutating action that supports POST to allow for large payloads, see #36056 - @RequiresPermission(ReadPermission.class) - @ApiVersion(9.1) - @Action(ActionType.SelectData.class) - public class ExecuteSqlAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(ExecuteSqlForm form, BindException errors) - { - form.ensureSchemaExists(); - - String schemaName = StringUtils.trimToNull(form.getQuerySettings().getSchemaName()); - if (null == schemaName) - throw new IllegalArgumentException("No value was supplied for the required parameter 'schemaName'."); - String sql = form.getSql(); - if (StringUtils.isBlank(sql)) - throw new IllegalArgumentException("No value was supplied for the required parameter 'sql'."); - - //create a temp query settings object initialized with the posted LabKey SQL - //this will provide a temporary QueryDefinition to Query - QuerySettings settings = form.getQuerySettings(); - if (form.isSaveInSession()) - { - HttpSession session = getViewContext().getSession(); - if (session == null) - throw new IllegalStateException("Session required"); - - QueryDefinition def = QueryService.get().saveSessionQuery(getViewContext(), getContainer(), schemaName, sql); - settings.setDataRegionName("executeSql"); - settings.setQueryName(def.getName()); - } - else - { - settings = new TempQuerySettings(getViewContext(), sql, settings); - } - - //need to explicitly turn off various UI options that will try to refer to the - //current URL and query string - settings.setAllowChooseView(false); - settings.setAllowCustomizeView(false); - - // Issue 12233: add implicit maxRows=100k when using client API - settings.setShowRows(ShowRows.PAGINATED); - settings.setMaxRows(DEFAULT_API_MAX_ROWS); - - // 16961: ExecuteSql API without maxRows parameter defaults to returning 100 rows - //apply optional settings (maxRows, offset) - boolean metaDataOnly = false; - if (null != form.getMaxRows() && (form.getMaxRows() >= 0 || form.getMaxRows() == Table.ALL_ROWS)) - { - settings.setMaxRows(form.getMaxRows()); - metaDataOnly = Table.NO_ROWS == form.getMaxRows(); - } - - int offset = 0; - if (null != form.getOffset()) - { - settings.setOffset(form.getOffset().longValue()); - offset = form.getOffset(); - } - - //build a query view using the schema and settings - QueryView view = new QueryView(form.getSchema(), settings, errors); - view.setShowRecordSelectors(false); - view.setShowExportButtons(false); - view.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - view.setShowPagination(form.isIncludeTotalCount()); - - TableInfo t = view.getTable(); - boolean isEditable = null != t && isQueryEditable(view.getTable()); - boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; - boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; - - ApiQueryResponse response; - - // 13.2 introduced the getData API action, a condensed response wire format, and a js wrapper to consume the wire format. Support this as an option for legacy APIs. - if (getRequestedApiVersion() >= 13.2) - { - ReportingApiQueryResponse fancyResponse = new ReportingApiQueryResponse(view, isEditable, false, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); - fancyResponse.arrayMultiValueColumns(arrayMultiValueColumns); - fancyResponse.includeFormattedValue(includeFormattedValue); - response = fancyResponse; - } - else if (getRequestedApiVersion() >= 9.1) - { - response = new ExtendedApiQueryResponse(view, isEditable, - false, schemaName, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); - } - else - { - response = new ApiQueryResponse(view, isEditable, - false, schemaName, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, - metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), - form.isIncludeDisplayValues()); - } - response.includeStyle(form.isIncludeStyle()); - - return response; - } - } - - public static class ContainerFilterQueryForm extends QueryForm - { - private String _containerFilter; - - public String getContainerFilter() - { - return _containerFilter; - } - - public void setContainerFilter(String containerFilter) - { - _containerFilter = containerFilter; - } - - @Override - protected QuerySettings createQuerySettings(UserSchema schema) - { - var result = super.createQuerySettings(schema); - if (getContainerFilter() != null) - { - // If the user specified an incorrect filter, throw an IllegalArgumentException - try - { - ContainerFilter.Type containerFilterType = ContainerFilter.Type.valueOf(getContainerFilter()); - result.setContainerFilterName(containerFilterType.name()); - } - catch (IllegalArgumentException e) - { - // Remove bogus value from error message, Issue 45567 - throw new IllegalArgumentException("'containerFilter' parameter is not valid"); - } - } - return result; - } - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public class SelectDistinctAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(ContainerFilterQueryForm form, BindException errors) throws Exception - { - TableInfo table = form.getQueryView().getTable(); - if (null == table) - throw new NotFoundException(); - SqlSelector sqlSelector = getDistinctSql(table, form, errors); - - if (errors.hasErrors() || null == sqlSelector) - return null; - - ApiResponseWriter writer = new ApiJsonWriter(getViewContext().getResponse()); - - try (ResultSet rs = sqlSelector.getResultSet()) - { - writer.startResponse(); - writer.writeProperty("schemaName", form.getSchemaName()); - writer.writeProperty("queryName", form.getQueryName()); - writer.startList("values"); - - while (rs.next()) - { - writer.writeListEntry(rs.getObject(1)); - } - } - catch (SQLException x) - { - throw new RuntimeSQLException(x); - } - catch (DataAccessException x) // Spring error translator can return various subclasses of this - { - throw new RuntimeException(x); - } - writer.endList(); - writer.endResponse(); - - return null; - } - - @Nullable - private SqlSelector getDistinctSql(TableInfo table, ContainerFilterQueryForm form, BindException errors) - { - QuerySettings settings = form.getQuerySettings(); - QueryService service = QueryService.get(); - - if (null == getViewContext().getRequest().getParameter(QueryParam.maxRows.toString())) - { - settings.setMaxRows(DEFAULT_API_MAX_ROWS); - } - else - { - try - { - int maxRows = Integer.parseInt(getViewContext().getRequest().getParameter(QueryParam.maxRows.toString())); - settings.setMaxRows(maxRows); - } - catch (NumberFormatException e) - { - // Standard exception message, Issue 45567 - QuerySettings.throwParameterParseException(QueryParam.maxRows); - } - } - - List fieldKeys = settings.getFieldKeys(); - if (null == fieldKeys || fieldKeys.size() != 1) - { - errors.reject(ERROR_MSG, "Select Distinct requires that only one column be requested."); - return null; - } - Map columns = service.getColumns(table, fieldKeys); - if (columns.size() != 1) - { - errors.reject(ERROR_MSG, "Select Distinct requires that only one column be requested."); - return null; - } - - ColumnInfo col = columns.get(settings.getFieldKeys().get(0)); - if (col == null) - { - errors.reject(ERROR_MSG, "\"" + settings.getFieldKeys().get(0).getName() + "\" is not a valid column."); - return null; - } - - try - { - SimpleFilter filter = getFilterFromQueryForm(form); - - // Strip out filters on columns that don't exist - issue 21669 - service.ensureRequiredColumns(table, columns.values(), filter, null, new HashSet<>()); - QueryLogging queryLogging = new QueryLogging(); - QueryService.SelectBuilder builder = service.getSelectBuilder(table) - .columns(columns.values()) - .filter(filter) - .queryLogging(queryLogging) - .distinct(true); - SQLFragment selectSql = builder.buildSqlFragment(); - - // TODO: queryLogging.isShouldAudit() is always false at this point. - // The only place that seems to set this is ComplianceQueryLoggingProfileListener.queryInvoked() - if (queryLogging.isShouldAudit() && null != queryLogging.getExceptionToThrowIfLoggingIsEnabled()) - { - // this is probably a more helpful message - errors.reject(ERROR_MSG, "Cannot choose values from a column that requires logging."); - return null; - } - - // Regenerate the column since the alias may have changed after call to getSelectSQL() - columns = service.getColumns(table, settings.getFieldKeys()); - var colGetAgain = columns.get(settings.getFieldKeys().get(0)); - // I don't believe the above comment, so here's an assert - assert(colGetAgain.getAlias().equals(col.getAlias())); - - SQLFragment sql = new SQLFragment("SELECT ").appendIdentifier(col.getAlias()).append(" AS value FROM ("); - sql.append(selectSql); - sql.append(") S ORDER BY value"); - - sql = table.getSqlDialect().limitRows(sql, settings.getMaxRows()); - - // 18875: Support Parameterized queries in Select Distinct - Map _namedParameters = settings.getQueryParameters(); - - service.bindNamedParameters(sql, _namedParameters); - service.validateNamedParameters(sql); - - return new SqlSelector(table.getSchema().getScope(), sql, queryLogging); - } - catch (ConversionException | QueryService.NamedParameterNotProvided e) - { - errors.reject(ERROR_MSG, e.getMessage()); - return null; - } - } - } - - private SimpleFilter getFilterFromQueryForm(QueryForm form) - { - QuerySettings settings = form.getQuerySettings(); - SimpleFilter filter = null; - - // 21032: Respect 'ignoreFilter' - if (settings != null && !settings.getIgnoreUserFilter()) - { - // Attach any URL-based filters. This would apply to 'filterArray' from the JavaScript API. - filter = new SimpleFilter(settings.getBaseFilter()); - - String dataRegionName = form.getDataRegionName(); - if (StringUtils.trimToNull(dataRegionName) == null) - dataRegionName = QueryView.DATAREGIONNAME_DEFAULT; - - // Support for 'viewName' - CustomView view = settings.getCustomView(getViewContext(), form.getQueryDef()); - if (null != view && view.hasFilterOrSort() && !settings.getIgnoreViewFilter()) - { - ActionURL url = new ActionURL(SelectDistinctAction.class, getContainer()); - view.applyFilterAndSortToURL(url, dataRegionName); - filter.addAllClauses(new SimpleFilter(url, dataRegionName)); - } - - filter.addUrlFilters(settings.getSortFilterURL(), dataRegionName, Collections.emptyList(), getUser(), getContainer()); - } - - return filter; - } - - @RequiresPermission(ReadPermission.class) - public class GetColumnSummaryStatsAction extends ReadOnlyApiAction - { - private FieldKey _colFieldKey; - - @Override - public void validateForm(QueryForm form, Errors errors) - { - QuerySettings settings = form.getQuerySettings(); - List fieldKeys = settings != null ? settings.getFieldKeys() : null; - if (null == fieldKeys || fieldKeys.size() != 1) - errors.reject(ERROR_MSG, "GetColumnSummaryStats requires that only one column be requested."); - else - _colFieldKey = fieldKeys.get(0); - } - - @Override - public ApiResponse execute(QueryForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - QueryView view = form.getQueryView(); - DisplayColumn displayColumn = null; - - for (DisplayColumn dc : view.getDisplayColumns()) - { - if (dc.getColumnInfo() != null && _colFieldKey.equals(dc.getColumnInfo().getFieldKey())) - { - displayColumn = dc; - break; - } - } - - if (displayColumn != null && displayColumn.getColumnInfo() != null) - { - // get the map of the analytics providers to their relevant aggregates and add the information to the response - Map> analyticsProviders = new LinkedHashMap<>(); - Set colAggregates = new HashSet<>(); - for (ColumnAnalyticsProvider analyticsProvider : displayColumn.getAnalyticsProviders()) - { - if (analyticsProvider instanceof BaseAggregatesAnalyticsProvider baseAggProvider) - { - Map props = new HashMap<>(); - props.put("label", baseAggProvider.getLabel()); - - List aggregateNames = new ArrayList<>(); - for (Aggregate aggregate : AnalyticsProviderItem.createAggregates(baseAggProvider, _colFieldKey, null)) - { - aggregateNames.add(aggregate.getType().getName()); - colAggregates.add(aggregate); - } - props.put("aggregates", aggregateNames); - - analyticsProviders.put(baseAggProvider.getName(), props); - } - } - - // get the filter set from the queryform and verify that they resolve - SimpleFilter filter = getFilterFromQueryForm(form); - if (filter != null) - { - Map resolvedCols = QueryService.get().getColumns(view.getTable(), filter.getAllFieldKeys()); - for (FieldKey filterFieldKey : filter.getAllFieldKeys()) - { - if (!resolvedCols.containsKey(filterFieldKey)) - filter.deleteConditions(filterFieldKey); - } - } - - // query the table/view for the aggregate results - Collection columns = Collections.singleton(displayColumn.getColumnInfo()); - TableSelector selector = new TableSelector(view.getTable(), columns, filter, null).setNamedParameters(form.getQuerySettings().getQueryParameters()); - Map> aggResults = selector.getAggregates(new ArrayList<>(colAggregates)); - - // create a response object mapping the analytics providers to their relevant aggregate results - Map> aggregateResults = new HashMap<>(); - if (aggResults.containsKey(_colFieldKey.toString())) - { - for (Aggregate.Result r : aggResults.get(_colFieldKey.toString())) - { - Map props = new HashMap<>(); - Aggregate.Type type = r.getAggregate().getType(); - props.put("label", type.getFullLabel()); - props.put("description", type.getDescription()); - props.put("value", r.getFormattedValue(displayColumn, getContainer()).value()); - aggregateResults.put(type.getName(), props); - } - - response.put("success", true); - response.put("analyticsProviders", analyticsProviders); - response.put("aggregateResults", aggregateResults); - } - else - { - response.put("success", false); - response.put("message", "Unable to get aggregate results for " + _colFieldKey); - } - } - else - { - response.put("success", false); - response.put("message", "Unable to find ColumnInfo for " + _colFieldKey); - } - - return response; - } - } - - @RequiresPermission(ReadPermission.class) - public class ImportAction extends AbstractQueryImportAction - { - private QueryForm _form; - - @Override - protected void initRequest(QueryForm form) throws ServletException - { - _form = form; - - _insertOption = form.getInsertOption(); - QueryDefinition query = form.getQueryDef(); - List qpe = new ArrayList<>(); - TableInfo t = query.getTable(form.getSchema(), qpe, true); - if (!qpe.isEmpty()) - throw qpe.get(0); - if (null != t) - setTarget(t); - _auditBehaviorType = form.getAuditBehavior(); - _auditUserComment = form.getAuditUserComment(); - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - initRequest(form); - return super.getDefaultImportView(form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - new SchemaAction(_form).addNavTrail(root); - var executeQuery = _form.urlFor(QueryAction.executeQuery); - if (null == executeQuery) - root.addChild(_form.getQueryName()); - else - root.addChild(_form.getQueryName(), executeQuery); - root.addChild("Import Data"); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportSqlForm - { - private String _sql; - private String _schemaName; - private String _containerFilter; - private String _format = "excel"; - - public String getSql() - { - return _sql; - } - - public void setSql(String sql) - { - _sql = PageFlowUtil.wafDecode(sql); - } - - public String getSchemaName() - { - return _schemaName; - } - - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public String getContainerFilter() - { - return _containerFilter; - } - - public void setContainerFilter(String containerFilter) - { - _containerFilter = containerFilter; - } - - public String getFormat() - { - return _format; - } - - public void setFormat(String format) - { - _format = format; - } - } - - @RequiresPermission(ReadPermission.class) - @ApiVersion(9.2) - @Action(ActionType.Export.class) - public static class ExportSqlAction extends ExportAction - { - @Override - public void export(ExportSqlForm form, HttpServletResponse response, BindException errors) throws IOException, ExportException - { - String schemaName = StringUtils.trimToNull(form.getSchemaName()); - if (null == schemaName) - throw new NotFoundException("No value was supplied for the required parameter 'schemaName'"); - String sql = StringUtils.trimToNull(form.getSql()); - if (null == sql) - throw new NotFoundException("No value was supplied for the required parameter 'sql'"); - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); - - if (null == schema) - throw new NotFoundException("Schema '" + schemaName + "' not found in this folder"); - - //create a temp query settings object initialized with the posted LabKey SQL - //this will provide a temporary QueryDefinition to Query - TempQuerySettings settings = new TempQuerySettings(getViewContext(), sql); - - //need to explicitly turn off various UI options that will try to refer to the - //current URL and query string - settings.setAllowChooseView(false); - settings.setAllowCustomizeView(false); - - //return all rows - settings.setShowRows(ShowRows.ALL); - - //add container filter if supplied - if (form.getContainerFilter() != null && !form.getContainerFilter().isEmpty()) - { - ContainerFilter.Type containerFilterType = - ContainerFilter.Type.valueOf(form.getContainerFilter()); - settings.setContainerFilterName(containerFilterType.name()); - } - - //build a query view using the schema and settings - QueryView view = new QueryView(schema, settings, errors); - view.setShowRecordSelectors(false); - view.setShowExportButtons(false); - view.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - - //export it - ResponseHelper.setPrivate(response); - response.setHeader("X-Robots-Tag", "noindex"); - - if ("excel".equalsIgnoreCase(form.getFormat())) - view.exportToExcel(response); - else if ("tsv".equalsIgnoreCase(form.getFormat())) - view.exportToTsv(response); - else - errors.reject(null, "Invalid format specified; must be 'excel' or 'tsv'"); - - for (QueryException qe : view.getParseErrors()) - errors.reject(null, qe.getMessage()); - - if (errors.hasErrors()) - throw new ExportException(new SimpleErrorView(errors, false)); - } - } - - public static class ApiSaveRowsForm extends SimpleApiJsonForm - { - } - - private enum CommandType - { - insert(InsertPermission.class, QueryService.AuditAction.INSERT) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException - { - BatchValidationException errors = new BatchValidationException(); - List> insertedRows = qus.insertRows(user, container, rows, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - // Issue 42519: Submitter role not able to insert - // as per the definition of submitter, should allow insert without read - if (qus.hasPermission(user, ReadPermission.class) && shouldReselect(configParameters)) - { - return qus.getRows(user, container, insertedRows); - } - else - { - return insertedRows; - } - } - }, - insertWithKeys(InsertPermission.class, QueryService.AuditAction.INSERT) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException - { - List> newRows = new ArrayList<>(); - List> oldKeys = new ArrayList<>(); - for (Map row : rows) - { - //issue 13719: use CaseInsensitiveHashMaps. Also allow either values or oldKeys to be null - CaseInsensitiveHashMap newMap = row.get(SaveRowsAction.PROP_VALUES) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_VALUES)).toMap()) : new CaseInsensitiveHashMap<>(); - newRows.add(newMap); - - CaseInsensitiveHashMap oldMap = row.get(SaveRowsAction.PROP_OLD_KEYS) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_OLD_KEYS)).toMap()) : new CaseInsensitiveHashMap<>(); - oldKeys.add(oldMap); - } - BatchValidationException errors = new BatchValidationException(); - List> updatedRows = qus.insertRows(user, container, newRows, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - // Issue 42519: Submitter role not able to insert - // as per the definition of submitter, should allow insert without read - if (qus.hasPermission(user, ReadPermission.class) && shouldReselect(configParameters)) - { - updatedRows = qus.getRows(user, container, updatedRows); - } - List> results = new ArrayList<>(); - for (int i = 0; i < updatedRows.size(); i++) - { - Map result = new HashMap<>(); - result.put(SaveRowsAction.PROP_VALUES, updatedRows.get(i)); - result.put(SaveRowsAction.PROP_OLD_KEYS, oldKeys.get(i)); - results.add(result); - } - return results; - } - }, - importRows(InsertPermission.class, QueryService.AuditAction.INSERT) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, BatchValidationException - { - BatchValidationException errors = new BatchValidationException(); - DataIteratorBuilder it = new ListofMapsDataIterator.Builder(rows.get(0).keySet(), rows); - qus.importRows(user, container, it, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - return Collections.emptyList(); - } - }, - moveRows(MoveEntitiesPermission.class, QueryService.AuditAction.UPDATE) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException - { - BatchValidationException errors = new BatchValidationException(); - - Container targetContainer = (Container) configParameters.get(QueryUpdateService.ConfigParameters.TargetContainer); - Map updatedCounts = qus.moveRows(user, container, targetContainer, rows, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - return Collections.singletonList(updatedCounts); - } - }, - update(UpdatePermission.class, QueryService.AuditAction.UPDATE) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException - { - BatchValidationException errors = new BatchValidationException(); - List> updatedRows = qus.updateRows(user, container, rows, null, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - return shouldReselect(configParameters) ? qus.getRows(user, container, updatedRows) : updatedRows; - } - }, - updateChangingKeys(UpdatePermission.class, QueryService.AuditAction.UPDATE) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException - { - List> newRows = new ArrayList<>(); - List> oldKeys = new ArrayList<>(); - for (Map row : rows) - { - // issue 13719: use CaseInsensitiveHashMaps. Also allow either values or oldKeys to be null. - // this should never happen on an update, but we will let it fail later with a better error message instead of the NPE here - CaseInsensitiveHashMap newMap = row.get(SaveRowsAction.PROP_VALUES) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_VALUES)).toMap()) : new CaseInsensitiveHashMap<>(); - newRows.add(newMap); - - CaseInsensitiveHashMap oldMap = row.get(SaveRowsAction.PROP_OLD_KEYS) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_OLD_KEYS)).toMap()) : new CaseInsensitiveHashMap<>(); - oldKeys.add(oldMap); - } - BatchValidationException errors = new BatchValidationException(); - List> updatedRows = qus.updateRows(user, container, newRows, oldKeys, errors, configParameters, extraContext); - if (errors.hasErrors()) - throw errors; - if (shouldReselect(configParameters)) - updatedRows = qus.getRows(user, container, updatedRows); - List> results = new ArrayList<>(); - for (int i = 0; i < updatedRows.size(); i++) - { - Map result = new HashMap<>(); - result.put(SaveRowsAction.PROP_VALUES, updatedRows.get(i)); - result.put(SaveRowsAction.PROP_OLD_KEYS, oldKeys.get(i)); - results.add(result); - } - return results; - } - }, - delete(DeletePermission.class, QueryService.AuditAction.DELETE) - { - @Override - public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException - { - return qus.deleteRows(user, container, rows, configParameters, extraContext); - } - }; - - private final Class _permission; - private final QueryService.AuditAction _auditAction; - - CommandType(Class permission, QueryService.AuditAction auditAction) - { - _permission = permission; - _auditAction = auditAction; - } - - public Class getPermission() - { - return _permission; - } - - public QueryService.AuditAction getAuditAction() - { - return _auditAction; - } - - public static boolean shouldReselect(Map configParameters) - { - if (configParameters == null || !configParameters.containsKey(QueryUpdateService.ConfigParameters.SkipReselectRows)) - return true; - - return Boolean.TRUE != configParameters.get(QueryUpdateService.ConfigParameters.SkipReselectRows); - } - - public abstract List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) - throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException; - } - - /** - * Base action class for insert/update/delete actions - */ - protected abstract static class BaseSaveRowsAction
extends MutatingApiAction - { - public static final String PROP_SCHEMA_NAME = "schemaName"; - public static final String PROP_QUERY_NAME = "queryName"; - public static final String PROP_CONTAINER_PATH = "containerPath"; - public static final String PROP_TARGET_CONTAINER_PATH = "targetContainerPath"; - public static final String PROP_COMMAND = "command"; - public static final String PROP_ROWS = "rows"; - - private JSONObject _json; - - @Override - public void validateForm(FORM apiSaveRowsForm, Errors errors) - { - _json = apiSaveRowsForm.getJsonObject(); - - // if the POST was done using FormData, the apiSaveRowsForm would not have bound the json data, so - // we'll instead look for that data in the request param directly - if (_json == null && getViewContext().getRequest() != null && getViewContext().getRequest().getParameter("json") != null) - _json = new JSONObject(getViewContext().getRequest().getParameter("json")); - } - - protected JSONObject getJsonObject() - { - return _json; - } - - protected Container getContainerForCommand(JSONObject json) - { - return getContainerForCommand(json, PROP_CONTAINER_PATH, getContainer()); - } - - protected Container getContainerForCommand(JSONObject json, String containerPathProp, @Nullable Container defaultContainer) - { - Container container; - String containerPath = StringUtils.trimToNull(json.optString(containerPathProp)); - if (containerPath == null) - { - if (defaultContainer != null) - container = defaultContainer; - else - throw new IllegalArgumentException(containerPathProp + " is required but was not provided."); - } - else - { - container = ContainerManager.getForPath(containerPath); - if (container == null) - { - throw new IllegalArgumentException("Unknown container: " + containerPath); - } - } - - // Issue 21850: Verify that the user has at least some sort of basic access to the container. We'll check for more downstream - if (!container.hasPermission(getUser(), ReadPermission.class) && - !container.hasPermission(getUser(), DeletePermission.class) && - !container.hasPermission(getUser(), InsertPermission.class) && - !container.hasPermission(getUser(), UpdatePermission.class)) - { - throw new UnauthorizedException(); - } - - return container; - } - - protected String getTargetContainerProp() - { - JSONObject json = getJsonObject(); - return json.optString(PROP_TARGET_CONTAINER_PATH, null); - } - - protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors) throws Exception - { - return executeJson(json, commandType, allowTransaction, errors, false); - } - - protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors, boolean isNestedTransaction) throws Exception - { - return executeJson(json, commandType, allowTransaction, errors, isNestedTransaction, null); - } - - protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors, boolean isNestedTransaction, @Nullable Integer commandIndex) throws Exception - { - JSONObject response = new JSONObject(); - Container container = getContainerForCommand(json); - User user = getUser(); - - if (json == null) - throw new ValidationException("Empty request"); - - JSONArray rows; - try - { - rows = json.getJSONArray(PROP_ROWS); - if (rows.isEmpty()) - throw new ValidationException("No '" + PROP_ROWS + "' array supplied."); - } - catch (JSONException x) - { - throw new ValidationException("No '" + PROP_ROWS + "' array supplied."); - } - - String schemaName = json.getString(PROP_SCHEMA_NAME); - String queryName = json.getString(PROP_QUERY_NAME); - TableInfo table = getTableInfo(container, user, schemaName, queryName); - - if (!table.hasPermission(user, commandType.getPermission())) - throw new UnauthorizedException(); - - if (commandType != CommandType.insert && table.getPkColumns().isEmpty()) - throw new IllegalArgumentException("The table '" + table.getPublicSchemaName() + "." + - table.getPublicName() + "' cannot be updated because it has no primary key defined!"); - - QueryUpdateService qus = table.getUpdateService(); - if (null == qus) - throw new IllegalArgumentException("The query '" + queryName + "' in the schema '" + schemaName + - "' is not updatable via the HTTP-based APIs."); - - int rowsAffected = 0; - - List> rowsToProcess = new ArrayList<>(); - - // NOTE RowMapFactory is faster, but for update it's important to preserve missing v explicit NULL values - // Do we need to support some sort of UNDEFINED and NULL instance of MvFieldWrapper? - RowMapFactory f = null; - if (commandType == CommandType.insert || commandType == CommandType.insertWithKeys || commandType == CommandType.delete) - f = new RowMapFactory<>(); - CaseInsensitiveHashMap referenceCasing = new CaseInsensitiveHashMap<>(); - - for (int idx = 0; idx < rows.length(); ++idx) - { - JSONObject jsonObj; - try - { - jsonObj = rows.getJSONObject(idx); - } - catch (JSONException x) - { - throw new IllegalArgumentException("rows[" + idx + "] is not an object."); - } - if (null != jsonObj) - { - Map rowMap = null == f ? new CaseInsensitiveHashMap<>(new HashMap<>(), referenceCasing) : f.getRowMap(); - // Use shallow copy since jsonObj.toMap() will translate contained JSONObjects into Maps, which we don't want - boolean conflictingCasing = JsonUtil.fillMapShallow(jsonObj, rowMap); - if (conflictingCasing) - { - // Issue 52616 - LOG.error("Row contained conflicting casing for key names in the incoming row: {}", jsonObj); - } - if (allowRowAttachments()) - addRowAttachments(table, rowMap, idx, commandIndex); - - rowsToProcess.add(rowMap); - rowsAffected++; - } - } - - Map extraContext = json.has("extraContext") ? json.getJSONObject("extraContext").toMap() : new CaseInsensitiveHashMap<>(); - - Map configParameters = new HashMap<>(); - - // Check first if the audit behavior has been defined for the table either in code or through XML. - // If not defined there, check for the audit behavior defined in the action form (json). - AuditBehaviorType behaviorType = table.getEffectiveAuditBehavior(json.optString("auditBehavior", null)); - if (behaviorType != null) - { - configParameters.put(DetailedAuditLogDataIterator.AuditConfigs.AuditBehavior, behaviorType); - String auditComment = json.optString("auditUserComment", null); - if (!StringUtils.isEmpty(auditComment)) - configParameters.put(DetailedAuditLogDataIterator.AuditConfigs.AuditUserComment, auditComment); - } - - boolean skipReselectRows = json.optBoolean("skipReselectRows", false); - if (skipReselectRows) - configParameters.put(QueryUpdateService.ConfigParameters.SkipReselectRows, true); - - if (getTargetContainerProp() != null) - { - Container targetContainer = getContainerForCommand(json, PROP_TARGET_CONTAINER_PATH, null); - configParameters.put(QueryUpdateService.ConfigParameters.TargetContainer, targetContainer); - } - - //set up the response, providing the schema name, query name, and operation - //so that the client can sort out which request this response belongs to - //(clients often submit these async) - response.put(PROP_SCHEMA_NAME, schemaName); - response.put(PROP_QUERY_NAME, queryName); - response.put("command", commandType.name()); - response.put("containerPath", container.getPath()); - - //we will transact operations by default, but the user may - //override this by sending a "transacted" property set to false - // 11741: A transaction may already be active if we're trying to - // insert/update/delete from within a transformation/validation script. - boolean transacted = allowTransaction && json.optBoolean("transacted", true); - TransactionAuditProvider.TransactionAuditEvent auditEvent = null; - try (DbScope.Transaction transaction = transacted ? table.getSchema().getScope().ensureTransaction() : NO_OP_TRANSACTION) - { - if (behaviorType != null && behaviorType != AuditBehaviorType.NONE) - { - DbScope.Transaction auditTransaction = !transacted && isNestedTransaction ? table.getSchema().getScope().getCurrentTransaction() : transaction; - if (auditTransaction == null) - auditTransaction = NO_OP_TRANSACTION; - - if (auditTransaction.getAuditEvent() != null) - auditEvent = auditTransaction.getAuditEvent(); - else - { - auditEvent = AbstractQueryUpdateService.createTransactionAuditEvent(container, commandType.getAuditAction()); - AbstractQueryUpdateService.addTransactionAuditEvent(auditTransaction, getUser(), auditEvent); - } - } - - QueryService.get().setEnvironment(QueryService.Environment.CONTAINER, container); - List> responseRows = - commandType.saveRows(qus, rowsToProcess, getUser(), container, configParameters, extraContext); - if (auditEvent != null) - auditEvent.addComment(commandType.getAuditAction(), responseRows.size()); - - if (commandType == CommandType.moveRows) - { - // moveRows returns a single map of updateCounts - response.put("updateCounts", responseRows.get(0)); - } - else if (commandType != CommandType.importRows) - { - response.put("rows", responseRows.stream() - .map(JsonUtil::toMapPreserveNonFinite) - .map(JsonUtil::toJsonPreserveNulls) - .collect(LabKeyCollectors.toJSONArray())); - } - - // if there is any provenance information, save it here - ProvenanceService svc = ProvenanceService.get(); - if (json.has("provenance")) - { - JSONObject provenanceJSON = json.getJSONObject("provenance"); - ProvenanceRecordingParams params = svc.createRecordingParams(getViewContext(), provenanceJSON, ProvenanceService.ADD_RECORDING); - RecordedAction action = svc.createRecordedAction(getViewContext(), params); - if (action != null && params.getRecordingId() != null) - { - // check for any row level provenance information - if (json.has("rows")) - { - Object rowObject = json.get("rows"); - if (rowObject instanceof JSONArray jsonArray) - { - // we need to match any provenance object inputs to the object outputs from the response rows, this typically would - // be the row lsid but it configurable in the provenance recording params - // - List> provenanceMap = svc.createProvenanceMapFromRows(getViewContext(), params, jsonArray, responseRows); - if (!provenanceMap.isEmpty()) - { - action.getProvenanceMap().addAll(provenanceMap); - } - svc.addRecordingStep(getViewContext().getRequest(), params.getRecordingId(), action); - } - else - { - errors.reject(SpringActionController.ERROR_MSG, "Unable to process provenance information, the rows object was not an array"); - } - } - } - } - transaction.commit(); - } - catch (OptimisticConflictException e) - { - //issue 13967: provide better message for OptimisticConflictException - errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); - } - catch (QueryUpdateServiceException | ConversionException | DuplicateKeyException | DataIntegrityViolationException e) - { - //Issue 14294: improve handling of ConversionException (and DuplicateKeyException (Issue 28037), and DataIntegrity (uniqueness) (Issue 22779) - errors.reject(SpringActionController.ERROR_MSG, e.getMessage() == null ? e.toString() : e.getMessage()); - } - catch (BatchValidationException e) - { - if (isSuccessOnValidationError()) - { - response.put("errors", createResponseWriter().toJSON(e)); - } - else - { - ExceptionUtil.decorateException(e, ExceptionUtil.ExceptionInfo.SkipMothershipLogging, "true", true); - throw e; - } - } - if (auditEvent != null) - { - response.put("transactionAuditId", auditEvent.getRowId()); - response.put("reselectRowCount", auditEvent.hasMultiActions()); - } - - response.put("rowsAffected", rowsAffected); - - return response; - } - - protected boolean allowRowAttachments() - { - return false; - } - - private void addRowAttachments(TableInfo tableInfo, Map rowMap, int rowIndex, @Nullable Integer commandIndex) - { - if (getFileMap() != null) - { - for (Map.Entry fileEntry : getFileMap().entrySet()) - { - // Allow for the fileMap key to include the row index, and optionally command index, for defining - // which row to attach this file to - String fullKey = fileEntry.getKey(); - String fieldKey = fullKey; - // Issue 52827: Cannot attach a file if the field name contains :: - // use lastIndexOf instead of split to get the proper parts - int lastDelimIndex = fullKey.lastIndexOf(ROW_ATTACHMENT_INDEX_DELIM); - if (lastDelimIndex > -1) - { - String fieldKeyExcludeIndex = fullKey.substring(0, lastDelimIndex); - String fieldRowIndex = fullKey.substring(lastDelimIndex + ROW_ATTACHMENT_INDEX_DELIM.length()); - if (!fieldRowIndex.equals(rowIndex+"")) continue; - - if (commandIndex == null) - { - // Single command, so we're parsing file names in the format of: FileField::0 - fieldKey = fieldKeyExcludeIndex; - } - else - { - // Multi-command, so we're parsing file names in the format of: FileField::0::1 - int subDelimIndex = fieldKeyExcludeIndex.lastIndexOf(ROW_ATTACHMENT_INDEX_DELIM); - if (subDelimIndex > -1) - { - fieldKey = fieldKeyExcludeIndex.substring(0, subDelimIndex); - String fieldCommandIndex = fieldKeyExcludeIndex.substring(subDelimIndex + ROW_ATTACHMENT_INDEX_DELIM.length()); - if (!fieldCommandIndex.equals(commandIndex+"")) - continue; - } - else - continue; - } - } - - SpringAttachmentFile file = new SpringAttachmentFile(fileEntry.getValue()); - rowMap.put(fieldKey, file.isEmpty() ? null : file); - } - } - - for (ColumnInfo col : tableInfo.getColumns()) - DataIteratorUtil.MatchType.multiPartFormData.updateRowMap(col, rowMap); - } - - protected boolean isSuccessOnValidationError() - { - return getRequestedApiVersion() >= 13.2; - } - - @NotNull - protected TableInfo getTableInfo(Container container, User user, String schemaName, String queryName) - { - if (null == schemaName || null == queryName) - throw new IllegalArgumentException("You must supply a schemaName and queryName!"); - - UserSchema schema = QueryService.get().getUserSchema(user, container, schemaName); - if (null == schema) - throw new IllegalArgumentException("The schema '" + schemaName + "' does not exist."); - - TableInfo table = schema.getTableForInsert(queryName); - if (table == null) - throw new IllegalArgumentException("The query '" + queryName + "' in the schema '" + schemaName + "' does not exist."); - return table; - } - } - - // Issue: 20522 - require read access to the action but executeJson will check for update privileges from the table - // - @RequiresPermission(ReadPermission.class) //will check below - @ApiVersion(8.3) - public static class UpdateRowsAction extends BaseSaveRowsAction - { - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - JSONObject response = executeJson(getJsonObject(), CommandType.update, true, errors); - if (response == null || errors.hasErrors()) - return null; - return new ApiSimpleResponse(response); - } - - @Override - protected boolean allowRowAttachments() - { - return true; - } - } - - @RequiresAnyOf({ReadPermission.class, InsertPermission.class}) //will check below - @ApiVersion(8.3) - public static class InsertRowsAction extends BaseSaveRowsAction - { - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - JSONObject response = executeJson(getJsonObject(), CommandType.insert, true, errors); - if (response == null || errors.hasErrors()) - return null; - - return new ApiSimpleResponse(response); - } - - @Override - protected boolean allowRowAttachments() - { - return true; - } - } - - @RequiresPermission(ReadPermission.class) //will check below - @ApiVersion(8.3) - public static class ImportRowsAction extends BaseSaveRowsAction - { - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - JSONObject response = executeJson(getJsonObject(), CommandType.importRows, true, errors); - if (response == null || errors.hasErrors()) - return null; - return new ApiSimpleResponse(response); - } - } - - @ActionNames("deleteRows, delRows") - @RequiresPermission(ReadPermission.class) //will check below - @ApiVersion(8.3) - public static class DeleteRowsAction extends BaseSaveRowsAction - { - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - JSONObject response = executeJson(getJsonObject(), CommandType.delete, true, errors); - if (response == null || errors.hasErrors()) - return null; - return new ApiSimpleResponse(response); - } - } - - @RequiresPermission(ReadPermission.class) //will check below - public static class MoveRowsAction extends BaseSaveRowsAction - { - private Container _targetContainer; - - @Override - public void validateForm(MoveRowsForm form, Errors errors) - { - super.validateForm(form, errors); - - JSONObject json = getJsonObject(); - if (json == null) - { - errors.reject(ERROR_GENERIC, "Empty request"); - } - else - { - // Since we are moving between containers, we know we have product folders enabled - if (getContainer().getProject().getAuditCommentsRequired() && StringUtils.isBlank(json.optString("auditUserComment"))) - errors.reject(ERROR_GENERIC, "A reason for the move of data is required."); - else - { - String queryName = json.optString(PROP_QUERY_NAME, null); - _targetContainer = ContainerManager.getMoveTargetContainer(queryName, getContainer(), getUser(), getTargetContainerProp(), errors); - } - } - } - - @Override - public ApiResponse execute(MoveRowsForm form, BindException errors) throws Exception - { - // if JSON does not have rows array, see if they were provided via selectionKey - if (!getJsonObject().has(PROP_ROWS)) - setRowsFromSelectionKey(form); - - JSONObject response = executeJson(getJsonObject(), CommandType.moveRows, true, errors); - if (response == null || errors.hasErrors()) - return null; - - updateSelections(form); - - response.put("success", true); - response.put("containerPath", _targetContainer.getPath()); - return new ApiSimpleResponse(response); - } - - private void updateSelections(MoveRowsForm form) - { - String selectionKey = form.getDataRegionSelectionKey(); - if (selectionKey != null) - { - Set rowIds = form.getIds(getViewContext(), false) - .stream().map(Object::toString).collect(Collectors.toSet()); - DataRegionSelection.setSelected(getViewContext(), selectionKey, rowIds, false); - - // if moving entities from a type, the selections from other selectionKeys in that container will - // possibly be holding onto invalid keys after the move, so clear them based on the containerPath and selectionKey suffix - String[] keyParts = selectionKey.split("|"); - if (keyParts.length > 1) - DataRegionSelection.clearRelatedByContainerPath(getViewContext(), keyParts[keyParts.length - 1]); - } - } - - private void setRowsFromSelectionKey(MoveRowsForm form) - { - Set rowIds = form.getIds(getViewContext(), false); // handle clear of selectionKey after move complete - - // convert rowIds to a JSONArray of JSONObjects with a single property "RowId" - JSONArray rows = new JSONArray(); - for (Long rowId : rowIds) - { - JSONObject row = new JSONObject(); - row.put("RowId", rowId); - rows.put(row); - } - getJsonObject().put(PROP_ROWS, rows); - } - } - - public static class MoveRowsForm extends ApiSaveRowsForm - { - private String _dataRegionSelectionKey; - private boolean _useSnapshotSelection; - - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - public void setDataRegionSelectionKey(String dataRegionSelectionKey) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - } - - public boolean isUseSnapshotSelection() - { - return _useSnapshotSelection; - } - - public void setUseSnapshotSelection(boolean useSnapshotSelection) - { - _useSnapshotSelection = useSnapshotSelection; - } - - @Override - public void bindJson(JSONObject json) - { - super.bindJson(json); - _dataRegionSelectionKey = json.optString("dataRegionSelectionKey", null); - _useSnapshotSelection = json.optBoolean("useSnapshotSelection", false); - } - - public Set getIds(ViewContext context, boolean clear) - { - if (_useSnapshotSelection) - return new HashSet<>(DataRegionSelection.getSnapshotSelectedIntegers(context, getDataRegionSelectionKey())); - else - return DataRegionSelection.getSelectedIntegers(context, getDataRegionSelectionKey(), clear); - } - } - - @RequiresNoPermission //will check below - public static class SaveRowsAction extends BaseSaveRowsAction - { - public static final String PROP_VALUES = "values"; - public static final String PROP_OLD_KEYS = "oldKeys"; - - @Override - protected boolean isFailure(BindException errors) - { - return !isSuccessOnValidationError() && super.isFailure(errors); - } - - @Override - protected boolean allowRowAttachments() - { - return true; - } - - @Override - public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception - { - // Issue 21850: Verify that the user has at least some sort of basic access to the container. We'll check for more - // specific permissions later once we've figured out exactly what they're trying to do. This helps us - // give a better HTTP response code when they're trying to access a resource that's not available to guests - if (!getContainer().hasPermission(getUser(), ReadPermission.class) && - !getContainer().hasPermission(getUser(), DeletePermission.class) && - !getContainer().hasPermission(getUser(), InsertPermission.class) && - !getContainer().hasPermission(getUser(), UpdatePermission.class)) - { - throw new UnauthorizedException(); - } - - JSONObject json = getJsonObject(); - if (json == null) - throw new IllegalArgumentException("Empty request"); - - JSONArray commands = json.optJSONArray("commands"); - if (commands == null || commands.isEmpty()) - { - throw new NotFoundException("Empty request"); - } - - boolean validateOnly = json.optBoolean("validateOnly", false); - // If we are going to validate and not commit, we need to be sure we're transacted as well. Otherwise, - // respect the client's request. - boolean transacted = validateOnly || json.optBoolean("transacted", true); - - // Keep track of whether we end up committing or not - boolean committed = false; - - DbScope scope = null; - if (transacted) - { - for (int i = 0; i < commands.length(); i++) - { - JSONObject commandJSON = commands.getJSONObject(i); - String schemaName = commandJSON.getString(PROP_SCHEMA_NAME); - String queryName = commandJSON.getString(PROP_QUERY_NAME); - Container container = getContainerForCommand(commandJSON); - TableInfo tableInfo = getTableInfo(container, getUser(), schemaName, queryName); - if (scope == null) - { - scope = tableInfo.getSchema().getScope(); - } - else if (scope != tableInfo.getSchema().getScope()) - { - throw new IllegalArgumentException("All queries must be from the same source database"); - } - } - assert scope != null; - } - - JSONArray resultArray = new JSONArray(); - JSONObject extraContext = json.optJSONObject("extraContext"); - - int startingErrorIndex = 0; - int errorCount = 0; - // 11741: A transaction may already be active if we're trying to - // insert/update/delete from within a transformation/validation script. - - try (DbScope.Transaction transaction = transacted ? scope.ensureTransaction() : NO_OP_TRANSACTION) - { - for (int i = 0; i < commands.length(); i++) - { - JSONObject commandObject = commands.getJSONObject(i); - String commandName = commandObject.getString(PROP_COMMAND); - if (commandName == null) - { - throw new ApiUsageException(PROP_COMMAND + " is required but was missing"); - } - CommandType command = CommandType.valueOf(commandName); - - // Copy the top-level 'extraContext' and merge in the command-level extraContext. - Map commandExtraContext = new HashMap<>(); - if (extraContext != null) - commandExtraContext.putAll(extraContext.toMap()); - if (commandObject.has("extraContext")) - { - commandExtraContext.putAll(commandObject.getJSONObject("extraContext").toMap()); - } - commandObject.put("extraContext", commandExtraContext); - - JSONObject commandResponse = executeJson(commandObject, command, !transacted, errors, transacted, i); - // Bail out immediately if we're going to return a failure-type response message - if (commandResponse == null || (errors.hasErrors() && !isSuccessOnValidationError())) - return null; - - //this would be populated in executeJson when a BatchValidationException is thrown - if (commandResponse.has("errors")) - { - errorCount += commandResponse.getJSONObject("errors").getInt("errorCount"); - } - - // If we encountered errors with this particular command and the client requested that don't treat - // the whole request as a failure (non-200 HTTP status code), stash the errors for this particular - // command in its response section. - // NOTE: executeJson should handle and serialize BatchValidationException - // these errors upstream - if (errors.getErrorCount() > startingErrorIndex && isSuccessOnValidationError()) - { - commandResponse.put("errors", ApiResponseWriter.convertToJSON(errors, startingErrorIndex).getValue()); - startingErrorIndex = errors.getErrorCount(); - } - - resultArray.put(commandResponse); - } - - // Don't commit if we had errors or if the client requested that we only validate (and not commit) - if (!errors.hasErrors() && !validateOnly && errorCount == 0) - { - transaction.commit(); - committed = true; - } - } - - errorCount += errors.getErrorCount(); - JSONObject result = new JSONObject(); - result.put("result", resultArray); - result.put("committed", committed); - result.put("errorCount", errorCount); - - return new ApiSimpleResponse(result); - } - } - - @RequiresPermission(ReadPermission.class) - public static class ApiTestAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/query/view/apitest.jsp"); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("API Test"); - } - } - - - @RequiresPermission(AdminPermission.class) - public static class AdminAction extends SimpleViewAction - { - @SuppressWarnings("UnusedDeclaration") - public AdminAction() - { - } - - public AdminAction(ViewContext ctx) - { - setViewContext(ctx); - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - setHelpTopic("externalSchemas"); - return new JspView<>("/org/labkey/query/view/admin.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Schema Administration", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ResetRemoteConnectionsForm - { - private boolean _reset; - - public boolean isReset() - { - return _reset; - } - - public void setReset(boolean reset) - { - _reset = reset; - } - } - - - @RequiresPermission(AdminPermission.class) - public static class ManageRemoteConnectionsAction extends FormViewAction - { - @Override - public void validateCommand(ResetRemoteConnectionsForm target, Errors errors) {} - - @Override - public boolean handlePost(ResetRemoteConnectionsForm form, BindException errors) - { - if (form.isReset()) - { - PropertyManager.getEncryptedStore().deletePropertySet(getContainer(), RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY); - } - return true; - } - - @Override - public URLHelper getSuccessURL(ResetRemoteConnectionsForm queryForm) - { - return new ActionURL(ManageRemoteConnectionsAction.class, getContainer()); - } - - @Override - public ModelAndView getView(ResetRemoteConnectionsForm queryForm, boolean reshow, BindException errors) - { - Map connectionMap; - try - { - // if the encrypted property store is configured but no values have yet been set, and empty map is returned - connectionMap = PropertyManager.getEncryptedStore().getProperties(getContainer(), RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY); - } - catch (Exception e) - { - connectionMap = null; // render the failure page - } - setHelpTopic("remoteConnection"); - return new JspView<>("/org/labkey/query/view/manageRemoteConnections.jsp", connectionMap, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Manage Remote Connections", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); - } - } - - private abstract static class BaseInsertExternalSchemaAction, T extends AbstractExternalSchemaDef> extends FormViewAction - { - protected BaseInsertExternalSchemaAction(Class commandClass) - { - super(commandClass); - } - - @Override - public void validateCommand(F form, Errors errors) - { - form.validate(errors); - } - - @Override - public boolean handlePost(F form, BindException errors) throws Exception - { - try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) - { - form.doInsert(); - auditSchemaAdminActivity(form.getBean(), "created", getContainer(), getUser()); - QueryManager.get().updateExternalSchemas(getContainer()); - - t.commit(); - } - catch (RuntimeSQLException e) - { - if (e.isConstraintException()) - { - errors.reject(ERROR_MSG, "A schema by that name is already defined in this folder"); - return false; - } - - throw e; - } - - return true; - } - - @Override - public ActionURL getSuccessURL(F form) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new AdminAction(getViewContext()).addNavTrail(root); - root.addChild("Define Schema", new ActionURL(getClass(), getContainer())); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class InsertLinkedSchemaAction extends BaseInsertExternalSchemaAction - { - public InsertLinkedSchemaAction() - { - super(LinkedSchemaForm.class); - } - - @Override - public ModelAndView getView(LinkedSchemaForm form, boolean reshow, BindException errors) - { - setHelpTopic("filterSchema"); - return new JspView<>("/org/labkey/query/view/linkedSchema.jsp", new LinkedSchemaBean(getContainer(), form.getBean(), true), errors); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class InsertExternalSchemaAction extends BaseInsertExternalSchemaAction - { - public InsertExternalSchemaAction() - { - super(ExternalSchemaForm.class); - } - - @Override - public ModelAndView getView(ExternalSchemaForm form, boolean reshow, BindException errors) - { - setHelpTopic("externalSchemas"); - return new JspView<>("/org/labkey/query/view/externalSchema.jsp", new ExternalSchemaBean(getContainer(), form.getBean(), true), errors); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class DeleteSchemaAction extends ConfirmAction - { - @Override - public String getConfirmText() - { - return "Delete"; - } - - @Override - public ModelAndView getConfirmView(SchemaForm form, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Delete Schema"); - - AbstractExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), AbstractExternalSchemaDef.class); - if (def == null) - throw new NotFoundException(); - - String schemaName = isBlank(def.getUserSchemaName()) ? "this schema" : "the schema '" + def.getUserSchemaName() + "'"; - return new HtmlView(HtmlString.of("Are you sure you want to delete " + schemaName + "? The tables and queries defined in this schema will no longer be accessible.")); - } - - @Override - public boolean handlePost(SchemaForm form, BindException errors) - { - AbstractExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), AbstractExternalSchemaDef.class); - if (def == null) - throw new NotFoundException(); - - try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) - { - auditSchemaAdminActivity(def, "deleted", getContainer(), getUser()); - QueryManager.get().delete(def); - t.commit(); - } - return true; - } - - @Override - public void validateCommand(SchemaForm form, Errors errors) - { - } - - @Override - @NotNull - public ActionURL getSuccessURL(SchemaForm form) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); - } - } - - private static void auditSchemaAdminActivity(AbstractExternalSchemaDef def, String action, Container container, User user) - { - String comment = StringUtils.capitalize(def.getSchemaType().toString()) + " schema '" + def.getUserSchemaName() + "' " + action; - AuditTypeEvent event = new AuditTypeEvent(ContainerAuditProvider.CONTAINER_AUDIT_EVENT, container, comment); - AuditLogService.get().addEvent(user, event); - } - - - private abstract static class BaseEditSchemaAction, T extends AbstractExternalSchemaDef> extends FormViewAction - { - protected BaseEditSchemaAction(Class commandClass) - { - super(commandClass); - } - - @Override - public void validateCommand(F form, Errors errors) - { - form.validate(errors); - } - - @Nullable - protected abstract T getCurrent(int externalSchemaId); - - @NotNull - protected T getDef(F form, boolean reshow) - { - T def; - Container defContainer; - - if (reshow) - { - def = form.getBean(); - T current = getCurrent(def.getExternalSchemaId()); - if (current == null) - throw new NotFoundException(); - - defContainer = current.lookupContainer(); - } - else - { - form.refreshFromDb(); - if (!form.isDataLoaded()) - throw new NotFoundException(); - - def = form.getBean(); - if (def == null) - throw new NotFoundException(); - - defContainer = def.lookupContainer(); - } - - if (!getContainer().equals(defContainer)) - throw new UnauthorizedException(); - - return def; - } - - @Override - public boolean handlePost(F form, BindException errors) throws Exception - { - T def = form.getBean(); - T fromDb = getCurrent(def.getExternalSchemaId()); - - // Unauthorized if def in the database reports a different container - if (!getContainer().equals(fromDb.lookupContainer())) - throw new UnauthorizedException(); - - try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) - { - form.doUpdate(); - auditSchemaAdminActivity(def, "updated", getContainer(), getUser()); - QueryManager.get().updateExternalSchemas(getContainer()); - t.commit(); - } - catch (RuntimeSQLException e) - { - if (e.isConstraintException()) - { - errors.reject(ERROR_MSG, "A schema by that name is already defined in this folder"); - return false; - } - - throw e; - } - return true; - } - - @Override - public ActionURL getSuccessURL(F externalSchemaForm) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new AdminAction(getViewContext()).addNavTrail(root); - root.addChild("Edit Schema", new ActionURL(getClass(), getContainer())); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class EditLinkedSchemaAction extends BaseEditSchemaAction - { - public EditLinkedSchemaAction() - { - super(LinkedSchemaForm.class); - } - - @Nullable - @Override - protected LinkedSchemaDef getCurrent(int externalId) - { - return QueryManager.get().getLinkedSchemaDef(getContainer(), externalId); - } - - @Override - public ModelAndView getView(LinkedSchemaForm form, boolean reshow, BindException errors) - { - LinkedSchemaDef def = getDef(form, reshow); - - setHelpTopic("filterSchema"); - return new JspView<>("/org/labkey/query/view/linkedSchema.jsp", new LinkedSchemaBean(getContainer(), def, false), errors); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class EditExternalSchemaAction extends BaseEditSchemaAction - { - public EditExternalSchemaAction() - { - super(ExternalSchemaForm.class); - } - - @Nullable - @Override - protected ExternalSchemaDef getCurrent(int externalId) - { - return QueryManager.get().getExternalSchemaDef(getContainer(), externalId); - } - - @Override - public ModelAndView getView(ExternalSchemaForm form, boolean reshow, BindException errors) - { - ExternalSchemaDef def = getDef(form, reshow); - - setHelpTopic("externalSchemas"); - return new JspView<>("/org/labkey/query/view/externalSchema.jsp", new ExternalSchemaBean(getContainer(), def, false), errors); - } - } - - - public static class DataSourceInfo - { - public final String sourceName; - public final String displayName; - public final boolean editable; - - public DataSourceInfo(DbScope scope) - { - this(scope.getDataSourceName(), scope.getDisplayName(), scope.getSqlDialect().isEditable()); - } - - public DataSourceInfo(Container c) - { - this(c.getId(), c.getName(), false); - } - - public DataSourceInfo(String sourceName, String displayName, boolean editable) - { - this.sourceName = sourceName; - this.displayName = displayName; - this.editable = editable; - } - - @Override - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - DataSourceInfo that = (DataSourceInfo) o; - return sourceName != null ? sourceName.equals(that.sourceName) : that.sourceName == null; - } - - @Override - public int hashCode() - { - return sourceName != null ? sourceName.hashCode() : 0; - } - } - - public static abstract class BaseExternalSchemaBean - { - protected final Container _c; - protected final T _def; - protected final boolean _insert; - protected final Map _help = new HashMap<>(); - - public BaseExternalSchemaBean(Container c, T def, boolean insert) - { - _c = c; - _def = def; - _insert = insert; - - TableInfo ti = QueryManager.get().getTableInfoExternalSchema(); - - ti.getColumns() - .stream() - .filter(ci -> null != ci.getDescription()) - .forEach(ci -> _help.put(ci.getName(), ci.getDescription())); - } - - public abstract DataSourceInfo getInitialSource(); - - public T getSchemaDef() - { - return _def; - } - - public boolean isInsert() - { - return _insert; - } - - public ActionURL getReturnURL() - { - return new ActionURL(AdminAction.class, _c); - } - - public ActionURL getDeleteURL() - { - return new QueryUrlsImpl().urlDeleteSchema(_c, _def); - } - - public String getHelpHTML(String fieldName) - { - return _help.get(fieldName); - } - } - - public static class LinkedSchemaBean extends BaseExternalSchemaBean - { - public LinkedSchemaBean(Container c, LinkedSchemaDef def, boolean insert) - { - super(c, def, insert); - } - - @Override - public DataSourceInfo getInitialSource() - { - Container sourceContainer = getInitialContainer(); - return new DataSourceInfo(sourceContainer); - } - - private @NotNull Container getInitialContainer() - { - LinkedSchemaDef def = getSchemaDef(); - Container sourceContainer = def.lookupSourceContainer(); - if (sourceContainer == null) - sourceContainer = def.lookupContainer(); - if (sourceContainer == null) - sourceContainer = _c; - return sourceContainer; - } - } - - public static class ExternalSchemaBean extends BaseExternalSchemaBean - { - protected final Map> _sourcesAndSchemas = new LinkedHashMap<>(); - protected final Map> _sourcesAndSchemasIncludingSystem = new LinkedHashMap<>(); - - public ExternalSchemaBean(Container c, ExternalSchemaDef def, boolean insert) - { - super(c, def, insert); - initSources(); - } - - public Collection getSources() - { - return _sourcesAndSchemas.keySet(); - } - - public Collection getSchemaNames(DataSourceInfo source, boolean includeSystem) - { - if (includeSystem) - return _sourcesAndSchemasIncludingSystem.get(source); - else - return _sourcesAndSchemas.get(source); - } - - @Override - public DataSourceInfo getInitialSource() - { - ExternalSchemaDef def = getSchemaDef(); - DbScope scope = def.lookupDbScope(); - if (scope == null) - scope = DbScope.getLabKeyScope(); - return new DataSourceInfo(scope); - } - - protected void initSources() - { - ModuleLoader moduleLoader = ModuleLoader.getInstance(); - - for (DbScope scope : DbScope.getDbScopes()) - { - SqlDialect dialect = scope.getSqlDialect(); - - Collection schemaNames = new LinkedList<>(); - Collection schemaNamesIncludingSystem = new LinkedList<>(); - - for (String schemaName : scope.getSchemaNames()) - { - schemaNamesIncludingSystem.add(schemaName); - - if (dialect.isSystemSchema(schemaName)) - continue; - - if (null != moduleLoader.getModule(scope, schemaName)) - continue; - - schemaNames.add(schemaName); - } - - DataSourceInfo source = new DataSourceInfo(scope); - _sourcesAndSchemas.put(source, schemaNames); - _sourcesAndSchemasIncludingSystem.put(source, schemaNamesIncludingSystem); - } - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class GetTablesForm - { - private String _dataSource; - private String _schemaName; - private boolean _sorted; - - public String getDataSource() - { - return _dataSource; - } - - public void setDataSource(String dataSource) - { - _dataSource = dataSource; - } - - public String getSchemaName() - { - return _schemaName; - } - - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public boolean isSorted() - { - return _sorted; - } - - public void setSorted(boolean sorted) - { - _sorted = sorted; - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class GetTablesAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(GetTablesForm form, BindException errors) - { - List> rows = new LinkedList<>(); - List tableNames = new ArrayList<>(); - - if (null != form.getSchemaName()) - { - DbScope scope = DbScope.getDbScope(form.getDataSource()); - if (null != scope) - { - DbSchema schema = scope.getSchema(form.getSchemaName(), DbSchemaType.Bare); - tableNames.addAll(schema.getTableNames()); - } - else - { - Container c = ContainerManager.getForId(form.getDataSource()); - if (null != c) - { - UserSchema schema = QueryService.get().getUserSchema(getUser(), c, form.getSchemaName()); - if (null != schema) - { - if (form.isSorted()) - for (TableInfo table : schema.getSortedTables()) - tableNames.add(table.getName()); - else - tableNames.addAll(schema.getTableAndQueryNames(true)); - } - } - } - } - - Collections.sort(tableNames); - - for (String tableName : tableNames) - { - Map row = new LinkedHashMap<>(); - row.put("table", tableName); - rows.add(row); - } - - Map properties = new HashMap<>(); - properties.put("rows", rows); - - return new ApiSimpleResponse(properties); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class SchemaTemplateForm - { - private String _name; - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - } - - - @RequiresPermission(AdminOperationsPermission.class) - public static class SchemaTemplateAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(SchemaTemplateForm form, BindException errors) - { - String name = form.getName(); - if (name == null) - throw new IllegalArgumentException("name required"); - - Container c = getContainer(); - TemplateSchemaType template = QueryServiceImpl.get().getSchemaTemplate(c, name); - if (template == null) - throw new NotFoundException("template not found"); - - JSONObject templateJson = QueryServiceImpl.get().schemaTemplateJson(name, template); - - return new ApiSimpleResponse("template", templateJson); - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class SchemaTemplatesAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(Object form, BindException errors) - { - Container c = getContainer(); - QueryServiceImpl svc = QueryServiceImpl.get(); - Map templates = svc.getSchemaTemplates(c); - - JSONArray ret = new JSONArray(); - for (String key : templates.keySet()) - { - TemplateSchemaType template = templates.get(key); - JSONObject templateJson = svc.schemaTemplateJson(key, template); - ret.put(templateJson); - } - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("templates", ret); - resp.put("success", true); - return resp; - } - } - - @RequiresPermission(AdminPermission.class) - public static class ReloadExternalSchemaAction extends FormHandlerAction - { - private String _userSchemaName; - - @Override - public void validateCommand(SchemaForm form, Errors errors) - { - } - - @Override - public boolean handlePost(SchemaForm form, BindException errors) - { - ExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), ExternalSchemaDef.class); - if (def == null) - throw new NotFoundException(); - - QueryManager.get().reloadExternalSchema(def); - _userSchemaName = def.getUserSchemaName(); - - return true; - } - - @Override - public ActionURL getSuccessURL(SchemaForm form) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "Schema " + _userSchemaName + " was reloaded successfully."); - } - } - - - @RequiresPermission(AdminPermission.class) - public static class ReloadAllUserSchemas extends FormHandlerAction - { - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - QueryManager.get().reloadAllExternalSchemas(getContainer()); - return true; - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "All schemas in this folder were reloaded successfully."); - } - } - - @RequiresPermission(AdminPermission.class) - public static class ReloadFailedConnectionsAction extends FormHandlerAction - { - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - DbScope.clearFailedDbScopes(); - return true; - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "Reconnection was attempted on all data sources that failed previous connection attempts."); - } - } - - @RequiresPermission(ReadPermission.class) - public static class TableInfoAction extends SimpleViewAction - { - @Override - public ModelAndView getView(TableInfoForm form, BindException errors) throws Exception - { - TablesDocument ret = TablesDocument.Factory.newInstance(); - TablesType tables = ret.addNewTables(); - - FieldKey[] fields = form.getFieldKeys(); - if (fields.length != 0) - { - TableInfo tinfo = QueryView.create(form, errors).getTable(); - Map columnMap = CustomViewImpl.getColumnInfos(tinfo, Arrays.asList(fields)); - TableXML.initTable(tables.addNewTable(), tinfo, null, columnMap.values()); - } - - for (FieldKey tableKey : form.getTableKeys()) - { - TableInfo tableInfo = form.getTableInfo(tableKey); - TableType xbTable = tables.addNewTable(); - TableXML.initTable(xbTable, tableInfo, tableKey); - } - getViewContext().getResponse().setContentType("text/xml"); - getViewContext().getResponse().getWriter().write(ret.toString()); - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - // Issue 18870: Guest user can't revert unsaved custom view changes - // Permission will be checked inline (guests are allowed to delete their session custom views) - @RequiresNoPermission - @Action(ActionType.Configure.class) - public static class DeleteViewAction extends MutatingApiAction - { - @Override - public ApiResponse execute(DeleteViewForm form, BindException errors) - { - CustomView view = form.getCustomView(); - if (view == null) - { - throw new NotFoundException(); - } - - if (getUser().isGuest()) - { - // Guests can only delete session custom views. - if (!view.isSession()) - throw new UnauthorizedException(); - } - else - { - // Logged in users must have read permission - if (!getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new UnauthorizedException(); - } - - if (view.isShared()) - { - if (!getContainer().hasPermission(getUser(), EditSharedViewPermission.class)) - throw new UnauthorizedException(); - } - - view.delete(getUser(), getViewContext().getRequest()); - - // Delete the first shadowed custom view, if available. - if (form.isComplete()) - { - form.reset(); - CustomView shadowed = form.getCustomView(); - if (shadowed != null && shadowed.isEditable() && !(shadowed instanceof ModuleCustomView)) - { - if (!shadowed.isShared() || getContainer().hasPermission(getUser(), EditSharedViewPermission.class)) - shadowed.delete(getUser(), getViewContext().getRequest()); - } - } - - // Try to get a custom view of the same name as the view we just deleted. - // The deleted view may have been a session view or a personal view masking shared view with the same name. - form.reset(); - view = form.getCustomView(); - String nextViewName = null; - if (view != null) - nextViewName = view.getName(); - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("viewName", nextViewName); - return response; - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class SaveSessionViewForm extends QueryForm - { - private String newName; - private boolean inherit; - private boolean shared; - private boolean hidden; - private boolean replace; - private String containerPath; - - public String getNewName() - { - return newName; - } - - public void setNewName(String newName) - { - this.newName = newName; - } - - public boolean isInherit() - { - return inherit; - } - - public void setInherit(boolean inherit) - { - this.inherit = inherit; - } - - public boolean isShared() - { - return shared; - } - - public void setShared(boolean shared) - { - this.shared = shared; - } - - public String getContainerPath() - { - return containerPath; - } - - public void setContainerPath(String containerPath) - { - this.containerPath = containerPath; - } - - public boolean isHidden() - { - return hidden; - } - - public void setHidden(boolean hidden) - { - this.hidden = hidden; - } - - public boolean isReplace() - { - return replace; - } - - public void setReplace(boolean replace) - { - this.replace = replace; - } - } - - // Moves a session view into the database. - @RequiresPermission(ReadPermission.class) - public static class SaveSessionViewAction extends MutatingApiAction - { - @Override - public ApiResponse execute(SaveSessionViewForm form, BindException errors) - { - CustomView view = form.getCustomView(); - if (view == null) - { - throw new NotFoundException(); - } - if (!view.isSession()) - throw new IllegalArgumentException("This action only supports saving session views."); - - //if (!getContainer().getId().equals(view.getContainer().getId())) - // throw new IllegalArgumentException("View may only be saved from container it was created in."); - - assert !view.canInherit() && !view.isShared() && view.isEditable(): "Session view should never be inheritable or shared and always be editable"; - - // Users may save views to a location other than the current container - String containerPath = form.getContainerPath(); - Container container; - if (form.isInherit() && containerPath != null) - { - // Only respect this request if it's a view that is inheritable in subfolders - container = ContainerManager.getForPath(containerPath); - } - else - { - // Otherwise, save it in the current container - container = getContainer(); - } - - if (container == null) - throw new NotFoundException("No such container: " + containerPath); - - if (form.isShared() || form.isInherit()) - { - if (!container.hasPermission(getUser(), EditSharedViewPermission.class)) - throw new UnauthorizedException(); - } - - DbScope scope = QueryManager.get().getDbSchema().getScope(); - try (DbScope.Transaction tx = scope.ensureTransaction()) - { - // Delete the session view. The view will be restored if an exception is thrown. - view.delete(getUser(), getViewContext().getRequest()); - - // Get any previously existing non-session view. - // The session custom view and the view-to-be-saved may have different names. - // If they do have different names, we may need to delete an existing session view with that name. - // UNDONE: If the view has a different name, we will clobber it without asking. - CustomView existingView = form.getQueryDef().getCustomView(getUser(), null, form.getNewName()); - if (existingView != null && existingView.isSession()) - { - // Delete any session view we are overwriting. - existingView.delete(getUser(), getViewContext().getRequest()); - existingView = form.getQueryDef().getCustomView(getUser(), null, form.getNewName()); - } - - // save a new private view if shared is false but existing view is shared - if (existingView != null && !form.isShared() && existingView.getOwner() == null) - { - existingView = null; - } - - if (existingView != null && !form.isReplace() && !StringUtils.isEmpty(form.getNewName())) - throw new IllegalArgumentException("A saved view by the name \"" + form.getNewName() + "\" already exists. "); - - if (existingView == null || (existingView instanceof ModuleCustomView && existingView.isEditable())) - { - User owner = form.isShared() ? null : getUser(); - - CustomViewImpl viewCopy = new CustomViewImpl(form.getQueryDef(), owner, form.getNewName()); - viewCopy.setColumns(view.getColumns()); - viewCopy.setCanInherit(form.isInherit()); - viewCopy.setFilterAndSort(view.getFilterAndSort()); - viewCopy.setColumnProperties(view.getColumnProperties()); - viewCopy.setIsHidden(form.isHidden()); - if (form.isInherit()) - viewCopy.setContainer(container); - - viewCopy.save(getUser(), getViewContext().getRequest()); - } - else if (!existingView.isEditable()) - { - throw new IllegalArgumentException("Existing view '" + form.getNewName() + "' is not editable. You may save this view with a different name."); - } - else - { - // UNDONE: changing shared property of an existing view is unimplemented. Not sure if it makes sense from a usability point of view. - existingView.setColumns(view.getColumns()); - existingView.setFilterAndSort(view.getFilterAndSort()); - existingView.setColumnProperties(view.getColumnProperties()); - existingView.setCanInherit(form.isInherit()); - if (form.isInherit()) - ((CustomViewImpl)existingView).setContainer(container); - existingView.setIsHidden(form.isHidden()); - - existingView.save(getUser(), getViewContext().getRequest()); - } - - tx.commit(); - return new ApiSimpleResponse("success", true); - } - catch (Exception e) - { - // dirty the view then save the deleted session view back in session state - view.setName(view.getName()); - view.save(getUser(), getViewContext().getRequest()); - - throw e; - } - } - } - - /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ - @RequiresPermission(AdminPermission.class) - public class ManageViewsAction extends SimpleViewAction - { - @SuppressWarnings("UnusedDeclaration") - public ManageViewsAction() - { - } - - public ManageViewsAction(ViewContext ctx) - { - setViewContext(ctx); - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - return new JspView<>("/org/labkey/query/view/manageViews.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - new BeginAction(getViewContext()).addNavTrail(root); - root.addChild("Manage Views", QueryController.this.getViewContext().getActionURL()); - } - } - - - /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ - @RequiresPermission(AdminPermission.class) - public class InternalDeleteView extends ConfirmAction - { - @Override - public ModelAndView getConfirmView(InternalViewForm form, BindException errors) - { - return new JspView<>("/org/labkey/query/view/internalDeleteView.jsp", form, errors); - } - - @Override - public boolean handlePost(InternalViewForm form, BindException errors) - { - CstmView view = form.getViewAndCheckPermission(); - QueryManager.get().delete(view); - return true; - } - - @Override - public void validateCommand(InternalViewForm internalViewForm, Errors errors) - { - } - - @Override - @NotNull - public ActionURL getSuccessURL(InternalViewForm internalViewForm) - { - return new ActionURL(ManageViewsAction.class, getContainer()); - } - } - - /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ - @RequiresPermission(AdminPermission.class) - public class InternalSourceViewAction extends FormViewAction - { - @Override - public void validateCommand(InternalSourceViewForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(InternalSourceViewForm form, boolean reshow, BindException errors) - { - CstmView view = form.getViewAndCheckPermission(); - form.ff_inherit = QueryManager.get().canInherit(view.getFlags()); - form.ff_hidden = QueryManager.get().isHidden(view.getFlags()); - form.ff_columnList = view.getColumns(); - form.ff_filter = view.getFilter(); - return new JspView<>("/org/labkey/query/view/internalSourceView.jsp", form, errors); - } - - @Override - public boolean handlePost(InternalSourceViewForm form, BindException errors) - { - CstmView view = form.getViewAndCheckPermission(); - int flags = view.getFlags(); - flags = QueryManager.get().setCanInherit(flags, form.ff_inherit); - flags = QueryManager.get().setIsHidden(flags, form.ff_hidden); - view.setFlags(flags); - view.setColumns(form.ff_columnList); - view.setFilter(form.ff_filter); - QueryManager.get().update(getUser(), view); - return true; - } - - @Override - public ActionURL getSuccessURL(InternalSourceViewForm form) - { - return new ActionURL(ManageViewsAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - new ManageViewsAction(getViewContext()).addNavTrail(root); - root.addChild("Edit source of Grid View"); - } - } - - /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ - @RequiresPermission(AdminPermission.class) - public class InternalNewViewAction extends FormViewAction - { - int _customViewId = 0; - - @Override - public void validateCommand(InternalNewViewForm form, Errors errors) - { - if (StringUtils.trimToNull(form.ff_schemaName) == null) - { - errors.reject(ERROR_MSG, "Schema name cannot be blank."); - } - if (StringUtils.trimToNull(form.ff_queryName) == null) - { - errors.reject(ERROR_MSG, "Query name cannot be blank"); - } - } - - @Override - public ModelAndView getView(InternalNewViewForm form, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/query/view/internalNewView.jsp", form, errors); - } - - @Override - public boolean handlePost(InternalNewViewForm form, BindException errors) - { - if (form.ff_share) - { - if (!getContainer().hasPermission(getUser(), AdminPermission.class)) - throw new UnauthorizedException(); - } - List existing = QueryManager.get().getCstmViews(getContainer(), form.ff_schemaName, form.ff_queryName, form.ff_viewName, form.ff_share ? null : getUser(), false, false); - CstmView view; - if (!existing.isEmpty()) - { - } - else - { - view = new CstmView(); - view.setSchema(form.ff_schemaName); - view.setQueryName(form.ff_queryName); - view.setName(form.ff_viewName); - view.setContainerId(getContainer().getId()); - if (form.ff_share) - { - view.setCustomViewOwner(null); - } - else - { - view.setCustomViewOwner(getUser().getUserId()); - } - if (form.ff_inherit) - { - view.setFlags(QueryManager.get().setCanInherit(view.getFlags(), form.ff_inherit)); - } - InternalViewForm.checkEdit(getViewContext(), view); - try - { - view = QueryManager.get().insert(getUser(), view); - } - catch (Exception e) - { - LogManager.getLogger(QueryController.class).error("Error", e); - errors.reject(ERROR_MSG, "An exception occurred: " + e); - return false; - } - _customViewId = view.getCustomViewId(); - } - return true; - } - - @Override - public ActionURL getSuccessURL(InternalNewViewForm form) - { - ActionURL forward = new ActionURL(InternalSourceViewAction.class, getContainer()); - forward.addParameter("customViewId", Integer.toString(_customViewId)); - return forward; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Create New Grid View"); - } - } - - - @ActionNames("clearSelected, selectNone") - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public static class SelectNoneAction extends MutatingApiAction - { - @Override - public void validateForm(SelectForm form, Errors errors) - { - if (form.getSchemaName().isEmpty() != (form.getQueryName() == null)) - { - errors.reject(ERROR_MSG, "Both schemaName and queryName are required"); - } - } - - @Override - public ApiResponse execute(final SelectForm form, BindException errors) throws Exception - { - if (form.getQueryName() == null) - { - DataRegionSelection.clearAll(getViewContext(), form.getKey()); - return new DataRegionSelection.SelectionResponse(0); - } - - int count = DataRegionSelection.setSelectedFromForm(form); - return new DataRegionSelection.SelectionResponse(count); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class SelectForm extends QueryForm - { - protected boolean clearSelected; - protected String key; - - public boolean isClearSelected() - { - return clearSelected; - } - - public void setClearSelected(boolean clearSelected) - { - this.clearSelected = clearSelected; - } - - public String getKey() - { - return key; - } - - public void setKey(String key) - { - this.key = key; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectData.class) - public static class SelectAllAction extends MutatingApiAction - { - @Override - public void validateForm(QueryForm form, Errors errors) - { - if (form.getSchemaName().isEmpty() || form.getQueryName() == null) - { - errors.reject(ERROR_MSG, "schemaName and queryName required"); - } - } - - @Override - public ApiResponse execute(final QueryForm form, BindException errors) throws Exception - { - int count = DataRegionSelection.setSelectionForAll(form, true); - return new DataRegionSelection.SelectionResponse(count); - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetSelectedAction extends ReadOnlyApiAction - { - @Override - public void validateForm(SelectForm form, Errors errors) - { - if (form.getSchemaName().isEmpty() != (form.getQueryName() == null)) - { - errors.reject(ERROR_MSG, "Both schemaName and queryName are required"); - } - } - - @Override - public ApiResponse execute(final SelectForm form, BindException errors) throws Exception - { - getViewContext().getResponse().setHeader("Content-Type", CONTENT_TYPE_JSON); - if (form.getQueryName() == null) - { - Set selected = DataRegionSelection.getSelected(getViewContext(), form.getKey(), form.isClearSelected()); - return new ApiSimpleResponse("selected", selected); - } - else - { - Set selected = DataRegionSelection.getSelected(form, form.isClearSelected()); - return new ApiSimpleResponse("selected", selected); - } - } - } - - @ActionNames("setSelected, setCheck") - @RequiresPermission(ReadPermission.class) - public static class SetCheckAction extends MutatingApiAction - { - @Override - public ApiResponse execute(final SetCheckForm form, BindException errors) throws Exception - { - String[] ids = form.getId(getViewContext().getRequest()); - Set selection = new LinkedHashSet<>(); - if (ids != null) - { - for (String id : ids) - { - if (StringUtils.isNotBlank(id)) - selection.add(id); - } - } - - int count; - if (form.getQueryName() != null && form.isValidateIds() && form.isChecked()) - { - selection = DataRegionSelection.getValidatedIds(selection, form); - } - - count = DataRegionSelection.setSelected( - getViewContext(), form.getKey(), - selection, form.isChecked()); - - return new DataRegionSelection.SelectionResponse(count); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class SetCheckForm extends SelectForm - { - protected String[] ids; - protected boolean checked; - protected boolean validateIds; - - public String[] getId(HttpServletRequest request) - { - // 5025 : DataRegion checkbox names may contain comma - // Beehive parses a single parameter value with commas into an array - // which is not what we want. - String[] paramIds = request.getParameterValues("id"); - return paramIds == null ? ids: paramIds; - } - - public void setId(String[] ids) - { - this.ids = ids; - } - - public boolean isChecked() - { - return checked; - } - - public void setChecked(boolean checked) - { - this.checked = checked; - } - - public boolean isValidateIds() - { - return validateIds; - } - - public void setValidateIds(boolean validateIds) - { - this.validateIds = validateIds; - } - } - - @RequiresPermission(ReadPermission.class) - public static class ReplaceSelectedAction extends MutatingApiAction - { - @Override - public ApiResponse execute(final SetCheckForm form, BindException errors) - { - String[] ids = form.getId(getViewContext().getRequest()); - List selection = new ArrayList<>(); - if (ids != null) - { - for (String id : ids) - { - if (StringUtils.isNotBlank(id)) - selection.add(id); - } - } - - - DataRegionSelection.clearAll(getViewContext(), form.getKey()); - int count = DataRegionSelection.setSelected( - getViewContext(), form.getKey(), - selection, true); - return new DataRegionSelection.SelectionResponse(count); - } - } - - @RequiresPermission(ReadPermission.class) - public static class SetSnapshotSelectionAction extends MutatingApiAction - { - @Override - public ApiResponse execute(final SetCheckForm form, BindException errors) - { - String[] ids = form.getId(getViewContext().getRequest()); - List selection = new ArrayList<>(); - if (ids != null) - { - for (String id : ids) - { - if (StringUtils.isNotBlank(id)) - selection.add(id); - } - } - - DataRegionSelection.clearAll(getViewContext(), form.getKey(), true); - int count = DataRegionSelection.setSelected( - getViewContext(), form.getKey(), - selection, true, true); - return new DataRegionSelection.SelectionResponse(count); - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetSnapshotSelectionAction extends ReadOnlyApiAction - { - @Override - public void validateForm(SelectForm form, Errors errors) - { - if (StringUtils.isEmpty(form.getKey())) - { - errors.reject(ERROR_MSG, "Selection key is required"); - } - } - - @Override - public ApiResponse execute(final SelectForm form, BindException errors) throws Exception - { - List selected = DataRegionSelection.getSnapshotSelected(getViewContext(), form.getKey()); - return new ApiSimpleResponse("selected", selected); - } - } - - public static String getMessage(SqlDialect d, SQLException x) - { - return x.getMessage(); - } - - - public static class GetSchemasForm - { - private boolean _includeHidden = true; - private SchemaKey _schemaName; - - public SchemaKey getSchemaName() - { - return _schemaName; - } - - @SuppressWarnings("unused") - public void setSchemaName(SchemaKey schemaName) - { - _schemaName = schemaName; - } - - public boolean isIncludeHidden() - { - return _includeHidden; - } - - @SuppressWarnings("unused") - public void setIncludeHidden(boolean includeHidden) - { - _includeHidden = includeHidden; - } - } - - - @RequiresPermission(ReadPermission.class) - @ApiVersion(12.3) - public static class GetSchemasAction extends ReadOnlyApiAction - { - @Override - protected long getLastModified(GetSchemasForm form) - { - return QueryService.get().metadataLastModified(); - } - - @Override - public ApiResponse execute(GetSchemasForm form, BindException errors) - { - final Container container = getContainer(); - final User user = getUser(); - - final boolean includeHidden = form.isIncludeHidden(); - if (getRequestedApiVersion() >= 9.3) - { - SimpleSchemaTreeVisitor visitor = new SimpleSchemaTreeVisitor<>(includeHidden) - { - @Override - public Void visitUserSchema(UserSchema schema, Path path, JSONObject json) - { - JSONObject schemaProps = new JSONObject(); - - schemaProps.put("schemaName", schema.getName()); - schemaProps.put("fullyQualifiedName", schema.getSchemaName()); - schemaProps.put("description", schema.getDescription()); - schemaProps.put("hidden", schema.isHidden()); - NavTree tree = schema.getSchemaBrowserLinks(user); - if (tree != null && tree.hasChildren()) - schemaProps.put("menu", tree.toJSON()); - - // Collect children schemas - JSONObject children = new JSONObject(); - visit(schema.getSchemas(_includeHidden), path, children); - if (!children.isEmpty()) - schemaProps.put("schemas", children); - - // Add node's schemaProps to the parent's json. - json.put(schema.getName(), schemaProps); - return null; - } - }; - - // By default, start from the root. - QuerySchema schema; - if (form.getSchemaName() != null) - schema = DefaultSchema.get(user, container, form.getSchemaName()); - else - schema = DefaultSchema.get(user, container); - - // Ensure consistent exception as other query actions - QueryForm.ensureSchemaNotNull(schema); - - // Create the JSON response by visiting the schema children. The parent schema information isn't included. - JSONObject ret = new JSONObject(); - visitor.visitTop(schema.getSchemas(includeHidden), ret); - - return new ApiSimpleResponse(ret); - } - else - { - return new ApiSimpleResponse("schemas", DefaultSchema.get(user, container).getUserSchemaPaths(includeHidden)); - } - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class GetQueriesForm - { - private String _schemaName; - private boolean _includeUserQueries = true; - private boolean _includeSystemQueries = true; - private boolean _includeColumns = true; - private boolean _includeViewDataUrl = true; - private boolean _includeTitle = true; - private boolean _queryDetailColumns = false; - - public String getSchemaName() - { - return _schemaName; - } - - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public boolean isIncludeUserQueries() - { - return _includeUserQueries; - } - - public void setIncludeUserQueries(boolean includeUserQueries) - { - _includeUserQueries = includeUserQueries; - } - - public boolean isIncludeSystemQueries() - { - return _includeSystemQueries; - } - - public void setIncludeSystemQueries(boolean includeSystemQueries) - { - _includeSystemQueries = includeSystemQueries; - } - - public boolean isIncludeColumns() - { - return _includeColumns; - } - - public void setIncludeColumns(boolean includeColumns) - { - _includeColumns = includeColumns; - } - - public boolean isQueryDetailColumns() - { - return _queryDetailColumns; - } - - public void setQueryDetailColumns(boolean queryDetailColumns) - { - _queryDetailColumns = queryDetailColumns; - } - - public boolean isIncludeViewDataUrl() - { - return _includeViewDataUrl; - } - - public void setIncludeViewDataUrl(boolean includeViewDataUrl) - { - _includeViewDataUrl = includeViewDataUrl; - } - - public boolean isIncludeTitle() - { - return _includeTitle; - } - - public void setIncludeTitle(boolean includeTitle) - { - _includeTitle = includeTitle; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) - public static class GetQueriesAction extends ReadOnlyApiAction - { - @Override - protected long getLastModified(GetQueriesForm form) - { - return QueryService.get().metadataLastModified(); - } - - @Override - public ApiResponse execute(GetQueriesForm form, BindException errors) - { - if (null == StringUtils.trimToNull(form.getSchemaName())) - throw new IllegalArgumentException("You must supply a value for the 'schemaName' parameter!"); - - ApiSimpleResponse response = new ApiSimpleResponse(); - UserSchema uschema = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()); - if (null == uschema) - throw new NotFoundException("The schema name '" + form.getSchemaName() - + "' was not found within the folder '" + getContainer().getPath() + "'"); - - response.put("schemaName", form.getSchemaName()); - - List> qinfos = new ArrayList<>(); - - //user-defined queries - if (form.isIncludeUserQueries()) - { - for (QueryDefinition qdef : uschema.getQueryDefs().values()) - { - if (!qdef.isTemporary()) - { - ActionURL viewDataUrl = form.isIncludeViewDataUrl() ? uschema.urlFor(QueryAction.executeQuery, qdef) : null; - qinfos.add(getQueryProps(qdef, viewDataUrl, true, uschema, form.isIncludeColumns(), form.isQueryDetailColumns(), form.isIncludeTitle())); - } - } - } - - //built-in tables - if (form.isIncludeSystemQueries()) - { - for (String qname : uschema.getVisibleTableNames()) - { - // Go direct against the UserSchema instead of calling into QueryService, which takes a schema and - // query name as strings and therefore has to create new instances - QueryDefinition qdef = uschema.getQueryDefForTable(qname); - if (qdef != null) - { - ActionURL viewDataUrl = form.isIncludeViewDataUrl() ? uschema.urlFor(QueryAction.executeQuery, qdef) : null; - qinfos.add(getQueryProps(qdef, viewDataUrl, false, uschema, form.isIncludeColumns(), form.isQueryDetailColumns(), form.isIncludeTitle())); - } - } - } - response.put("queries", qinfos); - - return response; - } - - protected Map getQueryProps(QueryDefinition qdef, ActionURL viewDataUrl, boolean isUserDefined, UserSchema schema, boolean includeColumns, boolean useQueryDetailColumns, boolean includeTitle) - { - Map qinfo = new HashMap<>(); - qinfo.put("hidden", qdef.isHidden()); - qinfo.put("snapshot", qdef.isSnapshot()); - qinfo.put("inherit", qdef.canInherit()); - qinfo.put("isUserDefined", isUserDefined); - boolean canEdit = qdef.canEdit(getUser()); - qinfo.put("canEdit", canEdit); - qinfo.put("canEditSharedViews", getContainer().hasPermission(getUser(), EditSharedViewPermission.class)); - // CONSIDER: do we want to separate the 'canEditMetadata' property and 'isMetadataOverridable' properties to differentiate between capability and the permission check? - qinfo.put("isMetadataOverrideable", qdef.isMetadataEditable() && qdef.canEditMetadata(getUser())); - - if (isUserDefined) - qinfo.put("moduleName", qdef.getModuleName()); - boolean isInherited = qdef.canInherit() && !getContainer().equals(qdef.getDefinitionContainer()); - qinfo.put("isInherited", isInherited); - if (isInherited) - qinfo.put("containerPath", qdef.getDefinitionContainer().getPath()); - qinfo.put("isIncludedForLookups", qdef.isIncludedForLookups()); - - if (null != qdef.getDescription()) - qinfo.put("description", qdef.getDescription()); - if (viewDataUrl != null) - qinfo.put("viewDataUrl", viewDataUrl); - - String title = qdef.getName(); - String name = qdef.getName(); - try - { - // get the TableInfo if the user requested column info or title, otherwise skip (it can be expensive) - if (includeColumns || includeTitle) - { - TableInfo table = qdef.getTable(schema, null, true); - - if (null != table) - { - if (includeColumns) - { - Collection> columns; - - if (useQueryDetailColumns) - { - columns = JsonWriter - .getNativeColProps(table, Collections.emptyList(), null, false, false) - .values(); - } - else - { - columns = new ArrayList<>(); - for (ColumnInfo col : table.getColumns()) - { - Map cinfo = new HashMap<>(); - cinfo.put("name", col.getName()); - if (null != col.getLabel()) - cinfo.put("caption", col.getLabel()); - if (null != col.getShortLabel()) - cinfo.put("shortCaption", col.getShortLabel()); - if (null != col.getDescription()) - cinfo.put("description", col.getDescription()); - - columns.add(cinfo); - } - } - - if (!columns.isEmpty()) - qinfo.put("columns", columns); - } - - if (includeTitle) - { - name = table.getPublicName(); - title = table.getTitle(); - } - } - } - } - catch(Exception e) - { - //may happen due to query failing parse - } - - qinfo.put("title", title); - qinfo.put("name", name); - return qinfo; - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class GetQueryViewsForm - { - private String _schemaName; - private String _queryName; - private String _viewName; - private boolean _metadata; - private boolean _excludeSessionView; - - public String getSchemaName() - { - return _schemaName; - } - - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public String getQueryName() - { - return _queryName; - } - - public void setQueryName(String queryName) - { - _queryName = queryName; - } - - public String getViewName() - { - return _viewName; - } - - public void setViewName(String viewName) - { - _viewName = viewName; - } - - public boolean isMetadata() - { - return _metadata; - } - - public void setMetadata(boolean metadata) - { - _metadata = metadata; - } - - public boolean isExcludeSessionView() - { - return _excludeSessionView; - } - - public void setExcludeSessionView(boolean excludeSessionView) - { - _excludeSessionView = excludeSessionView; - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) - public static class GetQueryViewsAction extends ReadOnlyApiAction - { - @Override - protected long getLastModified(GetQueryViewsForm form) - { - return QueryService.get().metadataLastModified(); - } - - @Override - public ApiResponse execute(GetQueryViewsForm form, BindException errors) - { - if (null == StringUtils.trimToNull(form.getSchemaName())) - throw new IllegalArgumentException("You must pass a value for the 'schemaName' parameter!"); - if (null == StringUtils.trimToNull(form.getQueryName())) - throw new IllegalArgumentException("You must pass a value for the 'queryName' parameter!"); - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()); - if (null == schema) - throw new NotFoundException("The schema name '" + form.getSchemaName() - + "' was not found within the folder '" + getContainer().getPath() + "'"); - - QueryDefinition querydef = QueryService.get().createQueryDefForTable(schema, form.getQueryName()); - if (null == querydef || querydef.getTable(null, true) == null) - throw new NotFoundException("The query '" + form.getQueryName() + "' was not found within the '" - + form.getSchemaName() + "' schema in the container '" - + getContainer().getPath() + "'!"); - - Map views = querydef.getCustomViews(getUser(), getViewContext().getRequest(), true, false, form.isExcludeSessionView()); - if (null == views) - views = Collections.emptyMap(); - - Map> columnMetadata = new HashMap<>(); - - List> viewInfos = Collections.emptyList(); - if (getViewContext().getBindPropertyValues().contains("viewName")) - { - // Get info for a named view or the default view (null) - String viewName = StringUtils.trimToNull(form.getViewName()); - CustomView view = views.get(viewName); - if (view != null) - { - viewInfos = Collections.singletonList(CustomViewUtil.toMap(view, getUser(), form.isMetadata())); - } - else if (viewName == null) - { - // The default view was requested but it hasn't been customized yet. Create the 'default default' view. - viewInfos = Collections.singletonList(CustomViewUtil.toMap(getViewContext(), schema, form.getQueryName(), null, form.isMetadata(), true, columnMetadata)); - } - } - else - { - boolean foundDefault = false; - viewInfos = new ArrayList<>(views.size()); - for (CustomView view : views.values()) - { - if (view.getName() == null) - foundDefault = true; - viewInfos.add(CustomViewUtil.toMap(view, getUser(), form.isMetadata())); - } - - if (!foundDefault) - { - // The default view hasn't been customized yet. Create the 'default default' view. - viewInfos.add(CustomViewUtil.toMap(getViewContext(), schema, form.getQueryName(), null, form.isMetadata(), true, columnMetadata)); - } - } - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("schemaName", form.getSchemaName()); - response.put("queryName", form.getQueryName()); - response.put("views", viewInfos); - - return response; - } - } - - @RequiresNoPermission - public static class GetServerDateAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(Object o, BindException errors) - { - return new ApiSimpleResponse("date", new Date()); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - private static class SaveApiTestForm - { - private String _getUrl; - private String _postUrl; - private String _postData; - private String _response; - - public String getGetUrl() - { - return _getUrl; - } - - public void setGetUrl(String getUrl) - { - _getUrl = getUrl; - } - - public String getPostUrl() - { - return _postUrl; - } - - public void setPostUrl(String postUrl) - { - _postUrl = postUrl; - } - - public String getResponse() - { - return _response; - } - - public void setResponse(String response) - { - _response = response; - } - - public String getPostData() - { - return _postData; - } - - public void setPostData(String postData) - { - _postData = postData; - } - } - - - @RequiresPermission(ReadPermission.class) - public static class SaveApiTestAction extends MutatingApiAction - { - @Override - public ApiResponse execute(SaveApiTestForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - ApiTestsDocument doc = ApiTestsDocument.Factory.newInstance(); - - TestCaseType test = doc.addNewApiTests().addNewTest(); - test.setName("recorded test case"); - ActionURL url = null; - - if (!StringUtils.isEmpty(form.getGetUrl())) - { - test.setType("get"); - url = new ActionURL(form.getGetUrl()); - } - else if (!StringUtils.isEmpty(form.getPostUrl())) - { - test.setType("post"); - test.setFormData(form.getPostData()); - url = new ActionURL(form.getPostUrl()); - } - - if (url != null) - { - String uri = url.getLocalURIString(); - if (uri.startsWith(url.getContextPath())) - uri = uri.substring(url.getContextPath().length() + 1); - - test.setUrl(uri); - } - test.setResponse(form.getResponse()); - - XmlOptions opts = new XmlOptions(); - opts.setSaveCDataEntityCountThreshold(0); - opts.setSaveCDataLengthThreshold(0); - opts.setSavePrettyPrint(); - opts.setUseDefaultNamespace(); - - response.put("xml", doc.xmlText(opts)); - - return response; - } - } - - - private abstract static class ParseAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - List qpe = new ArrayList<>(); - String expr = getViewContext().getRequest().getParameter("q"); - ArrayList html = new ArrayList<>(); - PageConfig config = getPageConfig(); - var inputId = config.makeId("submit_"); - config.addHandler(inputId, "click", "Ext.getBody().mask();"); - html.add("
\n" + - "" - ); - - QNode e = null; - if (null != expr) - { - try - { - e = _parse(expr,qpe); - } - catch (RuntimeException x) - { - qpe.add(new QueryParseException(x.getMessage(),x, 0, 0)); - } - } - - Tree tree = null; - if (null != expr) - { - try - { - tree = _tree(expr); - } catch (Exception x) - { - qpe.add(new QueryParseException(x.getMessage(),x, 0, 0)); - } - } - - for (Throwable x : qpe) - { - if (null != x.getCause() && x != x.getCause()) - x = x.getCause(); - html.add("
" + PageFlowUtil.filter(x.toString())); - LogManager.getLogger(QueryController.class).debug(expr,x); - } - if (null != e) - { - String prefix = SqlParser.toPrefixString(e); - html.add("
"); - html.add(PageFlowUtil.filter(prefix)); - } - if (null != tree) - { - String prefix = SqlParser.toPrefixString(tree); - html.add("
"); - html.add(PageFlowUtil.filter(prefix)); - } - html.add(""); - return HtmlView.unsafe(StringUtils.join(html,"")); - } - - @Override - public void addNavTrail(NavTree root) - { - } - - abstract QNode _parse(String e, List errors); - abstract Tree _tree(String e) throws Exception; - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class ParseExpressionAction extends ParseAction - { - @Override - QNode _parse(String s, List errors) - { - return new SqlParser().parseExpr(s, true, errors); - } - - @Override - Tree _tree(String e) - { - return null; - } - } - - @RequiresPermission(AdminOperationsPermission.class) - public static class ParseQueryAction extends ParseAction - { - @Override - QNode _parse(String s, List errors) - { - return new SqlParser().parseQuery(s, errors, null); - } - - @Override - Tree _tree(String s) throws Exception - { - return new SqlParser().rawQuery(s); - } - } - - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.SelectMetaData.class) - public static class ValidateQueryMetadataAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(QueryForm form, BindException errors) - { - UserSchema schema = form.getSchema(); - - if (null == schema) - { - errors.reject(ERROR_MSG, "could not resolve schema: " + form.getSchemaName()); - return null; - } - - List parseErrors = new ArrayList<>(); - List parseWarnings = new ArrayList<>(); - ApiSimpleResponse response = new ApiSimpleResponse(); - - try - { - TableInfo table = schema.getTable(form.getQueryName(), null); - - if (null == table) - { - errors.reject(ERROR_MSG, "could not resolve table: " + form.getQueryName()); - return null; - } - - if (!QueryManager.get().validateQuery(table, true, parseErrors, parseWarnings)) - { - for (QueryParseException e : parseErrors) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - return response; - } - - SchemaKey schemaKey = SchemaKey.fromString(form.getSchemaName()); - QueryManager.get().validateQueryMetadata(schemaKey, form.getQueryName(), getUser(), getContainer(), parseErrors, parseWarnings); - QueryManager.get().validateQueryViews(schemaKey, form.getQueryName(), getUser(), getContainer(), parseErrors, parseWarnings); - } - catch (QueryParseException e) - { - parseErrors.add(e); - } - - for (QueryParseException e : parseErrors) - { - errors.reject(ERROR_MSG, e.getMessage()); - } - - for (QueryParseException e : parseWarnings) - { - errors.reject(ERROR_MSG, "WARNING: " + e.getMessage()); - } - - return response; - } - - @Override - protected ApiResponseWriter createResponseWriter() throws IOException - { - ApiResponseWriter result = super.createResponseWriter(); - // Issue 44875 - don't send a 400 or 500 response code when there's a bogus query or metadata - result.setErrorResponseStatus(HttpServletResponse.SC_OK); - return result; - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class QueryExportAuditForm - { - private int rowId; - - public int getRowId() - { - return rowId; - } - - public void setRowId(int rowId) - { - this.rowId = rowId; - } - } - - /** - * Action used to redirect QueryAuditProvider [details] column to the exported table's grid view. - */ - @RequiresPermission(AdminPermission.class) - public static class QueryExportAuditRedirectAction extends SimpleRedirectAction - { - @Override - public URLHelper getRedirectURL(QueryExportAuditForm form) - { - if (form.getRowId() == 0) - throw new NotFoundException("Query export audit rowid required"); - - UserSchema auditSchema = QueryService.get().getUserSchema(getUser(), getContainer(), AbstractAuditTypeProvider.QUERY_SCHEMA_NAME); - TableInfo queryExportAuditTable = auditSchema.getTable(QueryExportAuditProvider.QUERY_AUDIT_EVENT, null); - if (null == queryExportAuditTable) - throw new NotFoundException(); - - TableSelector selector = new TableSelector(queryExportAuditTable, - PageFlowUtil.set( - QueryExportAuditProvider.COLUMN_NAME_SCHEMA_NAME, - QueryExportAuditProvider.COLUMN_NAME_QUERY_NAME, - QueryExportAuditProvider.COLUMN_NAME_DETAILS_URL), - new SimpleFilter(FieldKey.fromParts(AbstractAuditTypeProvider.COLUMN_NAME_ROW_ID), form.getRowId()), null); - - Map result = selector.getMap(); - if (result == null) - throw new NotFoundException("Query export audit event not found for rowId"); - - String schemaName = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_SCHEMA_NAME); - String queryName = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_QUERY_NAME); - String detailsURL = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_DETAILS_URL); - - if (schemaName == null || queryName == null) - throw new NotFoundException("Query export audit event has not schemaName or queryName"); - - ActionURL url = new ActionURL(ExecuteQueryAction.class, getContainer()); - - // Apply the sorts and filters - if (detailsURL != null) - { - ActionURL sortFilterURL = new ActionURL(detailsURL); - url.setPropertyValues(sortFilterURL.getPropertyValues()); - } - - if (url.getParameter(QueryParam.schemaName) == null) - url.addParameter(QueryParam.schemaName, schemaName); - if (url.getParameter(QueryParam.queryName) == null && url.getParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName) == null) - url.addParameter(QueryParam.queryName, queryName); - - return url; - } - } - - @RequiresPermission(ReadPermission.class) - public static class AuditHistoryAction extends SimpleViewAction - { - @Override - public ModelAndView getView(QueryForm form, BindException errors) - { - return QueryUpdateAuditProvider.createHistoryQueryView(getViewContext(), form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Audit History"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class AuditDetailsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(QueryDetailsForm form, BindException errors) - { - return QueryUpdateAuditProvider.createDetailsQueryView(getViewContext(), form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Audit History"); - } - } - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class QueryDetailsForm extends QueryForm - { - String _keyValue; - - public String getKeyValue() - { - return _keyValue; - } - - public void setKeyValue(String keyValue) - { - _keyValue = keyValue; - } - } - - @RequiresPermission(ReadPermission.class) - @Action(ActionType.Export.class) - public static class ExportTablesAction extends FormViewAction - { - private ActionURL _successUrl; - - @Override - public void validateCommand(ExportTablesForm form, Errors errors) - { - } - - @Override - public boolean handlePost(ExportTablesForm form, BindException errors) - { - HttpServletResponse httpResponse = getViewContext().getResponse(); - Container container = getContainer(); - QueryServiceImpl svc = (QueryServiceImpl)QueryService.get(); - - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); OutputStream outputStream = new BufferedOutputStream(baos)) - { - try (ZipFile zip = new ZipFile(outputStream, true)) - { - svc.writeTables(container, getUser(), zip, form.getSchemas(), form.getHeaderType()); - } - - PageFlowUtil.streamFileBytes(httpResponse, FileUtil.makeFileNameWithTimestamp(container.getName(), "tables.zip"), baos.toByteArray(), false); - } - catch (Exception e) - { - errors.reject(ERROR_MSG, e.getMessage() != null ? e.getMessage() : e.getClass().getName()); - LOG.error("Errror exporting tables", e); - } - - if (errors.hasErrors()) - { - _successUrl = new ActionURL(ExportTablesAction.class, getContainer()); - } - - return !errors.hasErrors(); - } - - @Override - public ModelAndView getView(ExportTablesForm form, boolean reshow, BindException errors) - { - // When exporting the zip to the browser, the base action will attempt to reshow the view since we returned - // null as the success URL; returning null here causes the base action to stop pestering the action. - if (reshow && !errors.hasErrors()) - return null; - - return new JspView<>("/org/labkey/query/view/exportTables.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Export Tables"); - } - - @Override - public ActionURL getSuccessURL(ExportTablesForm form) - { - return _successUrl; - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class ExportTablesForm implements HasBindParameters - { - ColumnHeaderType _headerType = ColumnHeaderType.DisplayFieldKey; - Map>> _schemas = new HashMap<>(); - - public ColumnHeaderType getHeaderType() - { - return _headerType; - } - - public void setHeaderType(ColumnHeaderType headerType) - { - _headerType = headerType; - } - - public Map>> getSchemas() - { - return _schemas; - } - - public void setSchemas(Map>> schemas) - { - _schemas = schemas; - } - - @Override - public @NotNull BindException bindParameters(PropertyValues values) - { - BindException errors = new NullSafeBindException(this, "form"); - - PropertyValue schemasProperty = values.getPropertyValue("schemas"); - if (schemasProperty != null && schemasProperty.getValue() != null) - { - try - { - _schemas = JsonUtil.DEFAULT_MAPPER.readValue((String)schemasProperty.getValue(), _schemas.getClass()); - } - catch (IOException e) - { - errors.rejectValue("schemas", ERROR_MSG, e.getMessage()); - } - } - - PropertyValue headerTypeProperty = values.getPropertyValue("headerType"); - if (headerTypeProperty != null && headerTypeProperty.getValue() != null) - { - try - { - _headerType = ColumnHeaderType.valueOf(String.valueOf(headerTypeProperty.getValue())); - } - catch (IllegalArgumentException ex) - { - // ignore - } - } - - return errors; - } - } - - - @RequiresPermission(ReadPermission.class) - public static class SaveNamedSetAction extends MutatingApiAction - { - @Override - public Object execute(NamedSetForm namedSetForm, BindException errors) - { - QueryService.get().saveNamedSet(namedSetForm.getSetName(), namedSetForm.parseSetList()); - return new ApiSimpleResponse("success", true); - } - } - - - @SuppressWarnings({"unused", "WeakerAccess"}) - public static class NamedSetForm - { - String setName; - String[] setList; - - public String getSetName() - { - return setName; - } - - public void setSetName(String setName) - { - this.setName = setName; - } - - public String[] getSetList() - { - return setList; - } - - public void setSetList(String[] setList) - { - this.setList = setList; - } - - public List parseSetList() - { - return Arrays.asList(setList); - } - } - - - @RequiresPermission(ReadPermission.class) - public static class DeleteNamedSetAction extends MutatingApiAction - { - - @Override - public Object execute(NamedSetForm namedSetForm, BindException errors) - { - QueryService.get().deleteNamedSet(namedSetForm.getSetName()); - return new ApiSimpleResponse("success", true); - } - } - - @RequiresPermission(ReadPermission.class) - public static class AnalyzeQueriesAction extends ReadOnlyApiAction - { - @Override - public Object execute(Object o, BindException errors) throws Exception - { - JSONObject ret = new JSONObject(); - - try - { - QueryService.QueryAnalysisService analysisService = QueryService.get().getQueryAnalysisService(); - if (analysisService != null) - { - DefaultSchema start = DefaultSchema.get(getUser(), getContainer()); - var deps = new HashSetValuedHashMap(); - - analysisService.analyzeFolder(start, deps); - ret.put("success", true); - - JSONObject objects = new JSONObject(); - for (var from : deps.keySet()) - { - objects.put(from.getKey(), from.toJSON()); - for (var to : deps.get(from)) - objects.put(to.getKey(), to.toJSON()); - } - ret.put("objects", objects); - - JSONArray dependants = new JSONArray(); - for (var from : deps.keySet()) - { - for (var to : deps.get(from)) - dependants.put(new String[] {from.getKey(), to.getKey()}); - } - ret.put("graph", dependants); - } - else - { - ret.put("success", false); - } - return ret; - } - catch (Throwable e) - { - LOG.error(e); - throw UnexpectedException.wrap(e); - } - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetQueryEditorMetadataAction extends ReadOnlyApiAction - { - @Override - protected ObjectMapper createRequestObjectMapper() - { - PropertyService propertyService = PropertyService.get(); - if (null != propertyService) - { - ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy(); - mapper.addMixIn(GWTPropertyDescriptor.class, MetadataTableJSONMixin.class); - return mapper; - } - else - { - throw new RuntimeException("Could not serialize request object"); - } - } - - @Override - protected ObjectMapper createResponseObjectMapper() - { - return createRequestObjectMapper(); - } - - @Override - public Object execute(QueryForm queryForm, BindException errors) throws Exception - { - QueryDefinition queryDef = queryForm.getQueryDef(); - return MetadataTableJSON.getMetadata(queryDef.getSchema().getSchemaName(), queryDef.getName(), getUser(), getContainer()); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - public static class SaveQueryMetadataAction extends MutatingApiAction - { - @Override - protected ObjectMapper createRequestObjectMapper() - { - PropertyService propertyService = PropertyService.get(); - if (null != propertyService) - { - ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy(); - propertyService.configureObjectMapper(mapper, null); - return mapper; - } - else - { - throw new RuntimeException("Could not serialize request object"); - } - } - - @Override - protected ObjectMapper createResponseObjectMapper() - { - return createRequestObjectMapper(); - } - - @Override - public Object execute(QueryMetadataApiForm queryMetadataApiForm, BindException errors) throws Exception - { - String schemaName = queryMetadataApiForm.getSchemaName(); - MetadataTableJSON domain = queryMetadataApiForm.getDomain(); - MetadataTableJSON.saveMetadata(schemaName, domain.getName(), null, domain.getFields(true), queryMetadataApiForm.isUserDefinedQuery(), false, getUser(), getContainer()); - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", true); - resp.put("domain", MetadataTableJSON.getMetadata(schemaName, domain.getName(), getUser(), getContainer())); - return resp; - } - } - - @Marshal(Marshaller.Jackson) - @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) - public static class ResetQueryMetadataAction extends MutatingApiAction - { - @Override - public Object execute(QueryForm queryForm, BindException errors) throws Exception - { - QueryDefinition queryDef = queryForm.getQueryDef(); - return MetadataTableJSON.resetToDefault(queryDef.getSchema().getSchemaName(), queryDef.getName(), getUser(), getContainer()); - } - } - - private static class QueryMetadataApiForm - { - private MetadataTableJSON _domain; - private String _schemaName; - private boolean _userDefinedQuery; - - public MetadataTableJSON getDomain() - { - return _domain; - } - - @SuppressWarnings("unused") - public void setDomain(MetadataTableJSON domain) - { - _domain = domain; - } - - public String getSchemaName() - { - return _schemaName; - } - - @SuppressWarnings("unused") - public void setSchemaName(String schemaName) - { - _schemaName = schemaName; - } - - public boolean isUserDefinedQuery() - { - return _userDefinedQuery; - } - - @SuppressWarnings("unused") - public void setUserDefinedQuery(boolean userDefinedQuery) - { - _userDefinedQuery = userDefinedQuery; - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetDefaultVisibleColumnsAction extends ReadOnlyApiAction - { - @Override - public Object execute(GetQueryDetailsAction.Form form, BindException errors) throws Exception - { - ApiSimpleResponse resp = new ApiSimpleResponse(); - - Container container = getContainer(); - User user = getUser(); - - if (StringUtils.isEmpty(form.getSchemaName())) - throw new NotFoundException("SchemaName not specified"); - - QuerySchema querySchema = DefaultSchema.get(user, container, form.getSchemaName()); - if (!(querySchema instanceof UserSchema schema)) - throw new NotFoundException("Could not find the specified schema in the folder '" + container.getPath() + "'"); - - QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, form.getQueryName()); - QueryDefinition queryDef = settings.getQueryDef(schema); - if (null == queryDef) - // Don't echo the provided query name, but schema name is legit since it was found. See #44528. - throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'"); - - TableInfo tinfo = queryDef.getTable(null, true); - if (null == tinfo) - throw new NotFoundException("Could not find the specified query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); - - List fields = tinfo.getDefaultVisibleColumns(); - - List displayColumns = QueryService.get().getColumns(tinfo, fields) - .values() - .stream() - .filter(cinfo -> fields.contains(cinfo.getFieldKey())) - .map(cinfo -> cinfo.getDisplayColumnFactory().createRenderer(cinfo)) - .collect(Collectors.toList()); - - resp.put("columns", JsonWriter.getNativeColProps(displayColumns, null, false)); - - return resp; - } - } - - public static class ParseForm implements ApiJsonForm - { - String expression = ""; - Map columnMap = new HashMap<>(); - List phiColumns = new ArrayList<>(); - - Map getColumnMap() - { - return columnMap; - } - - public String getExpression() - { - return expression; - } - - public void setExpression(String expression) - { - this.expression = expression; - } - - public List getPhiColumns() - { - return phiColumns; - } - - public void setPhiColumns(List phiColumns) - { - this.phiColumns = phiColumns; - } - - @Override - public void bindJson(JSONObject json) - { - if (json.has("expression")) - setExpression(json.getString("expression")); - if (json.has("phiColumns")) - setPhiColumns(json.getJSONArray("phiColumns").toList().stream().map(s -> FieldKey.fromParts(s.toString())).collect(Collectors.toList())); - if (json.has("columnMap")) - { - JSONObject columnMap = json.getJSONObject("columnMap"); - for (String key : columnMap.keySet()) - { - try - { - getColumnMap().put(FieldKey.fromParts(key), JdbcType.valueOf(String.valueOf(columnMap.get(key)))); - } - catch (IllegalArgumentException iae) - { - getColumnMap().put(FieldKey.fromParts(key), JdbcType.OTHER); - } - } - } - } - } - - - /** - * Since this api purpose is to return parse errors, it does not generally return success:false. - *
- * The API expects JSON like this, note that column names should be in FieldKey.toString() encoded to match the response JSON format. - *
-     *     { "expression": "A$ + B", "columnMap":{"A$D":"VARCHAR", "X":"VARCHAR"}}
-     * 
- * and returns a response like this - *
-     *     {
-     *       "jdbcType" : "OTHER",
-     *       "success" : true,
-     *       "columnMap" : {"A$D":"VARCHAR", "B":"OTHER"}
-     *       "errors" : [ { "msg" : "\"B\" not found.", "type" : "sql" } ]
-     *     }
-     * 
- * The columnMap object keys are the names of columns found in the expression. Names are returned - * in FieldKey.toString() formatting e.g. dollar-sign encoded. The object structure - * is compatible with the columnMap input parameter, so it can be used as a template to make a second request - * with types filled in. If provided, the type will be copied from the input columnMap, otherwise it will be "OTHER". - *
- * Parse exceptions may contain a line (usually 1) and col location e.g. - *
-     * {
-     *     "msg" : "Error on line 1: Syntax error near 'error', expected 'EOF'
-     *     "col" : 2,
-     *     "line" : 1,
-     *     "type" : "sql",
-     *     "errorStr" : "A error B"
-     *   }
-     * 
- */ - @RequiresNoPermission - @CSRF(CSRF.Method.NONE) - public static class ParseCalculatedColumnAction extends ReadOnlyApiAction - { - @Override - public Object execute(ParseForm form, BindException errors) throws Exception - { - if (errors.hasErrors()) - return errors; - JSONObject result = new JSONObject(Map.of("success",true)); - var requiredColumns = new HashSet(); - JdbcType jdbcType = JdbcType.OTHER; - try - { - var schema = DefaultSchema.get(getViewContext().getUser(), getViewContext().getContainer()).getUserSchema("core"); - var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; - ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), form.getExpression(), null); - Map columns = new HashMap<>(); - for (var entry : form.getColumnMap().entrySet()) - { - BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); - // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions - // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects - if (form.getPhiColumns().contains(entry.getKey())) - entryCol.setPHI(PHI.PHI); - columns.put(entry.getKey(), entryCol); - table.addColumn(entryCol); - } - // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() - QueryServiceImpl.get().bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); - jdbcType = calculatedCol.getJdbcType(); - } - catch (QueryException x) - { - JSONArray parseErrors = new JSONArray(); - parseErrors.put(x.toJSON(form.getExpression())); - result.put("errors", parseErrors); - } - finally - { - if (!requiredColumns.isEmpty()) - { - JSONObject columnMap = new JSONObject(); - for (FieldKey fk : requiredColumns) - { - JdbcType type = Objects.requireNonNullElse(form.getColumnMap().get(fk), JdbcType.OTHER); - columnMap.put(fk.toString(), type); - } - result.put("columnMap", columnMap); - } - } - result.put("jdbcType", jdbcType.name()); - return result; - } - } - - @JsonIgnoreProperties(ignoreUnknown = true) - public static class QueryImportTemplateForm - { - private String schemaName; - private String queryName; - private String auditUserComment; - private List templateLabels; - private List templateUrls; - private Long _lastKnownModified; - - public void setQueryName(String queryName) - { - this.queryName = queryName; - } - - public List getTemplateLabels() - { - return templateLabels == null ? Collections.emptyList() : templateLabels; - } - - public void setTemplateLabels(List templateLabels) - { - this.templateLabels = templateLabels; - } - - public List getTemplateUrls() - { - return templateUrls == null ? Collections.emptyList() : templateUrls; - } - - public void setTemplateUrls(List templateUrls) - { - this.templateUrls = templateUrls; - } - - public String getSchemaName() - { - return schemaName; - } - - @SuppressWarnings("unused") - public void setSchemaName(String schemaName) - { - this.schemaName = schemaName; - } - - public String getQueryName() - { - return queryName; - } - - public Long getLastKnownModified() - { - return _lastKnownModified; - } - - public void setLastKnownModified(Long lastKnownModified) - { - _lastKnownModified = lastKnownModified; - } - - public String getAuditUserComment() - { - return auditUserComment; - } - - public void setAuditUserComment(String auditUserComment) - { - this.auditUserComment = auditUserComment; - } - - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) //Real permissions will be enforced later on by the DomainKind - public static class UpdateQueryImportTemplateAction extends MutatingApiAction - { - private DomainKind _kind; - private UserSchema _schema; - private TableInfo _tInfo; - private QueryDefinition _queryDef; - private Domain _domain; - - @Override - protected ObjectMapper createResponseObjectMapper() - { - return this.createRequestObjectMapper(); - } - - @Override - public void validateForm(QueryImportTemplateForm form, Errors errors) - { - User user = getUser(); - Container container = getContainer(); - String domainURI = PropertyService.get().getDomainURI(form.getSchemaName(), form.getQueryName(), container, user); - _kind = PropertyService.get().getDomainKind(domainURI); - _domain = PropertyService.get().getDomain(container, domainURI); - if (_domain == null) - throw new IllegalArgumentException("Domain '" + domainURI + "' not found."); - - if (!_kind.canEditDefinition(user, _domain)) - throw new UnauthorizedException("You don't have permission to update import templates for this domain."); - - QuerySchema querySchema = DefaultSchema.get(user, container, form.getSchemaName()); - if (!(querySchema instanceof UserSchema _schema)) - throw new NotFoundException("Could not find the specified schema in the folder '" + container.getPath() + "'."); - QuerySettings settings = _schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, form.getQueryName()); - _queryDef = settings.getQueryDef(_schema); - if (null == _queryDef) - throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'."); - if (!_queryDef.isMetadataEditable()) - throw new UnsupportedOperationException("Query metadata is not editable."); - _tInfo = _queryDef.getTable(_schema, new ArrayList<>(), true, true); - if (_tInfo == null) - throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'."); - - } - - private Map getRowFiles() - { - Map rowFiles = new IntHashMap<>(); - if (getFileMap() != null) - { - for (Map.Entry fileEntry : getFileMap().entrySet()) - { - // allow for the fileMap key to include the row index for defining which row to attach this file to - // ex: "templateFile::0", "templateFile::1" - String fieldKey = fileEntry.getKey(); - int delimIndex = fieldKey.lastIndexOf("::"); - if (delimIndex > -1) - { - Integer fieldRowIndex = Integer.parseInt(fieldKey.substring(delimIndex + 2)); - SpringAttachmentFile file = new SpringAttachmentFile(fileEntry.getValue()); - rowFiles.put(fieldRowIndex, file.isEmpty() ? null : file); - } - } - } - return rowFiles; - } - - private List> getUploadedTemplates(QueryImportTemplateForm form, DomainKind kind) throws ValidationException, QueryUpdateServiceException, ExperimentException - { - FileContentService fcs = FileContentService.get(); - if (fcs == null) - throw new IllegalStateException("Unable to load file service."); - - User user = getUser(); - Container container = getContainer(); - - Map rowFiles = getRowFiles(); - List templateLabels = form.getTemplateLabels(); - Set labels = new HashSet<>(templateLabels); - if (labels.size() < templateLabels.size()) - throw new IllegalArgumentException("Duplicate template name is not allowed."); - - List templateUrls = form.getTemplateUrls(); - List> uploadedTemplates = new ArrayList<>(); - for (int rowIndex = 0; rowIndex < form.getTemplateLabels().size(); rowIndex++) - { - String templateLabel = templateLabels.get(rowIndex); - if (StringUtils.isBlank(templateLabel.trim())) - throw new IllegalArgumentException("Template name cannot be blank."); - String templateUrl = templateUrls.get(rowIndex); - Object file = rowFiles.get(rowIndex); - if (StringUtils.isEmpty(templateUrl) && file == null) - throw new IllegalArgumentException("Template file is not provided."); - - if (file instanceof MultipartFile || file instanceof SpringAttachmentFile) - { - String fileName; - if (file instanceof MultipartFile f) - fileName = f.getName(); - else - { - SpringAttachmentFile f = (SpringAttachmentFile) file; - fileName = f.getFilename(); - } - String fileNameValidation = FileUtil.validateFileName(fileName); - if (!StringUtils.isEmpty(fileNameValidation)) - throw new IllegalArgumentException(fileNameValidation); - - FileLike uploadDir = ensureUploadDirectory(container, kind.getDomainFileDirectory()); - uploadDir = uploadDir.resolveChild("_templates"); - Object savedFile = saveFile(user, container, "template file", file, uploadDir); - Path savedFilePath; - - if (savedFile instanceof File ioFile) - savedFilePath = ioFile.toPath(); - else if (savedFile instanceof FileLike fl) - savedFilePath = fl.toNioPathForRead(); - else - throw UnexpectedException.wrap(null,"Unable to upload template file."); - - templateUrl = fcs.getWebDavUrl(savedFilePath, container, FileContentService.PathType.serverRelative).toString(); - } - - uploadedTemplates.add(Pair.of(templateLabel, templateUrl)); - } - return uploadedTemplates; - } - - @Override - public Object execute(QueryImportTemplateForm form, BindException errors) throws ValidationException, QueryUpdateServiceException, SQLException, ExperimentException, MetadataUnavailableException - { - User user = getUser(); - Container container = getContainer(); - String schemaName = form.getSchemaName(); - String queryName = form.getQueryName(); - QueryDef queryDef = QueryManager.get().getQueryDef(container, schemaName, queryName, false); - if (queryDef != null && queryDef.getQueryDefId() != 0) - { - Long lastKnownModified = form.getLastKnownModified(); - if (lastKnownModified == null || lastKnownModified != queryDef.getModified().getTime()) - throw new ApiUsageException("Unable to save import templates. The templates appear out of date, reload the page and try again."); - } - - List> updatedTemplates = getUploadedTemplates(form, _kind); - - List> existingTemplates = _tInfo.getImportTemplates(getViewContext()); - List> existingCustomTemplates = new ArrayList<>(); - for (Pair template_ : existingTemplates) - { - if (!template_.second.toLowerCase().contains("exportexceltemplate")) - existingCustomTemplates.add(template_); - } - if (!updatedTemplates.equals(existingCustomTemplates)) - { - TablesDocument doc = null; - TableType xmlTable = null; - TableType.ImportTemplates xmlImportTemplates; - - if (queryDef != null) - { - try - { - doc = parseDocument(queryDef.getMetaData()); - } - catch (XmlException e) - { - throw new MetadataUnavailableException(e.getMessage()); - } - xmlTable = getTableType(form.getQueryName(), doc); - // when there is a queryDef but xmlTable is null it means the xmlMetaData contains tableName which does not - // match with actual queryName then reconstruct the xml table metadata : See Issue 43523 - if (xmlTable == null) - { - doc = null; - } - } - else - { - queryDef = new QueryDef(); - queryDef.setSchema(schemaName); - queryDef.setContainer(container.getId()); - queryDef.setName(queryName); - } - - if (doc == null) - { - doc = TablesDocument.Factory.newInstance(); - } - - if (xmlTable == null) - { - TablesType tables = doc.addNewTables(); - xmlTable = tables.addNewTable(); - xmlTable.setTableName(queryName); - } - - if (xmlTable.getTableDbType() == null) - { - xmlTable.setTableDbType("NOT_IN_DB"); - } - - // remove existing templates - if (xmlTable.isSetImportTemplates()) - xmlTable.unsetImportTemplates(); - xmlImportTemplates = xmlTable.addNewImportTemplates(); - - // set new templates - if (!updatedTemplates.isEmpty()) - { - for (Pair template_ : updatedTemplates) - { - ImportTemplateType importTemplateType = xmlImportTemplates.addNewTemplate(); - importTemplateType.setLabel(template_.first); - importTemplateType.setUrl(template_.second); - } - } - - XmlOptions xmlOptions = new XmlOptions(); - xmlOptions.setSavePrettyPrint(); - // Don't use an explicit namespace, making the XML much more readable - xmlOptions.setUseDefaultNamespace(); - queryDef.setMetaData(doc.xmlText(xmlOptions)); - if (queryDef.getQueryDefId() == 0) - { - QueryManager.get().insert(user, queryDef); - } - else - { - QueryManager.get().update(user, queryDef); - } - - DomainAuditProvider.DomainAuditEvent event = new DomainAuditProvider.DomainAuditEvent(getContainer(), "Import templates updated."); - event.setUserComment(form.getAuditUserComment()); - event.setDomainUri(_domain.getTypeURI()); - event.setDomainName(_domain.getName()); - AuditLogService.get().addEvent(user, event); - } - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", true); - return resp; - } - } - - - public static class TestCase extends AbstractActionPermissionTest - { - @Override - public void testActionPermissions() - { - User user = TestContext.get().getUser(); - assertTrue(user.hasSiteAdminPermission()); - - QueryController controller = new QueryController(); - - // @RequiresPermission(ReadPermission.class) - assertForReadPermission(user, false, - new BrowseAction(), - new BeginAction(), - controller.new SchemaAction(), - controller.new SourceQueryAction(), - controller.new ExecuteQueryAction(), - controller.new PrintRowsAction(), - new ExportScriptAction(), - new ExportRowsExcelAction(), - new ExportRowsXLSXAction(), - new ExportQueriesXLSXAction(), - new ExportExcelTemplateAction(), - new ExportRowsTsvAction(), - new ExcelWebQueryDefinitionAction(), - controller.new SaveQueryViewsAction(), - controller.new PropertiesQueryAction(), - controller.new SelectRowsAction(), - new GetDataAction(), - controller.new ExecuteSqlAction(), - controller.new SelectDistinctAction(), - controller.new GetColumnSummaryStatsAction(), - controller.new ImportAction(), - new ExportSqlAction(), - new UpdateRowsAction(), - new ImportRowsAction(), - new DeleteRowsAction(), - new TableInfoAction(), - new SaveSessionViewAction(), - new GetSchemasAction(), - new GetQueriesAction(), - new GetQueryViewsAction(), - new SaveApiTestAction(), - new ValidateQueryMetadataAction(), - new AuditHistoryAction(), - new AuditDetailsAction(), - new ExportTablesAction(), - new SaveNamedSetAction(), - new DeleteNamedSetAction(), - new ApiTestAction(), - new GetDefaultVisibleColumnsAction() - ); - - - // submitter should be allowed for InsertRows - assertForReadPermission(user, true, new InsertRowsAction()); - - // @RequiresPermission(DeletePermission.class) - assertForUpdateOrDeletePermission(user, - new DeleteQueryRowsAction() - ); - - // @RequiresPermission(AdminPermission.class) - assertForAdminPermission(user, - new DeleteQueryAction(), - controller.new MetadataQueryAction(), - controller.new NewQueryAction(), - new SaveSourceQueryAction(), - - new TruncateTableAction(), - new AdminAction(), - new ManageRemoteConnectionsAction(), - new ReloadExternalSchemaAction(), - new ReloadAllUserSchemas(), - controller.new ManageViewsAction(), - controller.new InternalDeleteView(), - controller.new InternalSourceViewAction(), - controller.new InternalNewViewAction(), - new QueryExportAuditRedirectAction() - ); - - // @RequiresPermission(AdminOperationsPermission.class) - assertForAdminOperationsPermission(user, - new EditRemoteConnectionAction(), - new DeleteRemoteConnectionAction(), - new TestRemoteConnectionAction(), - controller.new RawTableMetaDataAction(), - controller.new RawSchemaMetaDataAction(), - new InsertLinkedSchemaAction(), - new InsertExternalSchemaAction(), - new DeleteSchemaAction(), - new EditLinkedSchemaAction(), - new EditExternalSchemaAction(), - new GetTablesAction(), - new SchemaTemplateAction(), - new SchemaTemplatesAction(), - new ParseExpressionAction(), - new ParseQueryAction() - ); - - // @AdminConsoleAction - assertForAdminPermission(ContainerManager.getRoot(), user, - new DataSourceAdminAction() - ); - - // In addition to administrators (tested above), trusted analysts who are editors can create and edit queries - assertTrustedEditorPermission( - new DeleteQueryAction(), - controller.new MetadataQueryAction(), - controller.new NewQueryAction(), - new SaveSourceQueryAction() - ); - } - } - - public static class SaveRowsTestCase extends Assert - { - private static final String PROJECT_NAME1 = "SaveRowsTestProject1"; - private static final String PROJECT_NAME2 = "SaveRowsTestProject2"; - - private static final String USER_EMAIL = "saveRows@action.test"; - - private static final String LIST1 = "List1"; - private static final String LIST2 = "List2"; - - @Before - public void doSetup() throws Exception - { - doCleanup(); - - Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_NAME1, TestContext.get().getUser()); - Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_NAME2, TestContext.get().getUser()); - - //disable search so we dont get conflicts when deleting folder quickly - ContainerManager.updateSearchable(project1, false, TestContext.get().getUser()); - ContainerManager.updateSearchable(project2, false, TestContext.get().getUser()); - - ListDefinition ld1 = ListService.get().createList(project1, LIST1, ListDefinition.KeyType.Varchar); - ld1.getDomain().addProperty(new PropertyStorageSpec("TextField", JdbcType.VARCHAR)); - ld1.setKeyName("TextField"); - ld1.save(TestContext.get().getUser()); - - ListDefinition ld2 = ListService.get().createList(project2, LIST2, ListDefinition.KeyType.Varchar); - ld2.getDomain().addProperty(new PropertyStorageSpec("TextField", JdbcType.VARCHAR)); - ld2.setKeyName("TextField"); - ld2.save(TestContext.get().getUser()); - } - - @After - public void doCleanup() throws Exception - { - Container project = ContainerManager.getForPath(PROJECT_NAME1); - if (project != null) - { - ContainerManager.deleteAll(project, TestContext.get().getUser()); - } - - Container project2 = ContainerManager.getForPath(PROJECT_NAME2); - if (project2 != null) - { - ContainerManager.deleteAll(project2, TestContext.get().getUser()); - } - - User u = UserManager.getUser(new ValidEmail(USER_EMAIL)); - if (u != null) - { - UserManager.deleteUser(u.getUserId()); - } - } - - private JSONObject getCommand(String val1, String val2) - { - JSONObject command1 = new JSONObject(); - command1.put("containerPath", ContainerManager.getForPath(PROJECT_NAME1).getPath()); - command1.put("command", "insert"); - command1.put("schemaName", "lists"); - command1.put("queryName", LIST1); - command1.put("rows", getTestRows(val1)); - - JSONObject command2 = new JSONObject(); - command2.put("containerPath", ContainerManager.getForPath(PROJECT_NAME2).getPath()); - command2.put("command", "insert"); - command2.put("schemaName", "lists"); - command2.put("queryName", LIST2); - command2.put("rows", getTestRows(val2)); - - JSONObject json = new JSONObject(); - json.put("commands", Arrays.asList(command1, command2)); - - return json; - } - - private MockHttpServletResponse makeRequest(JSONObject json, User user) throws Exception - { - Map headers = new HashMap<>(); - headers.put("Content-Type", "application/json"); - - HttpServletRequest request = ViewServlet.mockRequest(RequestMethod.POST.name(), DetailsURL.fromString("/query/saveRows.view").copy(ContainerManager.getForPath(PROJECT_NAME1)).getActionURL(), user, headers, json.toString()); - return ViewServlet.mockDispatch(request, null); - } - - @Test - public void testCrossFolderSaveRows() throws Exception - { - User user = TestContext.get().getUser(); - assertTrue(user.hasSiteAdminPermission()); - - JSONObject json = getCommand(PROJECT_NAME1, PROJECT_NAME2); - MockHttpServletResponse response = makeRequest(json, TestContext.get().getUser()); - if (response.getStatus() != HttpServletResponse.SC_OK) - { - JSONObject responseJson = new JSONObject(response.getContentAsString()); - throw new RuntimeException("Problem saving rows across folders: " + responseJson.getString("exception")); - } - - Container project1 = ContainerManager.getForPath(PROJECT_NAME1); - Container project2 = ContainerManager.getForPath(PROJECT_NAME2); - - TableInfo list1 = ListService.get().getList(project1, LIST1).getTable(TestContext.get().getUser()); - TableInfo list2 = ListService.get().getList(project2, LIST2).getTable(TestContext.get().getUser()); - - assertEquals("Incorrect row count, list1", 1L, new TableSelector(list1).getRowCount()); - assertEquals("Incorrect row count, list2", 1L, new TableSelector(list2).getRowCount()); - - assertEquals("Incorrect value", PROJECT_NAME1, new TableSelector(list1, PageFlowUtil.set("TextField")).getObject(PROJECT_NAME1, String.class)); - assertEquals("Incorrect value", PROJECT_NAME2, new TableSelector(list2, PageFlowUtil.set("TextField")).getObject(PROJECT_NAME2, String.class)); - - list1.getUpdateService().truncateRows(TestContext.get().getUser(), project1, null, null); - list2.getUpdateService().truncateRows(TestContext.get().getUser(), project2, null, null); - } - - @Test - public void testWithoutPermissions() throws Exception - { - // Now test failure without appropriate permissions: - User withoutPermissions = SecurityManager.addUser(new ValidEmail(USER_EMAIL), TestContext.get().getUser()).getUser(); - - User user = TestContext.get().getUser(); - assertTrue(user.hasSiteAdminPermission()); - - Container project1 = ContainerManager.getForPath(PROJECT_NAME1); - Container project2 = ContainerManager.getForPath(PROJECT_NAME2); - - MutableSecurityPolicy securityPolicy = new MutableSecurityPolicy(SecurityPolicyManager.getPolicy(project1)); - securityPolicy.addRoleAssignment(withoutPermissions, EditorRole.class); - SecurityPolicyManager.savePolicyForTests(securityPolicy, TestContext.get().getUser()); - - assertTrue("Should have insert permission", project1.hasPermission(withoutPermissions, InsertPermission.class)); - assertFalse("Should not have insert permission", project2.hasPermission(withoutPermissions, InsertPermission.class)); - - // repeat insert: - JSONObject json = getCommand("ShouldFail1", "ShouldFail2"); - MockHttpServletResponse response = makeRequest(json, withoutPermissions); - if (response.getStatus() != HttpServletResponse.SC_FORBIDDEN) - { - JSONObject responseJson = new JSONObject(response.getContentAsString()); - throw new RuntimeException("Problem saving rows across folders: " + responseJson.getString("exception")); - } - - TableInfo list1 = ListService.get().getList(project1, LIST1).getTable(TestContext.get().getUser()); - TableInfo list2 = ListService.get().getList(project2, LIST2).getTable(TestContext.get().getUser()); - - // The insert should have failed - assertEquals("Incorrect row count, list1", 0L, new TableSelector(list1).getRowCount()); - assertEquals("Incorrect row count, list2", 0L, new TableSelector(list2).getRowCount()); - } - - private JSONArray getTestRows(String val) - { - JSONArray rows = new JSONArray(); - rows.put(Map.of("TextField", val)); - - return rows; - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed 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. + */ + +package org.labkey.query.controllers; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.antlr.runtime.tree.Tree; +import org.apache.commons.beanutils.ConversionException; +import org.apache.commons.beanutils.ConvertUtils; +import org.apache.commons.collections4.MultiValuedMap; +import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; +import org.apache.commons.collections4.multimap.HashSetValuedHashMap; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.commons.lang3.mutable.MutableInt; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.xmlbeans.XmlError; +import org.apache.xmlbeans.XmlException; +import org.apache.xmlbeans.XmlOptions; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONParserConfiguration; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.labkey.api.action.Action; +import org.labkey.api.action.ActionType; +import org.labkey.api.action.ApiJsonForm; +import org.labkey.api.action.ApiJsonWriter; +import org.labkey.api.action.ApiQueryResponse; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiResponseWriter; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.action.ApiVersion; +import org.labkey.api.action.ConfirmAction; +import org.labkey.api.action.ExportAction; +import org.labkey.api.action.ExportException; +import org.labkey.api.action.ExtendedApiQueryResponse; +import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.FormViewAction; +import org.labkey.api.action.HasBindParameters; +import org.labkey.api.action.JsonInputLimit; +import org.labkey.api.action.LabKeyError; +import org.labkey.api.action.Marshal; +import org.labkey.api.action.Marshaller; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.NullSafeBindException; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.action.ReportingApiQueryResponse; +import org.labkey.api.action.SimpleApiJsonForm; +import org.labkey.api.action.SimpleErrorView; +import org.labkey.api.action.SimpleRedirectAction; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.AdminUrls; +import org.labkey.api.attachments.SpringAttachmentFile; +import org.labkey.api.audit.AbstractAuditTypeProvider; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.AuditTypeEvent; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.audit.provider.ContainerAuditProvider; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.collections.CaseInsensitiveHashSet; +import org.labkey.api.collections.IntHashMap; +import org.labkey.api.collections.LabKeyCollectors; +import org.labkey.api.collections.RowMapFactory; +import org.labkey.api.collections.Sets; +import org.labkey.api.data.AbstractTableInfo; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.Aggregate; +import org.labkey.api.data.AnalyticsProviderItem; +import org.labkey.api.data.BaseColumnInfo; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.CachedResultSets; +import org.labkey.api.data.ColumnHeaderType; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.ContainerType; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbSchemaType; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.ExcelWriter; +import org.labkey.api.data.ForeignKey; +import org.labkey.api.data.JdbcMetaDataSelector; +import org.labkey.api.data.JdbcType; +import org.labkey.api.data.JsonWriter; +import org.labkey.api.data.PHI; +import org.labkey.api.data.PropertyManager; +import org.labkey.api.data.PropertyManager.PropertyMap; +import org.labkey.api.data.PropertyManager.WritablePropertyMap; +import org.labkey.api.data.PropertyStorageSpec; +import org.labkey.api.data.QueryLogging; +import org.labkey.api.data.ResultSetView; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SchemaTableInfo; +import org.labkey.api.data.ShowRows; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TSVWriter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.data.VirtualTable; +import org.labkey.api.data.dialect.JdbcMetaDataLocator; +import org.labkey.api.data.dialect.SqlDialect; +import org.labkey.api.dataiterator.DataIteratorBuilder; +import org.labkey.api.dataiterator.DataIteratorContext; +import org.labkey.api.dataiterator.DataIteratorUtil; +import org.labkey.api.dataiterator.DetailedAuditLogDataIterator; +import org.labkey.api.dataiterator.ListofMapsDataIterator; +import org.labkey.api.exceptions.OptimisticConflictException; +import org.labkey.api.exp.ExperimentException; +import org.labkey.api.exp.api.ProvenanceRecordingParams; +import org.labkey.api.exp.api.ProvenanceService; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.exp.list.ListService; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainAuditProvider; +import org.labkey.api.exp.property.DomainKind; +import org.labkey.api.exp.property.PropertyService; +import org.labkey.api.files.FileContentService; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.RecordedAction; +import org.labkey.api.query.AbstractQueryImportAction; +import org.labkey.api.query.AbstractQueryUpdateService; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.CustomView; +import org.labkey.api.query.DefaultSchema; +import org.labkey.api.query.DetailsURL; +import org.labkey.api.query.DuplicateKeyException; +import org.labkey.api.query.ExportScriptModel; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.FilteredTable; +import org.labkey.api.query.InvalidKeyException; +import org.labkey.api.query.MetadataUnavailableException; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryDefinition; +import org.labkey.api.query.QueryException; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryParam; +import org.labkey.api.query.QueryParseException; +import org.labkey.api.query.QuerySchema; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryUpdateForm; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.QueryUrls; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.RuntimeValidationException; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.SimpleSchemaTreeVisitor; +import org.labkey.api.query.TempQuerySettings; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.UserSchemaAction; +import org.labkey.api.query.ValidationException; +import org.labkey.api.reports.report.ReportDescriptor; +import org.labkey.api.security.ActionNames; +import org.labkey.api.security.AdminConsoleAction; +import org.labkey.api.security.CSRF; +import org.labkey.api.security.IgnoresTermsOfUse; +import org.labkey.api.security.MutableSecurityPolicy; +import org.labkey.api.security.RequiresAllOf; +import org.labkey.api.security.RequiresAnyOf; +import org.labkey.api.security.RequiresNoPermission; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.SecurityManager; +import org.labkey.api.security.SecurityPolicyManager; +import org.labkey.api.security.User; +import org.labkey.api.security.UserManager; +import org.labkey.api.security.ValidEmail; +import org.labkey.api.security.permissions.AbstractActionPermissionTest; +import org.labkey.api.security.permissions.AdminOperationsPermission; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.EditSharedViewPermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.MoveEntitiesPermission; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.permissions.PlatformDeveloperPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.settings.AdminConsole; +import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.LookAndFeelProperties; +import org.labkey.api.stats.BaseAggregatesAnalyticsProvider; +import org.labkey.api.stats.ColumnAnalyticsProvider; +import org.labkey.api.util.ButtonBuilder; +import org.labkey.api.util.DOM; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.JavaScriptFragment; +import org.labkey.api.util.JsonUtil; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.ResponseHelper; +import org.labkey.api.util.ReturnURLString; +import org.labkey.api.util.StringExpression; +import org.labkey.api.util.TestContext; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.UnexpectedException; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DetailsView; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.InsertView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.UpdateView; +import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.ViewServlet; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.PageConfig; +import org.labkey.api.writer.HtmlWriter; +import org.labkey.api.writer.ZipFile; +import org.labkey.data.xml.ColumnType; +import org.labkey.data.xml.ImportTemplateType; +import org.labkey.data.xml.TableType; +import org.labkey.data.xml.TablesDocument; +import org.labkey.data.xml.TablesType; +import org.labkey.data.xml.externalSchema.TemplateSchemaType; +import org.labkey.data.xml.queryCustomView.FilterType; +import org.labkey.query.AutoGeneratedDetailsCustomView; +import org.labkey.query.AutoGeneratedInsertCustomView; +import org.labkey.query.AutoGeneratedUpdateCustomView; +import org.labkey.query.CustomViewImpl; +import org.labkey.query.CustomViewUtil; +import org.labkey.query.EditQueriesPermission; +import org.labkey.query.EditableCustomView; +import org.labkey.query.LinkedTableInfo; +import org.labkey.query.MetadataTableJSON; +import org.labkey.query.ModuleCustomQueryDefinition; +import org.labkey.query.ModuleCustomView; +import org.labkey.query.QueryServiceImpl; +import org.labkey.query.TableXML; +import org.labkey.query.audit.QueryExportAuditProvider; +import org.labkey.query.audit.QueryUpdateAuditProvider; +import org.labkey.query.model.MetadataTableJSONMixin; +import org.labkey.query.persist.AbstractExternalSchemaDef; +import org.labkey.query.persist.CstmView; +import org.labkey.query.persist.ExternalSchemaDef; +import org.labkey.query.persist.ExternalSchemaDefCache; +import org.labkey.query.persist.LinkedSchemaDef; +import org.labkey.query.persist.QueryDef; +import org.labkey.query.persist.QueryManager; +import org.labkey.query.reports.ReportsController; +import org.labkey.query.reports.getdata.DataRequest; +import org.labkey.query.sql.QNode; +import org.labkey.query.sql.Query; +import org.labkey.query.sql.SqlParser; +import org.labkey.query.xml.ApiTestsDocument; +import org.labkey.query.xml.TestCaseType; +import org.labkey.remoteapi.RemoteConnections; +import org.labkey.remoteapi.SelectRowsStreamHack; +import org.labkey.remoteapi.query.SelectRowsCommand; +import org.labkey.vfs.FileLike; +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.PropertyValue; +import org.springframework.beans.PropertyValues; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.ModelAndView; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.trimToEmpty; +import static org.labkey.api.action.ApiJsonWriter.CONTENT_TYPE_JSON; +import static org.labkey.api.assay.AssayFileWriter.ensureUploadDirectory; +import static org.labkey.api.data.DbScope.NO_OP_TRANSACTION; +import static org.labkey.api.query.AbstractQueryUpdateService.saveFile; +import static org.labkey.api.util.DOM.BR; +import static org.labkey.api.util.DOM.DIV; +import static org.labkey.api.util.DOM.FONT; +import static org.labkey.api.util.DOM.Renderable; +import static org.labkey.api.util.DOM.TABLE; +import static org.labkey.api.util.DOM.TD; +import static org.labkey.api.util.DOM.TR; +import static org.labkey.api.util.DOM.at; +import static org.labkey.api.util.DOM.cl; +import static org.labkey.query.MetadataTableJSON.getTableType; +import static org.labkey.query.MetadataTableJSON.parseDocument; + +@SuppressWarnings("DefaultAnnotationParam") + +public class QueryController extends SpringActionController +{ + private static final Logger LOG = LogManager.getLogger(QueryController.class); + private static final String ROW_ATTACHMENT_INDEX_DELIM = "::"; + + private static final Set RESERVED_VIEW_NAMES = CaseInsensitiveHashSet.of( + "Default", + AutoGeneratedDetailsCustomView.NAME, + AutoGeneratedInsertCustomView.NAME, + AutoGeneratedUpdateCustomView.NAME + ); + + private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(QueryController.class, + ValidateQueryAction.class, + ValidateQueriesAction.class, + GetSchemaQueryTreeAction.class, + GetQueryDetailsAction.class, + ViewQuerySourceAction.class + ); + + public QueryController() + { + setActionResolver(_actionResolver); + } + + public static void registerAdminConsoleLinks() + { + AdminConsole.addLink(AdminConsole.SettingsLinkType.Diagnostics, "data sources", new ActionURL(DataSourceAdminAction.class, ContainerManager.getRoot())); + } + + public static class RemoteQueryConnectionUrls + { + public static ActionURL urlManageRemoteConnection(Container c) + { + return new ActionURL(ManageRemoteConnectionsAction.class, c); + } + + public static ActionURL urlCreateRemoteConnection(Container c) + { + return new ActionURL(EditRemoteConnectionAction.class, c); + } + + public static ActionURL urlEditRemoteConnection(Container c, String connectionName) + { + ActionURL url = new ActionURL(EditRemoteConnectionAction.class, c); + url.addParameter("connectionName", connectionName); + return url; + } + + public static ActionURL urlSaveRemoteConnection(Container c) + { + return new ActionURL(EditRemoteConnectionAction.class, c); + } + + public static ActionURL urlDeleteRemoteConnection(Container c, @Nullable String connectionName) + { + ActionURL url = new ActionURL(DeleteRemoteConnectionAction.class, c); + if (connectionName != null) + url.addParameter("connectionName", connectionName); + return url; + } + + public static ActionURL urlTestRemoteConnection(Container c, String connectionName) + { + ActionURL url = new ActionURL(TestRemoteConnectionAction.class, c); + url.addParameter("connectionName", connectionName); + return url; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class EditRemoteConnectionAction extends FormViewAction + { + @Override + public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) + { + remoteConnectionForm.setConnectionKind(RemoteConnections.CONNECTION_KIND_QUERY); + if (!errors.hasErrors()) + { + String name = remoteConnectionForm.getConnectionName(); + // package the remote-connection properties into the remoteConnectionForm and pass them along + Map map1 = RemoteConnections.getRemoteConnection(RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY, name, getContainer()); + remoteConnectionForm.setUrl(map1.get("URL")); + remoteConnectionForm.setUserEmail(map1.get("user")); + remoteConnectionForm.setPassword(map1.get("password")); + remoteConnectionForm.setFolderPath(map1.get("container")); + } + setHelpTopic("remoteConnection"); + return new JspView<>("/org/labkey/query/view/createRemoteConnection.jsp", remoteConnectionForm, errors); + } + + @Override + public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) + { + return RemoteConnections.createOrEditRemoteConnection(remoteConnectionForm, getContainer(), errors); + } + + @Override + public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) + { + return RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Create/Edit Remote Connection", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class DeleteRemoteConnectionAction extends FormViewAction + { + @Override + public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/query/view/confirmDeleteConnection.jsp", remoteConnectionForm, errors); + } + + @Override + public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) + { + remoteConnectionForm.setConnectionKind(RemoteConnections.CONNECTION_KIND_QUERY); + return RemoteConnections.deleteRemoteConnection(remoteConnectionForm, getContainer()); + } + + @Override + public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) + { + return RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Confirm Delete Connection", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class TestRemoteConnectionAction extends FormViewAction + { + @Override + public void validateCommand(RemoteConnections.RemoteConnectionForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnectionForm, boolean reshow, BindException errors) + { + String name = remoteConnectionForm.getConnectionName(); + String schemaName = "core"; // test Schema Name + String queryName = "Users"; // test Query Name + + // Extract the username, password, and container from the secure property store + Map singleConnectionMap = RemoteConnections.getRemoteConnection(RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY, name, getContainer()); + if (singleConnectionMap.isEmpty()) + throw new NotFoundException(); + String url = singleConnectionMap.get(RemoteConnections.FIELD_URL); + String user = singleConnectionMap.get(RemoteConnections.FIELD_USER); + String password = singleConnectionMap.get(RemoteConnections.FIELD_PASSWORD); + String container = singleConnectionMap.get(RemoteConnections.FIELD_CONTAINER); + + // connect to the remote server and retrieve an input stream + org.labkey.remoteapi.Connection cn = new org.labkey.remoteapi.Connection(url, user, password); + final SelectRowsCommand cmd = new SelectRowsCommand(schemaName, queryName); + try + { + DataIteratorBuilder source = SelectRowsStreamHack.go(cn, container, cmd, getContainer()); + // immediately close the source after opening it, this is a test. + source.getDataIterator(new DataIteratorContext()).close(); + } + catch (Exception e) + { + errors.addError(new LabKeyError("The listed credentials for this remote connection failed to connect.")); + return new JspView<>("/org/labkey/query/view/testRemoteConnectionsFailure.jsp", remoteConnectionForm); + } + + return new JspView<>("/org/labkey/query/view/testRemoteConnectionsSuccess.jsp", remoteConnectionForm); + } + + @Override + public boolean handlePost(RemoteConnections.RemoteConnectionForm remoteConnectionForm, BindException errors) + { + return true; + } + + @Override + public URLHelper getSuccessURL(RemoteConnections.RemoteConnectionForm remoteConnectionForm) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Manage Remote Connections", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + public static class QueryUrlsImpl implements QueryUrls + { + @Override + public ActionURL urlSchemaBrowser(Container c) + { + return new ActionURL(BeginAction.class, c); + } + + @Override + public ActionURL urlSchemaBrowser(Container c, @Nullable String schemaName) + { + ActionURL ret = urlSchemaBrowser(c); + if (schemaName != null) + { + ret.addParameter(QueryParam.schemaName.toString(), schemaName); + } + return ret; + } + + @Override + public ActionURL urlSchemaBrowser(Container c, @Nullable String schemaName, @Nullable String queryName) + { + if (StringUtils.isEmpty(queryName)) + return urlSchemaBrowser(c, schemaName); + ActionURL ret = urlSchemaBrowser(c); + ret.addParameter(QueryParam.schemaName.toString(), trimToEmpty(schemaName)); + ret.addParameter(QueryParam.queryName.toString(), trimToEmpty(queryName)); + return ret; + } + + public ActionURL urlExternalSchemaAdmin(Container c) + { + return urlExternalSchemaAdmin(c, null); + } + + public ActionURL urlExternalSchemaAdmin(Container c, @Nullable String message) + { + ActionURL url = new ActionURL(AdminAction.class, c); + + if (null != message) + url.addParameter("message", message); + + return url; + } + + public ActionURL urlInsertExternalSchema(Container c) + { + return new ActionURL(InsertExternalSchemaAction.class, c); + } + + public ActionURL urlNewQuery(Container c) + { + return new ActionURL(NewQueryAction.class, c); + } + + public ActionURL urlUpdateExternalSchema(Container c, AbstractExternalSchemaDef def) + { + ActionURL url = new ActionURL(EditExternalSchemaAction.class, c); + url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); + return url; + } + + public ActionURL urlReloadExternalSchema(Container c, AbstractExternalSchemaDef def) + { + ActionURL url = new ActionURL(ReloadExternalSchemaAction.class, c); + url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); + return url; + } + + public ActionURL urlDeleteSchema(Container c, AbstractExternalSchemaDef def) + { + ActionURL url = new ActionURL(DeleteSchemaAction.class, c); + url.addParameter("externalSchemaId", Integer.toString(def.getExternalSchemaId())); + return url; + } + + @Override + public ActionURL urlStartBackgroundRReport(@NotNull ActionURL baseURL, String reportId) + { + ActionURL result = baseURL.clone(); + result.setAction(ReportsController.StartBackgroundRReportAction.class); + result.replaceParameter(ReportDescriptor.Prop.reportId, reportId); + return result; + } + + @Override + public ActionURL urlExecuteQuery(@NotNull ActionURL baseURL) + { + ActionURL result = baseURL.clone(); + result.setAction(ExecuteQueryAction.class); + return result; + } + + @Override + public ActionURL urlExecuteQuery(Container c, String schemaName, String queryName) + { + return new ActionURL(ExecuteQueryAction.class, c) + .addParameter(QueryParam.schemaName, schemaName) + .addParameter(QueryParam.queryName, queryName); + } + + @Override + public @NotNull ActionURL urlCreateExcelTemplate(Container c, String schemaName, String queryName) + { + return new ActionURL(ExportExcelTemplateAction.class, c) + .addParameter(QueryParam.schemaName, schemaName) + .addParameter("query.queryName", queryName); + } + + @Override + public ActionURL urlMetadataQuery(Container c, String schemaName, String queryName) + { + return new ActionURL(MetadataQueryAction.class, c) + .addParameter(QueryParam.schemaName, schemaName) + .addParameter(QueryParam.queryName, queryName); + } + } + + @Override + public PageConfig defaultPageConfig() + { + // set default help topic for query controller + PageConfig config = super.defaultPageConfig(); + config.setHelpTopic("querySchemaBrowser"); + return config; + } + + @AdminConsoleAction(AdminOperationsPermission.class) + public static class DataSourceAdminAction extends SimpleViewAction + { + public DataSourceAdminAction() + { + } + + public DataSourceAdminAction(ViewContext viewContext) + { + setViewContext(viewContext); + } + + @Override + public ModelAndView getView(Object o, BindException errors) + { + // Site Admin or Troubleshooter? Troubleshooters can see all the information but can't test data sources. + boolean hasAdminOpsPerms = getContainer().hasPermission(getUser(), AdminOperationsPermission.class); + List allDefs = QueryManager.get().getExternalSchemaDefs(null); + + MultiValuedMap byDataSourceName = new ArrayListValuedHashMap<>(); + + for (ExternalSchemaDef def : allDefs) + byDataSourceName.put(def.getDataSource(), def); + + MutableInt row = new MutableInt(); + + Renderable r = DOM.DIV( + DIV("This page lists all the data sources defined in your " + AppProps.getInstance().getWebappConfigurationFilename() + " file that were available when first referenced and the external schemas defined in each."), + BR(), + TABLE(cl("labkey-data-region"), + TR(cl("labkey-show-borders"), + hasAdminOpsPerms ? TD(cl("labkey-column-header"), "Test") : null, + TD(cl("labkey-column-header"), "Data Source"), + TD(cl("labkey-column-header"), "Current Status"), + TD(cl("labkey-column-header"), "URL"), + TD(cl("labkey-column-header"), "Database Name"), + TD(cl("labkey-column-header"), "Product Name"), + TD(cl("labkey-column-header"), "Product Version"), + TD(cl("labkey-column-header"), "Max Connections"), + TD(cl("labkey-column-header"), "Active Connections"), + TD(cl("labkey-column-header"), "Idle Connections"), + TD(cl("labkey-column-header"), "Max Wait (ms)") + ), + DbScope.getDbScopes().stream() + .flatMap(scope -> { + String rowStyle = row.getAndIncrement() % 2 == 0 ? "labkey-alternate-row labkey-show-borders" : "labkey-row labkey-show-borders"; + Object status; + boolean connected = false; + try (Connection ignore = scope.getConnection()) + { + status = "connected"; + connected = true; + } + catch (Exception e) + { + status = FONT(cl("labkey-error"), "disconnected"); + } + + return Stream.of( + TR( + cl(rowStyle), + hasAdminOpsPerms ? TD(connected ? new ButtonBuilder("Test").href(new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", scope.getDataSourceName())) : "") : null, + TD(HtmlString.NBSP, scope.getDisplayName()), + TD(status), + TD(scope.getDatabaseUrl()), + TD(scope.getDatabaseName()), + TD(scope.getDatabaseProductName()), + TD(scope.getDatabaseProductVersion()), + TD(scope.getDataSourceProperties().getMaxTotal()), + TD(scope.getDataSourceProperties().getNumActive()), + TD(scope.getDataSourceProperties().getNumIdle()), + TD(scope.getDataSourceProperties().getMaxWaitMillis()) + ), + TR( + cl(rowStyle), + TD(HtmlString.NBSP), + TD(at(DOM.Attribute.colspan, 10), getDataSourceTable(byDataSourceName.get(scope.getDataSourceName()))) + ) + ); + }) + ) + ); + + return new HtmlView(r); + } + + private Renderable getDataSourceTable(Collection dsDefs) + { + if (dsDefs.isEmpty()) + return TABLE(TR(TD(HtmlString.NBSP))); + + MultiValuedMap byContainerPath = new ArrayListValuedHashMap<>(); + + for (ExternalSchemaDef def : dsDefs) + byContainerPath.put(def.getContainerPath(), def); + + TreeSet paths = new TreeSet<>(byContainerPath.keySet()); + + return TABLE(paths.stream() + .map(path -> TR(TD(at(DOM.Attribute.colspan, 4), getDataSourcePath(path, byContainerPath.get(path))))) + ); + } + + private Renderable getDataSourcePath(String path, Collection unsorted) + { + List defs = new ArrayList<>(unsorted); + defs.sort(Comparator.comparing(AbstractExternalSchemaDef::getUserSchemaName, String.CASE_INSENSITIVE_ORDER)); + Container c = ContainerManager.getForPath(path); + + if (null == c) + return TD(); + + boolean hasRead = c.hasPermission(getUser(), ReadPermission.class); + QueryUrlsImpl urls = new QueryUrlsImpl(); + + return + TD(TABLE( + TR(TD( + at(DOM.Attribute.colspan, 3), + hasRead ? LinkBuilder.simpleLink(path, urls.urlExternalSchemaAdmin(c)) : path + )), + TR(TD(TABLE( + defs.stream() + .map(def -> TR(TD( + at(DOM.Attribute.style, "padding-left:20px"), + hasRead ? LinkBuilder.simpleLink(def.getUserSchemaName() + + (!Strings.CS.equals(def.getSourceSchemaName(), def.getUserSchemaName()) ? " (" + def.getSourceSchemaName() + ")" : ""), urls.urlUpdateExternalSchema(c, def)) + : def.getUserSchemaName() + ))) + ))) + )); + } + + @Override + public void addNavTrail(NavTree root) + { + urlProvider(AdminUrls.class).addAdminNavTrail(root, "Data Source Administration", getClass(), getContainer()); + } + } + + public static class TestDataSourceForm + { + private String _dataSource; + + public String getDataSource() + { + return _dataSource; + } + + @SuppressWarnings("unused") + public void setDataSource(String dataSource) + { + _dataSource = dataSource; + } + } + + public static class TestDataSourceConfirmForm extends TestDataSourceForm + { + private String _excludeSchemas; + private String _excludeTables; + + public String getExcludeSchemas() + { + return _excludeSchemas; + } + + @SuppressWarnings("unused") + public void setExcludeSchemas(String excludeSchemas) + { + _excludeSchemas = excludeSchemas; + } + + public String getExcludeTables() + { + return _excludeTables; + } + + @SuppressWarnings("unused") + public void setExcludeTables(String excludeTables) + { + _excludeTables = excludeTables; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class TestDataSourceConfirmAction extends FormViewAction + { + private DbScope _scope; + + @Override + public ModelAndView getView(TestDataSourceConfirmForm form, boolean reshow, BindException errors) throws Exception + { + validateCommand(form, errors); + return new JspView<>("/org/labkey/query/view/testDataSourceConfirm.jsp", _scope); + } + + @Override + public void validateCommand(TestDataSourceConfirmForm form, Errors errors) + { + _scope = DbScope.getDbScope(form.getDataSource()); + + if (null == _scope) + throw new NotFoundException("Could not resolve data source " + form.getDataSource()); + } + + @Override + public boolean handlePost(TestDataSourceConfirmForm form, BindException errors) throws Exception + { + saveTestDataSourceProperties(form); + return true; + } + + @Override + public URLHelper getSuccessURL(TestDataSourceConfirmForm form) + { + return new ActionURL(TestDataSourceAction.class, getContainer()).addParameter("dataSource", _scope.getDataSourceName()); + } + + @Override + public void addNavTrail(NavTree root) + { + new DataSourceAdminAction(getViewContext()).addNavTrail(root); + root.addChild("Prepare Test of " + _scope.getDataSourceName()); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class TestDataSourceAction extends SimpleViewAction + { + private DbScope _scope; + + @Override + public ModelAndView getView(TestDataSourceForm form, BindException errors) + { + _scope = DbScope.getDbScope(form.getDataSource()); + + if (null == _scope) + throw new NotFoundException("Could not resolve data source " + form.getDataSource()); + + return new JspView<>("/org/labkey/query/view/testDataSource.jsp", _scope); + } + + @Override + public void addNavTrail(NavTree root) + { + new DataSourceAdminAction(getViewContext()).addNavTrail(root); + root.addChild("Test " + _scope.getDataSourceName()); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class ResetDataSourcePropertiesAction extends FormHandlerAction + { + @Override + public void validateCommand(TestDataSourceForm target, Errors errors) + { + } + + @Override + public boolean handlePost(TestDataSourceForm form, BindException errors) throws Exception + { + WritablePropertyMap map = PropertyManager.getWritableProperties(getCategory(form.getDataSource()), false); + if (map != null) + map.delete(); + return true; + } + + @Override + public URLHelper getSuccessURL(TestDataSourceForm form) + { + return new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", form.getDataSource()) ; + } + } + + private static final String TEST_DATA_SOURCE_CATEGORY = "testDataSourceProperties"; + private static final String TEST_DATA_SOURCE_SCHEMAS_PROPERTY = "excludeSchemas"; + private static final String TEST_DATA_SOURCE_TABLES_PROPERTY = "excludeTables"; + + private static String getCategory(String dataSourceName) + { + return TEST_DATA_SOURCE_CATEGORY + "|" + dataSourceName; + } + + public static void saveTestDataSourceProperties(TestDataSourceConfirmForm form) + { + WritablePropertyMap map = PropertyManager.getWritableProperties(getCategory(form.getDataSource()), true); + // Save empty entries as empty string to distinguish from null (which results in default values) + map.put(TEST_DATA_SOURCE_SCHEMAS_PROPERTY, StringUtils.trimToEmpty(form.getExcludeSchemas())); + map.put(TEST_DATA_SOURCE_TABLES_PROPERTY, StringUtils.trimToEmpty(form.getExcludeTables())); + map.save(); + } + + public static TestDataSourceConfirmForm getTestDataSourceProperties(DbScope scope) + { + TestDataSourceConfirmForm form = new TestDataSourceConfirmForm(); + PropertyMap map = PropertyManager.getProperties(getCategory(scope.getDataSourceName())); + form.setExcludeSchemas(map.getOrDefault(TEST_DATA_SOURCE_SCHEMAS_PROPERTY, scope.getSqlDialect().getDefaultSchemasToExcludeFromTesting())); + form.setExcludeTables(map.getOrDefault(TEST_DATA_SOURCE_TABLES_PROPERTY, scope.getSqlDialect().getDefaultTablesToExcludeFromTesting())); + + return form; + } + + @RequiresPermission(ReadPermission.class) + public static class BrowseAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/query/view/browse.jsp", null); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Schema Browser"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class BeginAction extends QueryViewAction + { + @SuppressWarnings("UnusedDeclaration") + public BeginAction() + { + } + + public BeginAction(ViewContext ctx) + { + setViewContext(ctx); + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + JspView view = new JspView<>("/org/labkey/query/view/browse.jsp", form); + view.setFrame(WebPartView.FrameType.NONE); + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Query Schema Browser", new QueryUrlsImpl().urlSchemaBrowser(getContainer())); + } + } + + @RequiresPermission(ReadPermission.class) + public class SchemaAction extends QueryViewAction + { + public SchemaAction() {} + + SchemaAction(QueryForm form) + { + _form = form; + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + _form = form; + return new JspView<>("/org/labkey/query/view/browse.jsp", form); + } + + @Override + public void addNavTrail(NavTree root) + { + if (_form != null && _form.getSchema() != null) + addSchemaActionNavTrail(root, _form.getSchema().getSchemaPath(), _form.getQueryName()); + } + } + + + void addSchemaActionNavTrail(NavTree root, SchemaKey schemaKey, String queryName) + { + if (getContainer().hasOneOf(getUser(), AdminPermission.class, PlatformDeveloperPermission.class)) + { + // Don't show the full query nav trail to non-admin/non-developer users as they almost certainly don't + // want it + try + { + String schemaName = schemaKey.toDisplayString(); + ActionURL url = new ActionURL(BeginAction.class, getContainer()); + url.addParameter("schemaName", schemaKey.toString()); + url.addParameter("queryName", queryName); + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild(schemaName + " Schema", url); + } + catch (NullPointerException e) + { + LOG.error("NullPointerException in addNavTrail", e); + } + } + } + + + // Trusted analysts who are editors can create and modify queries + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + @Action(ActionType.SelectData.class) + public class NewQueryAction extends FormViewAction + { + private NewQueryForm _form; + private ActionURL _successUrl; + + @Override + public void validateCommand(NewQueryForm target, org.springframework.validation.Errors errors) + { + target.ff_newQueryName = StringUtils.trimToNull(target.ff_newQueryName); + if (null == target.ff_newQueryName) + errors.reject(ERROR_MSG, "QueryName is required"); + } + + @Override + public ModelAndView getView(NewQueryForm form, boolean reshow, BindException errors) + { + form.ensureSchemaExists(); + + if (!form.getSchema().canCreate()) + { + throw new UnauthorizedException(); + } + + getPageConfig().setFocusId("ff_newQueryName"); + _form = form; + setHelpTopic("sqlTutorial"); + return new JspView<>("/org/labkey/query/view/newQuery.jsp", form, errors); + } + + @Override + public boolean handlePost(NewQueryForm form, BindException errors) + { + form.ensureSchemaExists(); + + if (!form.getSchema().canCreate()) + { + throw new UnauthorizedException(); + } + + try + { + if (StringUtils.isEmpty(form.ff_baseTableName)) + { + errors.reject(ERROR_MSG, "You must select a base table or query name."); + return false; + } + + UserSchema schema = form.getSchema(); + String newQueryName = form.ff_newQueryName; + QueryDef existing = QueryManager.get().getQueryDef(getContainer(), form.getSchemaName(), newQueryName, true); + if (existing != null) + { + errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists."); + return false; + } + TableInfo existingTable = form.getSchema().getTable(newQueryName, null); + if (existingTable != null) + { + errors.reject(ERROR_MSG, "A table with the name '" + newQueryName + "' already exists."); + return false; + } + // bug 6095 -- conflicting query and dataset names + if (form.getSchema().getTableNames().contains(newQueryName)) + { + errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists as a table"); + return false; + } + QueryDefinition newDef = QueryService.get().createQueryDef(getUser(), getContainer(), form.getSchemaKey(), form.ff_newQueryName); + Query query = new Query(schema); + query.setRootTable(FieldKey.fromParts(form.ff_baseTableName)); + String sql = query.getQueryText(); + if (null == sql) + sql = "SELECT * FROM \"" + form.ff_baseTableName + "\""; + newDef.setSql(sql); + + try + { + newDef.save(getUser(), getContainer()); + } + catch (SQLException x) + { + if (RuntimeSQLException.isConstraintException(x)) + { + errors.reject(ERROR_MSG, "The query '" + newQueryName + "' already exists."); + return false; + } + else + { + throw x; + } + } + + _successUrl = newDef.urlFor(form.ff_redirect); + return true; + } + catch (Exception e) + { + ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), e); + errors.reject(ERROR_MSG, Objects.toString(e.getMessage(), e.toString())); + return false; + } + } + + @Override + public ActionURL getSuccessURL(NewQueryForm newQueryForm) + { + return _successUrl; + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + root.addChild("New Query", new QueryUrlsImpl().urlNewQuery(getContainer())); + } + } + + // CONSIDER : deleting this action after the SQL editor UI changes are finalized, keep in mind that built-in views + // use this view as well via the edit metadata page. + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) // Note: This action deals with just meta data; it AJAXes data into place using GetWebPartAction + public class SourceQueryAction extends SimpleViewAction + { + public SourceForm _form; + public UserSchema _schema; + public QueryDefinition _queryDef; + + + @Override + public void validate(SourceForm target, BindException errors) + { + _form = target; + if (StringUtils.isEmpty(target.getSchemaName())) + throw new NotFoundException("schema name not specified"); + if (StringUtils.isEmpty(target.getQueryName())) + throw new NotFoundException("query name not specified"); + + QuerySchema querySchema = DefaultSchema.get(getUser(), getContainer(), _form.getSchemaKey()); + if (null == querySchema) + throw new NotFoundException("schema not found: " + _form.getSchemaKey().toDisplayString()); + if (!(querySchema instanceof UserSchema)) + throw new NotFoundException("Could not find the schema '" + _form.getSchemaName() + "' in the folder '" + getContainer().getPath() + "'"); + _schema = (UserSchema)querySchema; + } + + + @Override + public ModelAndView getView(SourceForm form, BindException errors) + { + _queryDef = _schema.getQueryDef(form.getQueryName()); + if (null == _queryDef) + _queryDef = _schema.getQueryDefForTable(form.getQueryName()); + if (null == _queryDef) + throw new NotFoundException("Could not find the query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); + + try + { + if (form.ff_queryText == null) + { + form.ff_queryText = _queryDef.getSql(); + form.ff_metadataText = _queryDef.getMetadataXml(); + if (null == form.ff_metadataText) + form.ff_metadataText = form.getDefaultMetadataText(); + } + + for (QueryException qpe : _queryDef.getParseErrors(_schema)) + { + errors.reject(ERROR_MSG, Objects.toString(qpe.getMessage(), qpe.toString())); + } + } + catch (Exception e) + { + try + { + ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), e); + } + catch (Throwable t) + { + // + } + errors.reject("ERROR_MSG", e.toString()); + LOG.error("Error", e); + } + + Renderable moduleWarning = null; + if (_queryDef instanceof ModuleCustomQueryDefinition mcqd && _queryDef.canEdit(getUser())) + { + moduleWarning = DIV(cl("labkey-warning-messages"), + "This SQL query is defined in the '" + mcqd.getModuleName() + "' module in directory '" + mcqd.getSqlFile().getParent() + "'.", + BR(), + "Changes to this query will be reflected in all usages across different folders on the server." + ); + } + + var sourceQueryView = new JspView<>("/org/labkey/query/view/sourceQuery.jsp", this, errors); + WebPartView ret = sourceQueryView; + if (null != moduleWarning) + ret = new VBox(new HtmlView(moduleWarning), sourceQueryView); + return ret; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("useSqlEditor"); + + addSchemaActionNavTrail(root, _form.getSchemaKey(), _form.getQueryName()); + + root.addChild("Edit " + _form.getQueryName()); + } + } + + + /** + * Ajax action to save a query. If the save is successful the request will return successfully. A query + * with SQL syntax errors can still be saved successfully. + * + * If the SQL contains parse errors, a parseErrors object will be returned which contains an array of + * JSON serialized error information. + */ + // Trusted analysts who are editors can create and modify queries + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + @Action(ActionType.Configure.class) + public static class SaveSourceQueryAction extends MutatingApiAction + { + private UserSchema _schema; + + @Override + public void validateForm(SourceForm form, Errors errors) + { + if (StringUtils.isEmpty(form.getSchemaName())) + throw new NotFoundException("Query definition not found, schemaName and queryName are required."); + if (StringUtils.isEmpty(form.getQueryName())) + throw new NotFoundException("Query definition not found, schemaName and queryName are required."); + + QuerySchema querySchema = DefaultSchema.get(getUser(), getContainer(), form.getSchemaKey()); + if (null == querySchema) + throw new NotFoundException("schema not found: " + form.getSchemaKey().toDisplayString()); + if (!(querySchema instanceof UserSchema)) + throw new NotFoundException("Could not find the schema '" + form.getSchemaName() + "' in the folder '" + getContainer().getPath() + "'"); + _schema = (UserSchema)querySchema; + + XmlOptions options = XmlBeansUtil.getDefaultParseOptions(); + List xmlErrors = new ArrayList<>(); + options.setErrorListener(xmlErrors); + try + { + // had a couple of real-world failures due to null pointers in this code, so it's time to be paranoid + if (form.ff_metadataText != null) + { + TablesDocument tablesDoc = TablesDocument.Factory.parse(form.ff_metadataText, options); + if (tablesDoc != null) + { + tablesDoc.validate(options); + TablesType tablesType = tablesDoc.getTables(); + if (tablesType != null) + { + for (TableType tableType : tablesType.getTableArray()) + { + if (null != tableType) + { + if (!Objects.equals(tableType.getTableName(), form.getQueryName())) + { + errors.reject(ERROR_MSG, "Table name in the XML metadata must match the table/query name: " + form.getQueryName()); + } + + TableType.Columns tableColumns = tableType.getColumns(); + if (null != tableColumns) + { + ColumnType[] tableColumnArray = tableColumns.getColumnArray(); + for (ColumnType column : tableColumnArray) + { + if (column.isSetPhi() || column.isSetProtected()) + { + throw new IllegalArgumentException("PHI/protected metadata must not be set here."); + } + + ColumnType.Fk fk = column.getFk(); + if (null != fk) + { + try + { + validateForeignKey(fk, column, errors); + validateLookupFilter(AbstractTableInfo.parseXMLLookupFilters(fk.getFilters()), errors); + } + catch (ValidationException e) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + } + } + } + } + } + } + } + } + } + catch (XmlException e) + { + throw new RuntimeValidationException(e); + } + + for (XmlError xmle : xmlErrors) + { + errors.reject(ERROR_MSG, XmlBeansUtil.getErrorMessage(xmle)); + } + } + + private void validateForeignKey(ColumnType.Fk fk, ColumnType column, Errors errors) + { + if (fk.isSetFkMultiValued()) + { + // issue 51695 : don't let users create unsupported MVFK types + String type = fk.getFkMultiValued(); + if (!AbstractTableInfo.MultiValuedFkType.junction.name().equals(type)) + { + errors.reject(ERROR_MSG, String.format("Column : \"%s\" has an invalid fkMultiValued value : \"%s\" is not supported.", column.getColumnName(), type)); + } + } + } + + private void validateLookupFilter(Map> filterMap, Errors errors) + { + filterMap.forEach((operation, filters) -> { + + String displayStr = "Filter for operation : " + operation.name(); + for (FilterType filter : filters) + { + if (isBlank(filter.getColumn())) + errors.reject(ERROR_MSG, displayStr + " requires columnName"); + + if (null == filter.getOperator()) + { + errors.reject(ERROR_MSG, displayStr + " requires operator"); + } + else + { + CompareType compareType = CompareType.getByURLKey(filter.getOperator().toString()); + if (null == compareType) + { + errors.reject(ERROR_MSG, displayStr + " operator is invalid"); + } + else + { + if (compareType.isDataValueRequired() && null == filter.getValue()) + errors.reject(ERROR_MSG, displayStr + " requires a value but none is specified"); + } + } + } + + try + { + // attempt to convert to something we can query against + SimpleFilter.fromXml(filters.toArray(new FilterType[0])); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + }); + } + + @Override + public ApiResponse execute(SourceForm form, BindException errors) + { + var queryDef = _schema.getQueryDef(form.getQueryName()); + if (null == queryDef) + queryDef = _schema.getQueryDefForTable(form.getQueryName()); + if (null == queryDef) + throw new NotFoundException("Could not find the query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); + + ApiSimpleResponse response = new ApiSimpleResponse(); + + try + { + if (form.ff_queryText != null) + { + if (!queryDef.isSqlEditable()) + throw new UnauthorizedException("Query SQL is not editable."); + + if (!queryDef.canEdit(getUser())) + throw new UnauthorizedException("Edit permissions are required."); + + queryDef.setSql(form.ff_queryText); + } + + String metadataText = StringUtils.trimToNull(form.ff_metadataText); + if (!Objects.equals(metadataText, queryDef.getMetadataXml())) + { + if (queryDef.isMetadataEditable()) + { + if (!queryDef.canEditMetadata(getUser())) + throw new UnauthorizedException("Edit metadata permissions are required."); + + if (!getUser().isTrustedBrowserDev()) + { + JavaScriptFragment.ensureXMLMetadataNoJavaScript(metadataText); + } + + queryDef.setMetadataXml(metadataText); + } + else + { + if (metadataText != null) + throw new UnsupportedOperationException("Query metadata is not editable."); + } + } + + queryDef.save(getUser(), getContainer()); + + // the query was successfully saved, validate the query but return any errors in the success response + List parseErrors = new ArrayList<>(); + List parseWarnings = new ArrayList<>(); + queryDef.validateQuery(_schema, parseErrors, parseWarnings); + if (!parseErrors.isEmpty()) + { + JSONArray errorArray = new JSONArray(); + + for (QueryException e : parseErrors) + { + errorArray.put(e.toJSON(form.ff_queryText)); + } + response.put("parseErrors", errorArray); + } + else if (!parseWarnings.isEmpty()) + { + JSONArray errorArray = new JSONArray(); + + for (QueryException e : parseWarnings) + { + errorArray.put(e.toJSON(form.ff_queryText)); + } + response.put("parseWarnings", errorArray); + } + } + catch (SQLException e) + { + errors.reject(ERROR_MSG, "An exception occurred: " + e); + LOG.error("Error", e); + } + catch (RuntimeException e) + { + errors.reject(ERROR_MSG, "An exception occurred: " + e.getMessage()); + LOG.error("Error", e); + } + + if (errors.hasErrors()) + return null; + + //if we got here, the query is OK + response.put("success", true); + return response; + } + + } + + + // Trusted analysts who are editors can create and modify queries + @RequiresAllOf({EditQueriesPermission.class, DeletePermission.class}) + @Action(ActionType.Configure.class) + public static class DeleteQueryAction extends ConfirmAction + { + public SourceForm _form; + public QuerySchema _baseSchema; + public QueryDefinition _queryDef; + + + @Override + public void validateCommand(SourceForm target, Errors errors) + { + _form = target; + if (StringUtils.isEmpty(target.getSchemaName())) + throw new NotFoundException("Query definition not found, schemaName and queryName are required."); + if (StringUtils.isEmpty(target.getQueryName())) + throw new NotFoundException("Query definition not found, schemaName and queryName are required."); + + _baseSchema = DefaultSchema.get(getUser(), getContainer(), _form.getSchemaKey()); + if (null == _baseSchema) + throw new NotFoundException("Schema not found: " + _form.getSchemaKey().toDisplayString()); + } + + + @Override + public ModelAndView getConfirmView(SourceForm form, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Delete Query"); + _queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), _baseSchema.getSchemaName(), form.getQueryName()); + + if (null == _queryDef) + throw new NotFoundException("Query not found: " + form.getQueryName()); + + if (!_queryDef.canDelete(getUser())) + { + errors.reject(ERROR_MSG, "Sorry, this query can not be deleted"); + } + + return new JspView<>("/org/labkey/query/view/deleteQuery.jsp", this, errors); + } + + + @Override + public boolean handlePost(SourceForm form, BindException errors) throws Exception + { + _queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), _baseSchema.getSchemaName(), form.getQueryName()); + + if (null == _queryDef) + return false; + try + { + _queryDef.delete(getUser()); + } + catch (OptimisticConflictException x) + { + /* reshow will throw NotFound, so just ignore */ + } + return true; + } + + @Override + @NotNull + public ActionURL getSuccessURL(SourceForm queryForm) + { + return ((UserSchema)_baseSchema).urlFor(QueryAction.schema); + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public class ExecuteQueryAction extends QueryViewAction + { + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + _form = form; + + if (errors.hasErrors()) + return new SimpleErrorView(errors, true); + + QueryView queryView = Objects.requireNonNull(form.getQueryView()); + + var t = queryView.getTable(); + if (null != t && !t.allowRobotsIndex()) + { + getPageConfig().setRobotsNone(); + } + + if (isPrint()) + { + queryView.setPrintView(true); + getPageConfig().setTemplate(PageConfig.Template.Print); + getPageConfig().setShowPrintDialog(true); + } + + queryView.setShadeAlternatingRows(true); + queryView.setShowBorders(true); + setHelpTopic("customSQL"); + _queryView = queryView; + return queryView; + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + TableInfo ti = null; + try + { + if (null != _queryView) + ti = _queryView.getTable(); + } + catch (QueryParseException x) + { + /* */ + } + String display = ti == null ? _form.getQueryName() : ti.getTitle(); + root.addChild(display); + } + } + + + @RequiresPermission(AdminOperationsPermission.class) + public class RawTableMetaDataAction extends QueryViewAction + { + private String _dbSchemaName; + private String _dbTableName; + + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + _form = form; + + QueryView queryView = form.getQueryView(); + String userSchemaName = queryView.getSchema().getName(); + TableInfo ti = queryView.getTable(); + if (null == ti) + throw new NotFoundException(); + + DbScope scope = ti.getSchema().getScope(); + + // Test for provisioned table + if (ti.getDomain() != null) + { + Domain domain = ti.getDomain(); + if (domain.getStorageTableName() != null) + { + // Use the real table and schema names for getting the metadata + _dbTableName = domain.getStorageTableName(); + _dbSchemaName = domain.getDomainKind().getStorageSchemaName(); + } + } + + // No domain or domain with non-provisioned storage (e.g., core.Users) + if (null == _dbSchemaName || null == _dbTableName) + { + DbSchema dbSchema = ti.getSchema(); + _dbSchemaName = dbSchema.getName(); + + // Try to get the underlying schema table and use the meta data name, #12015 + if (ti instanceof FilteredTable fti) + ti = fti.getRealTable(); + + if (ti instanceof SchemaTableInfo) + _dbTableName = ti.getMetaDataIdentifier().getId(); + else if (ti instanceof LinkedTableInfo) + _dbTableName = ti.getName(); + + if (null == _dbTableName) + { + TableInfo tableInfo = dbSchema.getTable(ti.getName()); + if (null != tableInfo) + _dbTableName = tableInfo.getMetaDataIdentifier().getId(); + } + } + + if (null != _dbTableName) + { + VBox result = new VBox(); + + ActionURL url = null; + QuerySchema qs = DefaultSchema.get(getUser(), getContainer()).getSchema(userSchemaName); + if (qs != null) + { + url = new ActionURL(RawSchemaMetaDataAction.class, getContainer()); + url.addParameter("schemaName", userSchemaName); + } + + SqlDialect dialect = scope.getSqlDialect(); + ScopeView scopeInfo = new ScopeView("Scope and Schema Information", scope, _dbSchemaName, url, _dbTableName); + + result.addView(scopeInfo); + + try (JdbcMetaDataLocator locator = dialect.getTableResolver().getSingleTableLocator(scope, _dbSchemaName, _dbTableName)) + { + JdbcMetaDataSelector columnSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getColumns(l.getCatalogName(), l.getSchemaNamePattern(), l.getTableNamePattern(), null)); + result.addView(new ResultSetView(CachedResultSets.create(columnSelector.getResultSet(), true, Table.ALL_ROWS), "Table Meta Data")); + + JdbcMetaDataSelector pkSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getPrimaryKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); + result.addView(new ResultSetView(CachedResultSets.create(pkSelector.getResultSet(), true, Table.ALL_ROWS), "Primary Key Meta Data")); + + if (dialect.canCheckIndices(ti)) + { + JdbcMetaDataSelector indexSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getIndexInfo(l.getCatalogName(), l.getSchemaName(), l.getTableName(), false, false)); + result.addView(new ResultSetView(CachedResultSets.create(indexSelector.getResultSet(), true, Table.ALL_ROWS), "Other Index Meta Data")); + } + + JdbcMetaDataSelector ikSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getImportedKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); + result.addView(new ResultSetView(CachedResultSets.create(ikSelector.getResultSet(), true, Table.ALL_ROWS), "Imported Keys Meta Data")); + + JdbcMetaDataSelector ekSelector = new JdbcMetaDataSelector(locator, + (dbmd, l) -> dbmd.getExportedKeys(l.getCatalogName(), l.getSchemaName(), l.getTableName())); + result.addView(new ResultSetView(CachedResultSets.create(ekSelector.getResultSet(), true, Table.ALL_ROWS), "Exported Keys Meta Data")); + } + return result; + } + else + { + errors.reject(ERROR_MSG, "Raw metadata not accessible for table " + ti.getName()); + return new SimpleErrorView(errors); + } + } + + @Override + public void addNavTrail(NavTree root) + { + (new SchemaAction(_form)).addNavTrail(root); + if (null != _dbTableName) + root.addChild("JDBC Meta Data For Table \"" + _dbSchemaName + "." + _dbTableName + "\""); + } + } + + + @RequiresPermission(AdminOperationsPermission.class) + public class RawSchemaMetaDataAction extends SimpleViewAction + { + private String _schemaName; + + @Override + public ModelAndView getView(Object form, BindException errors) throws Exception + { + _schemaName = getViewContext().getActionURL().getParameter("schemaName"); + if (null == _schemaName) + throw new NotFoundException(); + QuerySchema qs = DefaultSchema.get(getUser(), getContainer()).getSchema(_schemaName); + if (null == qs) + throw new NotFoundException(_schemaName); + DbSchema schema = qs.getDbSchema(); + String dbSchemaName = schema.getName(); + DbScope scope = schema.getScope(); + SqlDialect dialect = scope.getSqlDialect(); + + HttpView scopeInfo = new ScopeView("Scope Information", scope); + + ModelAndView tablesView; + + try (JdbcMetaDataLocator locator = dialect.getTableResolver().getAllTablesLocator(scope, dbSchemaName)) + { + JdbcMetaDataSelector selector = new JdbcMetaDataSelector(locator, + (dbmd, locator1) -> dbmd.getTables(locator1.getCatalogName(), locator1.getSchemaNamePattern(), locator1.getTableNamePattern(), null)); + Set tableNames = Sets.newCaseInsensitiveHashSet(qs.getTableNames()); + + ActionURL url = new ActionURL(RawTableMetaDataAction.class, getContainer()) + .addParameter("schemaName", _schemaName) + .addParameter("query.queryName", null); + tablesView = new ResultSetView(CachedResultSets.create(selector.getResultSet(), true, Table.ALL_ROWS), "Tables", "TABLE_NAME", url) + { + @Override + protected boolean shouldLink(ResultSet rs) throws SQLException + { + // Only link to tables and views (not indexes or sequences). And only if they're defined in the query schema. + String name = rs.getString("TABLE_NAME"); + String type = rs.getString("TABLE_TYPE"); + return ("TABLE".equalsIgnoreCase(type) || "VIEW".equalsIgnoreCase(type)) && tableNames.contains(name); + } + }; + } + + return new VBox(scopeInfo, tablesView); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("JDBC Meta Data For Schema \"" + _schemaName + "\""); + } + } + + + public static class ScopeView extends WebPartView + { + private final DbScope _scope; + private final String _schemaName; + private final String _tableName; + private final ActionURL _url; + + private ScopeView(String title, DbScope scope) + { + this(title, scope, null, null, null); + } + + private ScopeView(String title, DbScope scope, String schemaName, ActionURL url, String tableName) + { + super(title); + _scope = scope; + _schemaName = schemaName; + _tableName = tableName; + _url = url; + } + + @Override + protected void renderView(Object model, HtmlWriter out) + { + TABLE( + null != _schemaName ? getLabelAndContents("Schema", _url == null ? _schemaName : LinkBuilder.simpleLink(_schemaName, _url)) : null, + null != _tableName ? getLabelAndContents("Table", _tableName) : null, + getLabelAndContents("Scope", _scope.getDisplayName()), + getLabelAndContents("Dialect", _scope.getSqlDialect().getClass().getSimpleName()), + getLabelAndContents("URL", _scope.getDatabaseUrl()) + ).appendTo(out); + } + + // Return a single row (TR) with styled label and contents in separate TDs + private Renderable getLabelAndContents(String label, Object contents) + { + return TR( + TD( + cl("labkey-form-label"), + label + ), + TD( + contents + ) + ); + } + } + + // for backwards compat same as _executeQuery.view ?_print=1 + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public class PrintRowsAction extends ExecuteQueryAction + { + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + _print = true; + ModelAndView result = super.getView(form, errors); + String title = form.getQueryName(); + if (StringUtils.isEmpty(title)) + title = form.getSchemaName(); + getPageConfig().setTitle(title, true); + return result; + } + } + + + abstract static class _ExportQuery extends SimpleViewAction + { + @Override + public ModelAndView getView(K form, BindException errors) throws Exception + { + QueryView view = form.getQueryView(); + getPageConfig().setTemplate(PageConfig.Template.None); + HttpServletResponse response = getViewContext().getResponse(); + response.setHeader("X-Robots-Tag", "noindex"); + try + { + _export(form, view); + return null; + } + catch (QueryService.NamedParameterNotProvided | QueryParseException x) + { + ExceptionUtil.decorateException(x, ExceptionUtil.ExceptionInfo.SkipMothershipLogging, "true", true); + throw x; + } + } + + abstract void _export(K form, QueryView view) throws Exception; + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportScriptForm extends QueryForm + { + private String _type; + + public String getScriptType() + { + return _type; + } + + public void setScriptType(String type) + { + _type = type; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) // This is called "export" but it doesn't export any data + @CSRF(CSRF.Method.ALL) + public static class ExportScriptAction extends SimpleViewAction + { + @Override + public void validate(ExportScriptForm form, BindException errors) + { + // calling form.getQueryView() as a validation check as it will throw if schema/query missing + form.getQueryView(); + + if (StringUtils.isEmpty(form.getScriptType())) + throw new NotFoundException("Missing required parameter: scriptType."); + } + + @Override + public ModelAndView getView(ExportScriptForm form, BindException errors) + { + return ExportScriptModel.getExportScriptView(QueryView.create(form, errors), form.getScriptType(), getPageConfig(), getViewContext().getResponse()); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportRowsExcelAction extends _ExportQuery + { + @Override + void _export(ExportQueryForm form, QueryView view) throws Exception + { + view.exportToExcel(getViewContext().getResponse(), form.getHeaderType(), ExcelWriter.ExcelDocumentType.xls, form.getRenameColumnMap()); + } + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportRowsXLSXAction extends _ExportQuery + { + @Override + void _export(ExportQueryForm form, QueryView view) throws Exception + { + view.exportToExcel(getViewContext().getResponse(), form.getHeaderType(), ExcelWriter.ExcelDocumentType.xlsx, form.getRenameColumnMap()); + } + } + + public static class ExportQueriesForm extends ExportQueryForm implements ApiJsonForm + { + private String filename; + private List queryForms; + + public void setFilename(String filename) + { + this.filename = filename; + } + + public String getFilename() + { + return filename; + } + + public void setQueryForms(List queryForms) + { + this.queryForms = queryForms; + } + + public List getQueryForms() + { + return queryForms; + } + + /** + * Map JSON to Spring PropertyValue objects. + * @param json the properties + */ + private MutablePropertyValues getPropertyValues(JSONObject json) + { + // Collecting mapped properties as a list because adding them to an existing MutablePropertyValues object replaces existing values + List properties = new ArrayList<>(); + + for (String key : json.keySet()) + { + Object value = json.get(key); + if (value instanceof JSONArray val) + { + // Split arrays into individual pairs to be bound (Issue #45452) + for (int i = 0; i < val.length(); i++) + { + properties.add(new PropertyValue(key, val.get(i).toString())); + } + } + else + { + properties.add(new PropertyValue(key, value)); + } + } + + return new MutablePropertyValues(properties); + } + + @Override + public void bindJson(JSONObject json) + { + setFilename(json.get("filename").toString()); + List forms = new ArrayList<>(); + + JSONArray models = json.optJSONArray("queryForms"); + if (models == null) + { + QueryController.LOG.error("No models to export; Form's `queryForms` property was null"); + throw new RuntimeValidationException("No queries to export; Form's `queryForms` property was null"); + } + + for (JSONObject queryModel : JsonUtil.toJSONObjectList(models)) + { + ExportQueryForm qf = new ExportQueryForm(); + qf.setViewContext(getViewContext()); + + qf.bindParameters(getPropertyValues(queryModel)); + forms.add(qf); + } + + setQueryForms(forms); + } + } + + /** + * Export multiple query forms + */ + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportQueriesXLSXAction extends ReadOnlyApiAction + { + @Override + public Object execute(ExportQueriesForm form, BindException errors) throws Exception + { + getPageConfig().setTemplate(PageConfig.Template.None); + HttpServletResponse response = getViewContext().getResponse(); + response.setHeader("X-Robots-Tag", "noindex"); + ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment); + ViewContext viewContext = getViewContext(); + + Map> nameFormMap = new CaseInsensitiveHashMap<>(); + Map sheetNames = new HashMap<>(); + form.getQueryForms().forEach(qf -> { + String sheetName = qf.getSheetName(); + QueryView qv = qf.getQueryView(); + // use the given sheet name if provided, otherwise try the query definition name + String name = StringUtils.isNotBlank(sheetName) ? sheetName : qv.getQueryDef().getName(); + // if there is no sheet name or queryDefinition name, use a data region name if provided. Otherwise, use "Data" + name = StringUtils.isNotBlank(name) ? name : StringUtils.isNotBlank(qv.getDataRegionName()) ? qv.getDataRegionName() : "Data"; + // clean it to remove undesirable characters and make it of an acceptable length + name = ExcelWriter.cleanSheetName(name); + nameFormMap.computeIfAbsent(name, k -> new ArrayList<>()).add(qf); + }); + // Issue 53722: Need to assure unique names for the sheets in the presence of really long names + for (Map.Entry> entry : nameFormMap.entrySet()) { + String name = entry.getKey(); + if (entry.getValue().size() > 1) + { + List queryForms = entry.getValue(); + int countLength = String.valueOf(queryForms.size()).length() + 2; + if (countLength > name.length()) + throw new IllegalArgumentException("Cannot create sheet names from overlapping query names."); + for (int i = 0; i < queryForms.size(); i++) + { + sheetNames.put(entry.getValue().get(i), name.substring(0, name.length() - countLength) + "(" + i + ")"); + } + } + else + { + sheetNames.put(entry.getValue().get(0), name); + } + } + ExcelWriter writer = new ExcelWriter(ExcelWriter.ExcelDocumentType.xlsx) { + @Override + protected void renderSheets(Workbook workbook) + { + for (ExportQueryForm qf : form.getQueryForms()) + { + qf.setViewContext(viewContext); + qf.getSchema(); + + QueryView qv = qf.getQueryView(); + QueryView.ExcelExportConfig config = new QueryView.ExcelExportConfig(response, qf.getHeaderType()) + .setExcludeColumns(qf.getExcludeColumns()) + .setRenamedColumns(qf.getRenameColumnMap()); + qv.configureExcelWriter(this, config); + setSheetName(sheetNames.get(qf)); + setAutoSize(true); + renderNewSheet(workbook); + qv.logAuditEvent("Exported to Excel", getDataRowCount()); + } + + workbook.setActiveSheet(0); + } + }; + writer.setFilenamePrefix(form.getFilename()); + writer.renderWorkbook(response); + return null; //Returning anything here will cause error as excel writer will close the response stream + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class TemplateForm extends ExportQueryForm + { + boolean insertColumnsOnly = true; + String filenamePrefix; + FieldKey[] includeColumn; + String fileType; + + public TemplateForm() + { + _headerType = ColumnHeaderType.Caption; + } + + // "captionType" field backwards compatibility + public void setCaptionType(ColumnHeaderType headerType) + { + _headerType = headerType; + } + + public ColumnHeaderType getCaptionType() + { + return _headerType; + } + + public List getIncludeColumns() + { + if (includeColumn == null || includeColumn.length == 0) + return Collections.emptyList(); + return Arrays.asList(includeColumn); + } + + public FieldKey[] getIncludeColumn() + { + return includeColumn; + } + + public void setIncludeColumn(FieldKey[] includeColumn) + { + this.includeColumn = includeColumn; + } + + @NotNull + public String getFilenamePrefix() + { + return filenamePrefix == null ? getQueryName() : filenamePrefix; + } + + public void setFilenamePrefix(String prefix) + { + filenamePrefix = prefix; + } + + public String getFileType() + { + return fileType; + } + + public void setFileType(String fileType) + { + this.fileType = fileType; + } + } + + + /** + * Can be used to generate an Excel template for import into a table. Supported URL params include: + *
+ *
filenamePrefix
+ *
the prefix of the excel file that is generated, defaults to '_data'
+ * + *
query.viewName
+ *
if provided, the resulting excel file will use the fields present in this view. + * Non-usereditable columns will be skipped. + * Non-existent columns (like a lookup) unless includeMissingColumns is true. + * Any required columns missing from this view will be appended to the end of the query. + *
+ * + *
includeColumn
+ *
List of column names to include, even if the column doesn't exist or is non-userEditable. + * For example, this can be used to add a fake column that is only supported during the import process. + *
+ * + *
excludeColumn
+ *
List of column names to exclude. + *
+ * + *
exportAlias.columns
+ *
Use alternative column name in excel: exportAlias.originalColumnName=aliasColumnName + *
+ * + *
captionType
+ *
determines which column property is used in the header, either Label or Name
+ *
+ */ + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportExcelTemplateAction extends _ExportQuery + { + public ExportExcelTemplateAction() + { + setCommandClass(TemplateForm.class); + } + + @Override + void _export(TemplateForm form, QueryView view) throws Exception + { + boolean respectView = form.getViewName() != null; + ExcelWriter.ExcelDocumentType fileType = ExcelWriter.ExcelDocumentType.xlsx; + if (form.getFileType() != null) + { + try + { + fileType = ExcelWriter.ExcelDocumentType.valueOf(form.getFileType().toLowerCase()); + } + catch (IllegalArgumentException ignored) {} + } + view.exportToExcel( new QueryView.ExcelExportConfig(getViewContext().getResponse(), form.getHeaderType()) + .setTemplateOnly(true) + .setInsertColumnsOnly(form.insertColumnsOnly) + .setDocType(fileType) + .setRespectView(respectView) + .setIncludeColumns(form.getIncludeColumns()) + .setExcludeColumns(form.getExcludeColumns()) + .setRenamedColumns(form.getRenameColumnMap()) + .setPrefix((StringUtils.isEmpty(form.getFilenamePrefix()) ? "Import" : form.getFilenamePrefix()) + "_Template") // Issue 48028: Change template file names + ); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportQueryForm extends QueryForm + { + protected ColumnHeaderType _headerType = null; // QueryView will provide a default header type if the user doesn't select one + FieldKey[] excludeColumn; + Map renameColumns = null; + private String sheetName; + + public void setSheetName(String sheetName) + { + this.sheetName = sheetName; + } + + public String getSheetName() + { + return sheetName; + } + + public ColumnHeaderType getHeaderType() + { + return _headerType; + } + + public void setHeaderType(ColumnHeaderType headerType) + { + _headerType = headerType; + } + + public List getExcludeColumns() + { + if (excludeColumn == null || excludeColumn.length == 0) + return Collections.emptyList(); + return Arrays.asList(excludeColumn); + } + + public void setExcludeColumn(FieldKey[] excludeColumn) + { + this.excludeColumn = excludeColumn; + } + + public Map getRenameColumnMap() + { + if (renameColumns != null) + return renameColumns; + + renameColumns = new CaseInsensitiveHashMap<>(); + final String renameParamPrefix = "exportAlias."; + PropertyValue[] pvs = getInitParameters().getPropertyValues(); + for (PropertyValue pv : pvs) + { + String paramName = pv.getName(); + if (!paramName.startsWith(renameParamPrefix) || pv.getValue() == null) + continue; + + renameColumns.put(paramName.substring(renameParamPrefix.length()), (String) pv.getValue()); + } + + return renameColumns; + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportRowsTsvForm extends ExportQueryForm + { + private TSVWriter.DELIM _delim = TSVWriter.DELIM.TAB; + private TSVWriter.QUOTE _quote = TSVWriter.QUOTE.DOUBLE; + + public TSVWriter.DELIM getDelim() + { + return _delim; + } + + public void setDelim(TSVWriter.DELIM delim) + { + _delim = delim; + } + + public TSVWriter.QUOTE getQuote() + { + return _quote; + } + + public void setQuote(TSVWriter.QUOTE quote) + { + _quote = quote; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportRowsTsvAction extends _ExportQuery + { + public ExportRowsTsvAction() + { + setCommandClass(ExportRowsTsvForm.class); + } + + @Override + void _export(ExportRowsTsvForm form, QueryView view) throws Exception + { + view.exportToTsv(getViewContext().getResponse(), form.getDelim(), form.getQuote(), form.getHeaderType(), form.getRenameColumnMap()); + } + } + + + @RequiresNoPermission + @IgnoresTermsOfUse + @Action(ActionType.Export.class) + public static class ExcelWebQueryAction extends ExportRowsTsvAction + { + @Override + public ModelAndView getView(ExportRowsTsvForm form, BindException errors) throws Exception + { + if (!getContainer().hasPermission(getUser(), ReadPermission.class)) + { + if (!getUser().isGuest()) + { + throw new UnauthorizedException(); + } + getViewContext().getResponse().setHeader("WWW-Authenticate", "Basic realm=\"" + LookAndFeelProperties.getInstance(ContainerManager.getRoot()).getDescription() + "\""); + getViewContext().getResponse().setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return null; + } + + // Bug 5610. Excel web queries don't work over SSL if caching is disabled, + // so we need to allow caching so that Excel can read from IE on Windows. + HttpServletResponse response = getViewContext().getResponse(); + // Set the headers to allow the client to cache, but not proxies + ResponseHelper.setPrivate(response); + + QueryView view = form.getQueryView(); + getPageConfig().setTemplate(PageConfig.Template.None); + view.exportToExcelWebQuery(getViewContext().getResponse()); + return null; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExcelWebQueryDefinitionAction extends SimpleViewAction + { + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + getPageConfig().setTemplate(PageConfig.Template.None); + form.getQueryView(); + String queryViewActionURL = form.getQueryViewActionURL(); + ActionURL url; + if (queryViewActionURL != null) + { + url = new ActionURL(queryViewActionURL); + } + else + { + url = getViewContext().cloneActionURL(); + url.setAction(ExcelWebQueryAction.class); + } + getViewContext().getResponse().setContentType("text/x-ms-iqy"); + String filename = FileUtil.makeFileNameWithTimestamp(form.getQueryName(), "iqy"); + ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, filename); + PrintWriter writer = getViewContext().getResponse().getWriter(); + writer.println("WEB"); + writer.println("1"); + writer.println(url.getURIString()); + + QueryService.get().addAuditEvent(getUser(), getContainer(), form.getSchemaName(), form.getQueryName(), url, "Exported to Excel Web Query definition", null); + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + // Trusted analysts who are editors can create and modify queries + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + @Action(ActionType.SelectMetaData.class) + public class MetadataQueryAction extends SimpleViewAction + { + QueryForm _form = null; + + @Override + public ModelAndView getView(QueryForm queryForm, BindException errors) throws Exception + { + String schemaName = queryForm.getSchemaName(); + String queryName = queryForm.getQueryName(); + + _form = queryForm; + + if (schemaName.isEmpty() && (null == queryName || queryName.isEmpty())) + { + throw new NotFoundException("Must provide schemaName and queryName."); + } + + if (schemaName.isEmpty()) + { + throw new NotFoundException("Must provide schemaName."); + } + + if (null == queryName || queryName.isEmpty()) + { + throw new NotFoundException("Must provide queryName."); + } + + if (!queryForm.getQueryDef().isMetadataEditable()) + throw new UnauthorizedException("Query metadata is not editable"); + + if (!queryForm.canEditMetadata()) + throw new UnauthorizedException("You do not have permission to edit the query metadata"); + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("queryMetadataEditor")); + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + var metadataQuery = _form.getQueryDef().getName(); + if (null != metadataQuery) + root.addChild("Edit Metadata: " + _form.getQueryName(), metadataQuery); + else + root.addChild("Edit Metadata: " + _form.getQueryName()); + } + } + + // Uck. Supports the old and new view designer. + protected JSONObject saveCustomView(Container container, QueryDefinition queryDef, + String regionName, String viewName, boolean replaceExisting, + boolean share, boolean inherit, + boolean session, boolean saveFilter, + boolean hidden, JSONObject jsonView, + ActionURL returnUrl, + BindException errors) + { + User owner = getUser(); + boolean canSaveForAllUsers = container.hasPermission(getUser(), EditSharedViewPermission.class); + if (share && canSaveForAllUsers && !session) + { + owner = null; + } + String name = StringUtils.trimToNull(viewName); + + if (name != null && RESERVED_VIEW_NAMES.contains(name.toLowerCase())) + errors.reject(ERROR_MSG, "The grid view name '" + name + "' is not allowed."); + + boolean isHidden = hidden; + CustomView view; + if (owner == null) + view = queryDef.getSharedCustomView(name); + else + view = queryDef.getCustomView(owner, getViewContext().getRequest(), name); + + if (view != null && !replaceExisting && !StringUtils.isEmpty(name)) + errors.reject(ERROR_MSG, "A saved view by the name \"" + viewName + "\" already exists. "); + + // 11179: Allow editing the view if we're saving to session. + // NOTE: Check for session flag first otherwise the call to canEdit() will add errors to the errors collection. + boolean canEdit = view == null || session || view.canEdit(container, errors); + if (errors.hasErrors()) + return null; + + if (canEdit) + { + // Issue 13594: Disallow setting of the customview inherit bit for query views + // that have no available container filter types. Unfortunately, the only way + // to get the container filters is from the QueryView. Ideally, the query def + // would know if it was container filterable or not instead of using the QueryView. + if (inherit && canSaveForAllUsers && !session) + { + UserSchema schema = queryDef.getSchema(); + QueryView queryView = schema.createView(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, queryDef.getName(), errors); + if (queryView != null) + { + Set allowableContainerFilterTypes = queryView.getAllowableContainerFilterTypes(); + if (allowableContainerFilterTypes.size() <= 1) + { + errors.reject(ERROR_MSG, "QueryView doesn't support inherited custom views"); + return null; + } + } + } + + // Create a new view if none exists or the current view is a shared view + // and the user wants to override the shared view with a personal view. + if (view == null || (owner != null && view.isShared())) + { + if (owner == null) + view = queryDef.createSharedCustomView(name); + else + view = queryDef.createCustomView(owner, name); + + if (owner != null && session) + ((CustomViewImpl) view).isSession(true); + view.setIsHidden(hidden); + } + else if (session != view.isSession()) + { + if (session) + { + assert !view.isSession(); + if (owner == null) + { + errors.reject(ERROR_MSG, "Session views can't be saved for all users"); + return null; + } + + // The form is saving to session but the view is in the database. + // Make a copy in case it's a read-only version from an XML file + view = queryDef.createCustomView(owner, name); + ((CustomViewImpl) view).isSession(true); + } + else + { + // Remove the session view and call saveCustomView again to either create a new view or update an existing view. + assert view.isSession(); + boolean success = false; + try + { + view.delete(getUser(), getViewContext().getRequest()); + JSONObject ret = saveCustomView(container, queryDef, regionName, viewName, replaceExisting, share, inherit, session, saveFilter, hidden, jsonView, returnUrl, errors); + success = !errors.hasErrors() && ret != null; + return success ? ret : null; + } + finally + { + if (!success) + { + // dirty the view then save the deleted session view back in session state + view.setName(view.getName()); + view.save(getUser(), getViewContext().getRequest()); + } + } + } + } + + // NOTE: Updating, saving, and deleting the view may throw an exception + CustomViewImpl cview = null; + if (view instanceof EditableCustomView && view.isOverridable()) + { + cview = ((EditableCustomView)view).getEditableViewInfo(owner, session); + } + if (null == cview) + { + throw new IllegalArgumentException("View cannot be edited"); + } + + cview.update(jsonView, saveFilter); + if (canSaveForAllUsers && !session) + { + cview.setCanInherit(inherit); + } + isHidden = view.isHidden(); + cview.setContainer(container); + cview.save(getUser(), getViewContext().getRequest()); + if (owner == null) + { + // New view is shared so delete any previous custom view owned by the user with the same name. + CustomView personalView = queryDef.getCustomView(getUser(), getViewContext().getRequest(), name); + if (personalView != null && !personalView.isShared()) + { + personalView.delete(getUser(), getViewContext().getRequest()); + } + } + } + + if (null == returnUrl) + { + returnUrl = getViewContext().cloneActionURL().setAction(ExecuteQueryAction.class); + } + else + { + returnUrl = returnUrl.clone(); + if (name == null || !canEdit) + { + returnUrl.deleteParameter(regionName + "." + QueryParam.viewName); + } + else if (!isHidden) + { + returnUrl.replaceParameter(regionName + "." + QueryParam.viewName, name); + } + returnUrl.deleteParameter(regionName + "." + QueryParam.ignoreFilter); + if (saveFilter) + { + for (String key : returnUrl.getKeysByPrefix(regionName + ".")) + { + if (isFilterOrSort(regionName, key)) + returnUrl.deleteFilterParameters(key); + } + } + } + + JSONObject ret = new JSONObject(); + ret.put("redirect", returnUrl); + Map viewAsMap = CustomViewUtil.toMap(view, getUser(), true); + try + { + ret.put("view", new JSONObject(viewAsMap, new JSONParserConfiguration().withMaxNestingDepth(10))); + } + catch (JSONException e) + { + LOG.error("Failed to save view: {}", jsonView, e); + } + return ret; + } + + private boolean isFilterOrSort(String dataRegionName, String param) + { + assert param.startsWith(dataRegionName + "."); + String check = param.substring(dataRegionName.length() + 1); + if (check.contains("~")) + return true; + if ("sort".equals(check)) + return true; + if (check.equals("containerFilterName")) + return true; + return false; + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Configure.class) + @JsonInputLimit(100_000) + public class SaveQueryViewsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(SimpleApiJsonForm form, BindException errors) + { + JSONObject json = form.getJsonObject(); + if (json == null) + throw new NotFoundException("Empty request"); + + String schemaName = json.optString(QueryParam.schemaName.toString(), null); + String queryName = json.optString(QueryParam.queryName.toString(), null); + if (schemaName == null || queryName == null) + throw new NotFoundException("schemaName and queryName are required"); + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); + if (schema == null) + throw new NotFoundException("schema not found"); + + QueryDefinition queryDef = QueryService.get().getQueryDef(getUser(), getContainer(), schemaName, queryName); + if (queryDef == null) + queryDef = schema.getQueryDefForTable(queryName); + if (queryDef == null) + throw new NotFoundException("query not found"); + + JSONObject response = new JSONObject(); + response.put(QueryParam.schemaName.toString(), schemaName); + response.put(QueryParam.queryName.toString(), queryName); + JSONArray views = new JSONArray(); + response.put("views", views); + + ActionURL redirect = null; + JSONArray jsonViews = json.getJSONArray("views"); + for (int i = 0; i < jsonViews.length(); i++) + { + final JSONObject jsonView = jsonViews.getJSONObject(i); + String viewName = jsonView.optString("name", null); + if (viewName == null) + throw new NotFoundException("'name' is required all views'"); + + boolean shared = jsonView.optBoolean("shared", false); + boolean replace = jsonView.optBoolean("replace", true); // "replace" was the default before the flag is introduced + boolean inherit = jsonView.optBoolean("inherit", false); + boolean session = jsonView.optBoolean("session", false); + boolean hidden = jsonView.optBoolean("hidden", false); + // Users may save views to a location other than the current container + String containerPath = jsonView.optString("containerPath", getContainer().getPath()); + Container container; + if (inherit) + { + // Only respect this request if it's a view that is inheritable in subfolders + container = ContainerManager.getForPath(containerPath); + } + else + { + // Otherwise, save it in the current container + container = getContainer().getContainerFor(ContainerType.DataType.customQueryViews); + } + + if (container == null) + { + throw new NotFoundException("No such container: " + containerPath); + } + + JSONObject savedView = saveCustomView( + container, queryDef, QueryView.DATAREGIONNAME_DEFAULT, viewName, replace, + shared, inherit, session, true, hidden, jsonView, null, errors); + + if (savedView != null) + { + if (redirect == null) + redirect = (ActionURL)savedView.get("redirect"); + views.put(savedView.getJSONObject("view")); + } + } + + if (redirect != null) + response.put("redirect", redirect); + + if (errors.hasErrors()) + return null; + else + return new ApiSimpleResponse(response); + } + } + + public static class RenameQueryViewForm extends QueryForm + { + private String newName; + + public String getNewName() + { + return newName; + } + + public void setNewName(String newName) + { + this.newName = newName; + } + } + + @RequiresPermission(ReadPermission.class) + public class RenameQueryViewAction extends MutatingApiAction + { + @Override + public ApiResponse execute(RenameQueryViewForm form, BindException errors) + { + CustomView view = form.getCustomView(); + if (view == null) + { + throw new NotFoundException(); + } + + Container container = getContainer(); + User user = getUser(); + + String schemaName = form.getSchemaName(); + String queryName = form.getQueryName(); + if (schemaName == null || queryName == null) + throw new NotFoundException("schemaName and queryName are required"); + + UserSchema schema = QueryService.get().getUserSchema(user, container, schemaName); + if (schema == null) + throw new NotFoundException("schema not found"); + + QueryDefinition queryDef = QueryService.get().getQueryDef(user, container, schemaName, queryName); + if (queryDef == null) + queryDef = schema.getQueryDefForTable(queryName); + if (queryDef == null) + throw new NotFoundException("query not found"); + + renameCustomView(container, queryDef, view, form.getNewName(), errors); + + if (errors.hasErrors()) + return null; + else + return new ApiSimpleResponse("success", true); + } + } + + protected void renameCustomView(Container container, QueryDefinition queryDef, CustomView fromView, String newViewName, BindException errors) + { + if (newViewName != null && RESERVED_VIEW_NAMES.contains(newViewName.toLowerCase())) + errors.reject(ERROR_MSG, "The grid view name '" + newViewName + "' is not allowed."); + + String newName = StringUtils.trimToNull(newViewName); + if (StringUtils.isEmpty(newName)) + errors.reject(ERROR_MSG, "View name cannot be blank."); + + if (errors.hasErrors()) + return; + + User owner = getUser(); + boolean canSaveForAllUsers = container.hasPermission(getUser(), EditSharedViewPermission.class); + + if (!fromView.canEdit(container, errors)) + return; + + if (fromView.isSession()) + { + errors.reject(ERROR_MSG, "Cannot rename a session view."); + return; + } + + CustomView duplicateView = queryDef.getCustomView(owner, getViewContext().getRequest(), newName); + if (duplicateView == null && canSaveForAllUsers) + duplicateView = queryDef.getSharedCustomView(newName); + if (duplicateView != null) + { + // only allow duplicate view name if creating a new private view to shadow an existing shared view + if (!(!fromView.isShared() && duplicateView.isShared())) + { + errors.reject(ERROR_MSG, "Another saved view by the name \"" + newName + "\" already exists. "); + return; + } + } + + fromView.setName(newViewName); + fromView.save(getUser(), getViewContext().getRequest()); + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Configure.class) + public class PropertiesQueryAction extends FormViewAction + { + PropertiesForm _form = null; + private String _queryName; + + @Override + public void validateCommand(PropertiesForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(PropertiesForm form, boolean reshow, BindException errors) + { + // assertQueryExists requires that it be well-formed + // assertQueryExists(form); + QueryDefinition queryDef = form.getQueryDef(); + _form = form; + _form.setDescription(queryDef.getDescription()); + _form.setInheritable(queryDef.canInherit()); + _form.setHidden(queryDef.isHidden()); + setHelpTopic("editQueryProperties"); + _queryName = form.getQueryName(); + + return new JspView<>("/org/labkey/query/view/propertiesQuery.jsp", form, errors); + } + + @Override + public boolean handlePost(PropertiesForm form, BindException errors) throws Exception + { + // assertQueryExists requires that it be well-formed + // assertQueryExists(form); + if (!form.canEdit()) + { + throw new UnauthorizedException(); + } + QueryDefinition queryDef = form.getQueryDef(); + _queryName = form.getQueryName(); + if (!queryDef.getDefinitionContainer().getId().equals(getContainer().getId())) + throw new NotFoundException("Query not found"); + + _form = form; + + if (!StringUtils.isEmpty(form.rename) && !form.rename.equalsIgnoreCase(queryDef.getName())) + { + // issue 17766: check if query or table exist with this name + if (null != QueryManager.get().getQueryDef(getContainer(), form.getSchemaName(), form.rename, true) + || null != form.getSchema().getTable(form.rename,null)) + { + errors.reject(ERROR_MSG, "A query or table with the name \"" + form.rename + "\" already exists."); + return false; + } + + // Issue 40895: update queryName in xml metadata + updateXmlMetadata(queryDef); + queryDef.setName(form.rename); + // update form so getSuccessURL() works + _form = new PropertiesForm(form.getSchemaName(), form.rename); + _form.setViewContext(form.getViewContext()); + _queryName = form.rename; + } + + queryDef.setDescription(form.description); + queryDef.setCanInherit(form.inheritable); + queryDef.setIsHidden(form.hidden); + queryDef.save(getUser(), getContainer()); + return true; + } + + private void updateXmlMetadata(QueryDefinition queryDef) throws XmlException + { + if (null != queryDef.getMetadataXml()) + { + TablesDocument doc = TablesDocument.Factory.parse(queryDef.getMetadataXml()); + if (null != doc) + { + for (TableType tableType : doc.getTables().getTableArray()) + { + if (tableType.getTableName().equalsIgnoreCase(queryDef.getName())) + { + // update tableName in xml + tableType.setTableName(_form.rename); + } + } + XmlOptions xmlOptions = new XmlOptions(); + xmlOptions.setSavePrettyPrint(); + // Don't use an explicit namespace, making the XML much more readable + xmlOptions.setUseDefaultNamespace(); + queryDef.setMetadataXml(doc.xmlText(xmlOptions)); + } + } + } + + @Override + public ActionURL getSuccessURL(PropertiesForm propertiesForm) + { + ActionURL url = new ActionURL(BeginAction.class, propertiesForm.getViewContext().getContainer()); + url.addParameter("schemaName", propertiesForm.getSchemaName()); + if (null != _queryName) + url.addParameter("queryName", _queryName); + return url; + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + root.addChild("Edit query properties"); + } + } + + @ActionNames("truncateTable") + @RequiresPermission(AdminPermission.class) + public static class TruncateTableAction extends MutatingApiAction + { + UserSchema schema; + TableInfo table; + + @Override + public void validateForm(QueryForm form, Errors errors) + { + String schemaName = form.getSchemaName(); + String queryName = form.getQueryName(); + + if (isBlank(schemaName) || isBlank(queryName)) + throw new NotFoundException("schemaName and queryName are required"); + + schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); + if (null == schema) + throw new NotFoundException("The schema '" + schemaName + "' does not exist."); + + table = schema.getTable(queryName, null); + if (null == table) + throw new NotFoundException("The query '" + queryName + "' in the schema '" + schemaName + "' does not exist."); + } + + @Override + public ApiResponse execute(QueryForm form, BindException errors) throws Exception + { + int deletedRows; + QueryUpdateService qus = table.getUpdateService(); + + if (null == qus) + throw new IllegalArgumentException("The query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "' is not truncatable."); + + try (DbScope.Transaction transaction = table.getSchema().getScope().ensureTransaction()) + { + deletedRows = qus.truncateRows(getUser(), getContainer(), null, null); + transaction.commit(); + } + + ApiSimpleResponse response = new ApiSimpleResponse(); + + response.put("success", true); + response.put(BaseSaveRowsAction.PROP_SCHEMA_NAME, form.getSchemaName()); + response.put(BaseSaveRowsAction.PROP_QUERY_NAME, form.getQueryName()); + response.put("deletedRows", deletedRows); + + return response; + } + } + + + @RequiresPermission(DeletePermission.class) + public static class DeleteQueryRowsAction extends FormHandlerAction + { + @Override + public void validateCommand(QueryForm target, Errors errors) + { + } + + @Override + public boolean handlePost(QueryForm form, BindException errors) + { + TableInfo table = form.getQueryView().getTable(); + + if (!table.hasPermission(getUser(), DeletePermission.class)) + { + throw new UnauthorizedException(); + } + + QueryUpdateService updateService = table.getUpdateService(); + if (updateService == null) + throw new UnsupportedOperationException("Unable to delete - no QueryUpdateService registered for " + form.getSchemaName() + "." + form.getQueryName()); + + Set ids = DataRegionSelection.getSelected(form.getViewContext(), null, true); + List pks = table.getPkColumns(); + int numPks = pks.size(); + + //normalize the pks to arrays of correctly-typed objects + List> keyValues = new ArrayList<>(ids.size()); + for (String id : ids) + { + String[] stringValues; + if (numPks > 1) + { + stringValues = id.split(","); + if (stringValues.length != numPks) + throw new IllegalStateException("This table has " + numPks + " primary-key columns, but " + stringValues.length + " primary-key values were provided!"); + } + else + stringValues = new String[]{id}; + + Map rowKeyValues = new CaseInsensitiveHashMap<>(); + for (int idx = 0; idx < numPks; ++idx) + { + ColumnInfo keyColumn = pks.get(idx); + Object keyValue = keyColumn.getJavaClass() == String.class ? stringValues[idx] : ConvertUtils.convert(stringValues[idx], keyColumn.getJavaClass()); + rowKeyValues.put(keyColumn.getName(), keyValue); + } + keyValues.add(rowKeyValues); + } + + DbSchema dbSchema = table.getSchema(); + try + { + dbSchema.getScope().executeWithRetry(tx -> + { + try + { + updateService.deleteRows(getUser(), getContainer(), keyValues, null, null); + } + catch (SQLException x) + { + if (!RuntimeSQLException.isConstraintException(x)) + throw new RuntimeSQLException(x); + errors.reject(ERROR_MSG, getMessage(table.getSchema().getSqlDialect(), x)); + } + catch (DataIntegrityViolationException | OptimisticConflictException e) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + catch (BatchValidationException x) + { + x.addToErrors(errors); + } + catch (Exception x) + { + errors.reject(ERROR_MSG, null == x.getMessage() ? x.toString() : x.getMessage()); + ExceptionUtil.logExceptionToMothership(getViewContext().getRequest(), x); + } + // need to throw here to avoid committing tx + if (errors.hasErrors()) + throw new DbScope.RetryPassthroughException(errors); + return true; + }); + } + catch (DbScope.RetryPassthroughException x) + { + if (x.getCause() != errors) + x.throwRuntimeException(); + } + return !errors.hasErrors(); + } + + @Override + public ActionURL getSuccessURL(QueryForm form) + { + return form.getReturnActionURL(); + } + } + + @RequiresPermission(ReadPermission.class) + public static class DetailsQueryRowAction extends UserSchemaAction + { + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + + if (_schema != null && _table != null) + { + if (_table.hasPermission(getUser(), UpdatePermission.class)) + { + StringExpression updateExpr = _form.getQueryDef().urlExpr(QueryAction.updateQueryRow, _schema.getContainer()); + if (updateExpr != null) + { + String url = updateExpr.eval(tableForm.getTypedValues()); + if (url != null) + { + ActionURL updateUrl = new ActionURL(url); + ActionButton editButton = new ActionButton("Edit", updateUrl); + bb.add(editButton); + } + } + } + + + ActionURL gridUrl; + if (_form.getReturnActionURL() != null) + { + // If we have a specific return URL requested, use that + gridUrl = _form.getReturnActionURL(); + } + else + { + // Otherwise go back to the default grid view + gridUrl = _schema.urlFor(QueryAction.executeQuery, _form.getQueryDef()); + } + if (gridUrl != null) + { + ActionButton gridButton = new ActionButton("Show Grid", gridUrl); + bb.add(gridButton); + } + } + + DetailsView detailsView = new DetailsView(tableForm); + detailsView.setFrame(WebPartView.FrameType.PORTAL); + detailsView.getDataRegion().setButtonBar(bb); + + VBox view = new VBox(detailsView); + + DetailsURL detailsURL = QueryService.get().getAuditDetailsURL(getUser(), getContainer(), _table); + + if (detailsURL != null) + { + String url = detailsURL.eval(tableForm.getTypedValues()); + if (url != null) + { + ActionURL auditURL = new ActionURL(url); + + QueryView historyView = QueryUpdateAuditProvider.createDetailsQueryView(getViewContext(), + auditURL.getParameter(QueryParam.schemaName), + auditURL.getParameter(QueryParam.queryName), + auditURL.getParameter("keyValue"), errors); + + if (null != historyView) + { + historyView.setFrame(WebPartView.FrameType.PORTAL); + historyView.setTitle("History"); + + view.addView(historyView); + } + } + } + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) + { + return false; + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Details"); + } + } + + @RequiresPermission(InsertPermission.class) + public static class InsertQueryRowAction extends UserSchemaAction + { + @Override + public BindException bindParameters(PropertyValues m) throws Exception + { + BindException bind = super.bindParameters(m); + + // what is going on with UserSchemaAction and form binding? Why doesn't successUrl bind? + QueryUpdateForm form = (QueryUpdateForm)bind.getTarget(); + if (null == form.getSuccessUrl() && null != m.getPropertyValue(ActionURL.Param.successUrl.name())) + form.setSuccessUrl(new ReturnURLString(m.getPropertyValue(ActionURL.Param.successUrl.name()).getValue().toString())); + return bind; + } + + Map insertedRow = null; + + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Insert Row"); + + InsertView view = new InsertView(tableForm, errors); + view.getDataRegion().setButtonBar(createSubmitCancelButtonBar(tableForm)); + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) + { + List> list = doInsertUpdate(tableForm, errors, true); + if (null != list && list.size() == 1) + insertedRow = list.get(0); + return 0 == errors.getErrorCount(); + } + + /** + * NOTE: UserSchemaAction.addNavTrail() uses this method getSuccessURL() for the nav trail link (form==null). + * It is used for where to go on success, and also as a "back" link in the nav trail + * If there is a setSuccessUrl specified, we will use that for successful submit + */ + @Override + public ActionURL getSuccessURL(QueryUpdateForm form) + { + if (null == form) + return super.getSuccessURL(null); + + String str = null; + if (form.getSuccessUrl() != null) + str = form.getSuccessUrl().toString(); + if (isBlank(str)) + str = form.getReturnUrl(); + + if ("details.view".equals(str)) + { + if (null == insertedRow) + return super.getSuccessURL(form); + StringExpression se = form.getTable().getDetailsURL(null, getContainer()); + if (null == se) + return super.getSuccessURL(form); + str = se.eval(insertedRow); + } + try + { + if (!isBlank(str)) + return new ActionURL(str); + } + catch (IllegalArgumentException x) + { + // pass + } + return super.getSuccessURL(form); + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Insert " + _table.getName()); + } + } + + @RequiresPermission(UpdatePermission.class) + public static class UpdateQueryRowAction extends UserSchemaAction + { + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + ButtonBar bb = createSubmitCancelButtonBar(tableForm); + UpdateView view = new UpdateView(tableForm, errors); + view.getDataRegion().setButtonBar(bb); + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) throws Exception + { + doInsertUpdate(tableForm, errors, false); + return 0 == errors.getErrorCount(); + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Edit " + _table.getName()); + } + } + + @RequiresPermission(UpdatePermission.class) + public static class UpdateQueryRowsAction extends UpdateQueryRowAction + { + @Override + public ModelAndView handleRequest(QueryUpdateForm tableForm, BindException errors) throws Exception + { + tableForm.setBulkUpdate(true); + return super.handleRequest(tableForm, errors); + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) throws Exception + { + boolean ret; + + if (tableForm.isDataSubmit()) + { + ret = super.handlePost(tableForm, errors); + if (ret) + DataRegionSelection.clearAll(getViewContext(), null); // in case we altered primary keys, see issue #35055 + return ret; + } + + return false; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Edit Multiple " + _table.getName()); + } + } + + // alias + public static class DeleteAction extends DeleteQueryRowsAction + { + } + + public abstract static class QueryViewAction extends SimpleViewAction + { + QueryForm _form; + QueryView _queryView; + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class APIQueryForm extends ContainerFilterQueryForm + { + private Integer _start; + private Integer _limit; + private boolean _includeDetailsColumn = false; + private boolean _includeUpdateColumn = false; + private boolean _includeTotalCount = true; + private boolean _includeStyle = false; + private boolean _includeDisplayValues = false; + private boolean _minimalColumns = true; + private boolean _includeMetadata = true; + + public Integer getStart() + { + return _start; + } + + public void setStart(Integer start) + { + _start = start; + } + + public Integer getLimit() + { + return _limit; + } + + public void setLimit(Integer limit) + { + _limit = limit; + } + + public boolean isIncludeTotalCount() + { + return _includeTotalCount; + } + + public void setIncludeTotalCount(boolean includeTotalCount) + { + _includeTotalCount = includeTotalCount; + } + + public boolean isIncludeStyle() + { + return _includeStyle; + } + + public void setIncludeStyle(boolean includeStyle) + { + _includeStyle = includeStyle; + } + + public boolean isIncludeDetailsColumn() + { + return _includeDetailsColumn; + } + + public void setIncludeDetailsColumn(boolean includeDetailsColumn) + { + _includeDetailsColumn = includeDetailsColumn; + } + + public boolean isIncludeUpdateColumn() + { + return _includeUpdateColumn; + } + + public void setIncludeUpdateColumn(boolean includeUpdateColumn) + { + _includeUpdateColumn = includeUpdateColumn; + } + + public boolean isIncludeDisplayValues() + { + return _includeDisplayValues; + } + + public void setIncludeDisplayValues(boolean includeDisplayValues) + { + _includeDisplayValues = includeDisplayValues; + } + + public boolean isMinimalColumns() + { + return _minimalColumns; + } + + public void setMinimalColumns(boolean minimalColumns) + { + _minimalColumns = minimalColumns; + } + + public boolean isIncludeMetadata() + { + return _includeMetadata; + } + + public void setIncludeMetadata(boolean includeMetadata) + { + _includeMetadata = includeMetadata; + } + + @Override + protected QuerySettings createQuerySettings(UserSchema schema) + { + QuerySettings results = super.createQuerySettings(schema); + + // See dataintegration/202: The java client api / remote ETL calls selectRows with showRows=all. We need to test _initParameters to properly read this + boolean missingShowRows = null == getViewContext().getRequest().getParameter(getDataRegionName() + "." + QueryParam.showRows) && null == _initParameters.getPropertyValue(getDataRegionName() + "." + QueryParam.showRows); + if (null == getLimit() && !results.isMaxRowsSet() && missingShowRows) + { + results.setShowRows(ShowRows.PAGINATED); + results.setMaxRows(DEFAULT_API_MAX_ROWS); + } + + if (getLimit() != null) + { + results.setShowRows(ShowRows.PAGINATED); + results.setMaxRows(getLimit()); + } + if (getStart() != null) + results.setOffset(getStart()); + + return results; + } + } + + public static final int DEFAULT_API_MAX_ROWS = 100000; + + @CSRF(CSRF.Method.NONE) // No need for CSRF token --- this is a non-mutating action that supports POST to allow for large payloads, see #36056 + @ActionNames("selectRows, getQuery") + @RequiresPermission(ReadPermission.class) + @ApiVersion(9.1) + @Action(ActionType.SelectData.class) + public class SelectRowsAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(APIQueryForm form, BindException errors) + { + // Issue 12233: add implicit maxRows=100k when using client API + QueryView view = form.getQueryView(); + + view.setShowPagination(form.isIncludeTotalCount()); + + //if viewName was specified, ensure that it was actually found and used + //QueryView.create() will happily ignore an invalid view name and just return the default view + if (null != StringUtils.trimToNull(form.getViewName()) && + null == view.getQueryDef().getCustomView(getUser(), getViewContext().getRequest(), form.getViewName())) + { + throw new NotFoundException("The requested view '" + form.getViewName() + "' does not exist for this user."); + } + + TableInfo t = view.getTable(); + if (null == t) + { + List qpes = view.getParseErrors(); + if (!qpes.isEmpty()) + throw qpes.get(0); + throw new NotFoundException(form.getQueryName()); + } + + boolean isEditable = isQueryEditable(view.getTable()); + boolean metaDataOnly = form.getQuerySettings().getMaxRows() == 0; + boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; + boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; + + ApiQueryResponse response; + + // 13.2 introduced the getData API action, a condensed response wire format, and a js wrapper to consume the wire format. Support this as an option for legacy APIs. + if (getRequestedApiVersion() >= 13.2) + { + ReportingApiQueryResponse fancyResponse = new ReportingApiQueryResponse(view, isEditable, true, view.getQueryDef().getName(), form.getQuerySettings().getOffset(), null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); + fancyResponse.arrayMultiValueColumns(arrayMultiValueColumns); + fancyResponse.includeFormattedValue(includeFormattedValue); + response = fancyResponse; + } + //if requested version is >= 9.1, use the extended api query response + else if (getRequestedApiVersion() >= 9.1) + { + response = new ExtendedApiQueryResponse(view, isEditable, true, + form.getSchemaName(), form.getQueryName(), form.getQuerySettings().getOffset(), null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); + } + else + { + response = new ApiQueryResponse(view, isEditable, true, + form.getSchemaName(), form.getQueryName(), form.getQuerySettings().getOffset(), null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), + form.isIncludeDisplayValues(), form.isIncludeMetadata()); + } + response.includeStyle(form.isIncludeStyle()); + + // Issues 29515 and 32269 - force key and other non-requested columns to be sent back, but only if the client has + // requested minimal columns, as we now do for ExtJS stores + if (form.isMinimalColumns()) + { + // Be sure to use the settings from the view, as it may have swapped it out with a customized version. + // See issue 38747. + response.setColumnFilter(view.getSettings().getFieldKeys()); + } + + return response; + } + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public static class GetDataAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws Exception + { + ObjectMapper mapper = JsonUtil.createDefaultMapper(); + mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + JSONObject object = form.getJsonObject(); + if (object == null) + { + object = new JSONObject(); + } + DataRequest builder = mapper.readValue(object.toString(), DataRequest.class); + + return builder.render(getViewContext(), errors); + } + } + + protected boolean isQueryEditable(TableInfo table) + { + if (!getContainer().hasPermission("isQueryEditable", getUser(), DeletePermission.class)) + return false; + QueryUpdateService updateService = null; + try + { + updateService = table.getUpdateService(); + } + catch(Exception ignore) {} + return null != table && null != updateService; + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExecuteSqlForm extends APIQueryForm + { + private String _sql; + private Integer _maxRows; + private Integer _offset; + private boolean _saveInSession; + + public String getSql() + { + return _sql; + } + + public void setSql(String sql) + { + _sql = PageFlowUtil.wafDecode(StringUtils.trim(sql)); + } + + public Integer getMaxRows() + { + return _maxRows; + } + + public void setMaxRows(Integer maxRows) + { + _maxRows = maxRows; + } + + public Integer getOffset() + { + return _offset; + } + + public void setOffset(Integer offset) + { + _offset = offset; + } + + @Override + public void setLimit(Integer limit) + { + _maxRows = limit; + } + + @Override + public void setStart(Integer start) + { + _offset = start; + } + + public boolean isSaveInSession() + { + return _saveInSession; + } + + public void setSaveInSession(boolean saveInSession) + { + _saveInSession = saveInSession; + } + + @Override + public String getQueryName() + { + // ExecuteSqlAction doesn't allow setting query name parameter. + return null; + } + + @Override + public void setQueryName(String name) + { + // ExecuteSqlAction doesn't allow setting query name parameter. + } + } + + @CSRF(CSRF.Method.NONE) // No need for CSRF token --- this is a non-mutating action that supports POST to allow for large payloads, see #36056 + @RequiresPermission(ReadPermission.class) + @ApiVersion(9.1) + @Action(ActionType.SelectData.class) + public class ExecuteSqlAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(ExecuteSqlForm form, BindException errors) + { + form.ensureSchemaExists(); + + String schemaName = StringUtils.trimToNull(form.getQuerySettings().getSchemaName()); + if (null == schemaName) + throw new IllegalArgumentException("No value was supplied for the required parameter 'schemaName'."); + String sql = form.getSql(); + if (StringUtils.isBlank(sql)) + throw new IllegalArgumentException("No value was supplied for the required parameter 'sql'."); + + //create a temp query settings object initialized with the posted LabKey SQL + //this will provide a temporary QueryDefinition to Query + QuerySettings settings = form.getQuerySettings(); + if (form.isSaveInSession()) + { + HttpSession session = getViewContext().getSession(); + if (session == null) + throw new IllegalStateException("Session required"); + + QueryDefinition def = QueryService.get().saveSessionQuery(getViewContext(), getContainer(), schemaName, sql); + settings.setDataRegionName("executeSql"); + settings.setQueryName(def.getName()); + } + else + { + settings = new TempQuerySettings(getViewContext(), sql, settings); + } + + //need to explicitly turn off various UI options that will try to refer to the + //current URL and query string + settings.setAllowChooseView(false); + settings.setAllowCustomizeView(false); + + // Issue 12233: add implicit maxRows=100k when using client API + settings.setShowRows(ShowRows.PAGINATED); + settings.setMaxRows(DEFAULT_API_MAX_ROWS); + + // 16961: ExecuteSql API without maxRows parameter defaults to returning 100 rows + //apply optional settings (maxRows, offset) + boolean metaDataOnly = false; + if (null != form.getMaxRows() && (form.getMaxRows() >= 0 || form.getMaxRows() == Table.ALL_ROWS)) + { + settings.setMaxRows(form.getMaxRows()); + metaDataOnly = Table.NO_ROWS == form.getMaxRows(); + } + + int offset = 0; + if (null != form.getOffset()) + { + settings.setOffset(form.getOffset().longValue()); + offset = form.getOffset(); + } + + //build a query view using the schema and settings + QueryView view = new QueryView(form.getSchema(), settings, errors); + view.setShowRecordSelectors(false); + view.setShowExportButtons(false); + view.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + view.setShowPagination(form.isIncludeTotalCount()); + + TableInfo t = view.getTable(); + boolean isEditable = null != t && isQueryEditable(view.getTable()); + boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; + boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; + + ApiQueryResponse response; + + // 13.2 introduced the getData API action, a condensed response wire format, and a js wrapper to consume the wire format. Support this as an option for legacy APIs. + if (getRequestedApiVersion() >= 13.2) + { + ReportingApiQueryResponse fancyResponse = new ReportingApiQueryResponse(view, isEditable, false, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); + fancyResponse.arrayMultiValueColumns(arrayMultiValueColumns); + fancyResponse.includeFormattedValue(includeFormattedValue); + response = fancyResponse; + } + else if (getRequestedApiVersion() >= 9.1) + { + response = new ExtendedApiQueryResponse(view, isEditable, + false, schemaName, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), form.isIncludeMetadata()); + } + else + { + response = new ApiQueryResponse(view, isEditable, + false, schemaName, form.isSaveInSession() ? settings.getQueryName() : "sql", offset, null, + metaDataOnly, form.isIncludeDetailsColumn(), form.isIncludeUpdateColumn(), + form.isIncludeDisplayValues()); + } + response.includeStyle(form.isIncludeStyle()); + + return response; + } + } + + public static class ContainerFilterQueryForm extends QueryForm + { + private String _containerFilter; + + public String getContainerFilter() + { + return _containerFilter; + } + + public void setContainerFilter(String containerFilter) + { + _containerFilter = containerFilter; + } + + @Override + protected QuerySettings createQuerySettings(UserSchema schema) + { + var result = super.createQuerySettings(schema); + if (getContainerFilter() != null) + { + // If the user specified an incorrect filter, throw an IllegalArgumentException + try + { + ContainerFilter.Type containerFilterType = ContainerFilter.Type.valueOf(getContainerFilter()); + result.setContainerFilterName(containerFilterType.name()); + } + catch (IllegalArgumentException e) + { + // Remove bogus value from error message, Issue 45567 + throw new IllegalArgumentException("'containerFilter' parameter is not valid"); + } + } + return result; + } + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public class SelectDistinctAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(ContainerFilterQueryForm form, BindException errors) throws Exception + { + TableInfo table = form.getQueryView().getTable(); + if (null == table) + throw new NotFoundException(); + SqlSelector sqlSelector = getDistinctSql(table, form, errors); + + if (errors.hasErrors() || null == sqlSelector) + return null; + + ApiResponseWriter writer = new ApiJsonWriter(getViewContext().getResponse()); + + try (ResultSet rs = sqlSelector.getResultSet()) + { + writer.startResponse(); + writer.writeProperty("schemaName", form.getSchemaName()); + writer.writeProperty("queryName", form.getQueryName()); + writer.startList("values"); + + while (rs.next()) + { + writer.writeListEntry(rs.getObject(1)); + } + } + catch (SQLException x) + { + throw new RuntimeSQLException(x); + } + catch (DataAccessException x) // Spring error translator can return various subclasses of this + { + throw new RuntimeException(x); + } + writer.endList(); + writer.endResponse(); + + return null; + } + + @Nullable + private SqlSelector getDistinctSql(TableInfo table, ContainerFilterQueryForm form, BindException errors) + { + QuerySettings settings = form.getQuerySettings(); + QueryService service = QueryService.get(); + + if (null == getViewContext().getRequest().getParameter(QueryParam.maxRows.toString())) + { + settings.setMaxRows(DEFAULT_API_MAX_ROWS); + } + else + { + try + { + int maxRows = Integer.parseInt(getViewContext().getRequest().getParameter(QueryParam.maxRows.toString())); + settings.setMaxRows(maxRows); + } + catch (NumberFormatException e) + { + // Standard exception message, Issue 45567 + QuerySettings.throwParameterParseException(QueryParam.maxRows); + } + } + + List fieldKeys = settings.getFieldKeys(); + if (null == fieldKeys || fieldKeys.size() != 1) + { + errors.reject(ERROR_MSG, "Select Distinct requires that only one column be requested."); + return null; + } + Map columns = service.getColumns(table, fieldKeys); + if (columns.size() != 1) + { + errors.reject(ERROR_MSG, "Select Distinct requires that only one column be requested."); + return null; + } + + ColumnInfo col = columns.get(settings.getFieldKeys().get(0)); + if (col == null) + { + errors.reject(ERROR_MSG, "\"" + settings.getFieldKeys().get(0).getName() + "\" is not a valid column."); + return null; + } + + try + { + SimpleFilter filter = getFilterFromQueryForm(form); + + // Strip out filters on columns that don't exist - issue 21669 + service.ensureRequiredColumns(table, columns.values(), filter, null, new HashSet<>()); + QueryLogging queryLogging = new QueryLogging(); + QueryService.SelectBuilder builder = service.getSelectBuilder(table) + .columns(columns.values()) + .filter(filter) + .queryLogging(queryLogging) + .distinct(true); + SQLFragment selectSql = builder.buildSqlFragment(); + + // TODO: queryLogging.isShouldAudit() is always false at this point. + // The only place that seems to set this is ComplianceQueryLoggingProfileListener.queryInvoked() + if (queryLogging.isShouldAudit() && null != queryLogging.getExceptionToThrowIfLoggingIsEnabled()) + { + // this is probably a more helpful message + errors.reject(ERROR_MSG, "Cannot choose values from a column that requires logging."); + return null; + } + + // Regenerate the column since the alias may have changed after call to getSelectSQL() + columns = service.getColumns(table, settings.getFieldKeys()); + var colGetAgain = columns.get(settings.getFieldKeys().get(0)); + // I don't believe the above comment, so here's an assert + assert(colGetAgain.getAlias().equals(col.getAlias())); + + SQLFragment sql = new SQLFragment("SELECT ").appendIdentifier(col.getAlias()).append(" AS value FROM ("); + sql.append(selectSql); + sql.append(") S ORDER BY value"); + + sql = table.getSqlDialect().limitRows(sql, settings.getMaxRows()); + + // 18875: Support Parameterized queries in Select Distinct + Map _namedParameters = settings.getQueryParameters(); + + service.bindNamedParameters(sql, _namedParameters); + service.validateNamedParameters(sql); + + return new SqlSelector(table.getSchema().getScope(), sql, queryLogging); + } + catch (ConversionException | QueryService.NamedParameterNotProvided e) + { + errors.reject(ERROR_MSG, e.getMessage()); + return null; + } + } + } + + private SimpleFilter getFilterFromQueryForm(QueryForm form) + { + QuerySettings settings = form.getQuerySettings(); + SimpleFilter filter = null; + + // 21032: Respect 'ignoreFilter' + if (settings != null && !settings.getIgnoreUserFilter()) + { + // Attach any URL-based filters. This would apply to 'filterArray' from the JavaScript API. + filter = new SimpleFilter(settings.getBaseFilter()); + + String dataRegionName = form.getDataRegionName(); + if (StringUtils.trimToNull(dataRegionName) == null) + dataRegionName = QueryView.DATAREGIONNAME_DEFAULT; + + // Support for 'viewName' + CustomView view = settings.getCustomView(getViewContext(), form.getQueryDef()); + if (null != view && view.hasFilterOrSort() && !settings.getIgnoreViewFilter()) + { + ActionURL url = new ActionURL(SelectDistinctAction.class, getContainer()); + view.applyFilterAndSortToURL(url, dataRegionName); + filter.addAllClauses(new SimpleFilter(url, dataRegionName)); + } + + filter.addUrlFilters(settings.getSortFilterURL(), dataRegionName, Collections.emptyList(), getUser(), getContainer()); + } + + return filter; + } + + @RequiresPermission(ReadPermission.class) + public class GetColumnSummaryStatsAction extends ReadOnlyApiAction + { + private FieldKey _colFieldKey; + + @Override + public void validateForm(QueryForm form, Errors errors) + { + QuerySettings settings = form.getQuerySettings(); + List fieldKeys = settings != null ? settings.getFieldKeys() : null; + if (null == fieldKeys || fieldKeys.size() != 1) + errors.reject(ERROR_MSG, "GetColumnSummaryStats requires that only one column be requested."); + else + _colFieldKey = fieldKeys.get(0); + } + + @Override + public ApiResponse execute(QueryForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + QueryView view = form.getQueryView(); + DisplayColumn displayColumn = null; + + for (DisplayColumn dc : view.getDisplayColumns()) + { + if (dc.getColumnInfo() != null && _colFieldKey.equals(dc.getColumnInfo().getFieldKey())) + { + displayColumn = dc; + break; + } + } + + if (displayColumn != null && displayColumn.getColumnInfo() != null) + { + // get the map of the analytics providers to their relevant aggregates and add the information to the response + Map> analyticsProviders = new LinkedHashMap<>(); + Set colAggregates = new HashSet<>(); + for (ColumnAnalyticsProvider analyticsProvider : displayColumn.getAnalyticsProviders()) + { + if (analyticsProvider instanceof BaseAggregatesAnalyticsProvider baseAggProvider) + { + Map props = new HashMap<>(); + props.put("label", baseAggProvider.getLabel()); + + List aggregateNames = new ArrayList<>(); + for (Aggregate aggregate : AnalyticsProviderItem.createAggregates(baseAggProvider, _colFieldKey, null)) + { + aggregateNames.add(aggregate.getType().getName()); + colAggregates.add(aggregate); + } + props.put("aggregates", aggregateNames); + + analyticsProviders.put(baseAggProvider.getName(), props); + } + } + + // get the filter set from the queryform and verify that they resolve + SimpleFilter filter = getFilterFromQueryForm(form); + if (filter != null) + { + Map resolvedCols = QueryService.get().getColumns(view.getTable(), filter.getAllFieldKeys()); + for (FieldKey filterFieldKey : filter.getAllFieldKeys()) + { + if (!resolvedCols.containsKey(filterFieldKey)) + filter.deleteConditions(filterFieldKey); + } + } + + // query the table/view for the aggregate results + Collection columns = Collections.singleton(displayColumn.getColumnInfo()); + TableSelector selector = new TableSelector(view.getTable(), columns, filter, null).setNamedParameters(form.getQuerySettings().getQueryParameters()); + Map> aggResults = selector.getAggregates(new ArrayList<>(colAggregates)); + + // create a response object mapping the analytics providers to their relevant aggregate results + Map> aggregateResults = new HashMap<>(); + if (aggResults.containsKey(_colFieldKey.toString())) + { + for (Aggregate.Result r : aggResults.get(_colFieldKey.toString())) + { + Map props = new HashMap<>(); + Aggregate.Type type = r.getAggregate().getType(); + props.put("label", type.getFullLabel()); + props.put("description", type.getDescription()); + props.put("value", r.getFormattedValue(displayColumn, getContainer()).value()); + aggregateResults.put(type.getName(), props); + } + + response.put("success", true); + response.put("analyticsProviders", analyticsProviders); + response.put("aggregateResults", aggregateResults); + } + else + { + response.put("success", false); + response.put("message", "Unable to get aggregate results for " + _colFieldKey); + } + } + else + { + response.put("success", false); + response.put("message", "Unable to find ColumnInfo for " + _colFieldKey); + } + + return response; + } + } + + @RequiresPermission(ReadPermission.class) + public class ImportAction extends AbstractQueryImportAction + { + private QueryForm _form; + + @Override + protected void initRequest(QueryForm form) throws ServletException + { + _form = form; + + _insertOption = form.getInsertOption(); + QueryDefinition query = form.getQueryDef(); + List qpe = new ArrayList<>(); + TableInfo t = query.getTable(form.getSchema(), qpe, true); + if (!qpe.isEmpty()) + throw qpe.get(0); + if (null != t) + setTarget(t); + _auditBehaviorType = form.getAuditBehavior(); + _auditUserComment = form.getAuditUserComment(); + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + initRequest(form); + return super.getDefaultImportView(form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + new SchemaAction(_form).addNavTrail(root); + var executeQuery = _form.urlFor(QueryAction.executeQuery); + if (null == executeQuery) + root.addChild(_form.getQueryName()); + else + root.addChild(_form.getQueryName(), executeQuery); + root.addChild("Import Data"); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportSqlForm + { + private String _sql; + private String _schemaName; + private String _containerFilter; + private String _format = "excel"; + + public String getSql() + { + return _sql; + } + + public void setSql(String sql) + { + _sql = PageFlowUtil.wafDecode(sql); + } + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public String getContainerFilter() + { + return _containerFilter; + } + + public void setContainerFilter(String containerFilter) + { + _containerFilter = containerFilter; + } + + public String getFormat() + { + return _format; + } + + public void setFormat(String format) + { + _format = format; + } + } + + @RequiresPermission(ReadPermission.class) + @ApiVersion(9.2) + @Action(ActionType.Export.class) + public static class ExportSqlAction extends ExportAction + { + @Override + public void export(ExportSqlForm form, HttpServletResponse response, BindException errors) throws IOException, ExportException + { + String schemaName = StringUtils.trimToNull(form.getSchemaName()); + if (null == schemaName) + throw new NotFoundException("No value was supplied for the required parameter 'schemaName'"); + String sql = StringUtils.trimToNull(form.getSql()); + if (null == sql) + throw new NotFoundException("No value was supplied for the required parameter 'sql'"); + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaName); + + if (null == schema) + throw new NotFoundException("Schema '" + schemaName + "' not found in this folder"); + + //create a temp query settings object initialized with the posted LabKey SQL + //this will provide a temporary QueryDefinition to Query + TempQuerySettings settings = new TempQuerySettings(getViewContext(), sql); + + //need to explicitly turn off various UI options that will try to refer to the + //current URL and query string + settings.setAllowChooseView(false); + settings.setAllowCustomizeView(false); + + //return all rows + settings.setShowRows(ShowRows.ALL); + + //add container filter if supplied + if (form.getContainerFilter() != null && !form.getContainerFilter().isEmpty()) + { + ContainerFilter.Type containerFilterType = + ContainerFilter.Type.valueOf(form.getContainerFilter()); + settings.setContainerFilterName(containerFilterType.name()); + } + + //build a query view using the schema and settings + QueryView view = new QueryView(schema, settings, errors); + view.setShowRecordSelectors(false); + view.setShowExportButtons(false); + view.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + + //export it + ResponseHelper.setPrivate(response); + response.setHeader("X-Robots-Tag", "noindex"); + + if ("excel".equalsIgnoreCase(form.getFormat())) + view.exportToExcel(response); + else if ("tsv".equalsIgnoreCase(form.getFormat())) + view.exportToTsv(response); + else + errors.reject(null, "Invalid format specified; must be 'excel' or 'tsv'"); + + for (QueryException qe : view.getParseErrors()) + errors.reject(null, qe.getMessage()); + + if (errors.hasErrors()) + throw new ExportException(new SimpleErrorView(errors, false)); + } + } + + public static class ApiSaveRowsForm extends SimpleApiJsonForm + { + } + + private enum CommandType + { + insert(InsertPermission.class, QueryService.AuditAction.INSERT) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException + { + BatchValidationException errors = new BatchValidationException(); + List> insertedRows = qus.insertRows(user, container, rows, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + // Issue 42519: Submitter role not able to insert + // as per the definition of submitter, should allow insert without read + if (qus.hasPermission(user, ReadPermission.class) && shouldReselect(configParameters)) + { + return qus.getRows(user, container, insertedRows); + } + else + { + return insertedRows; + } + } + }, + insertWithKeys(InsertPermission.class, QueryService.AuditAction.INSERT) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException + { + List> newRows = new ArrayList<>(); + List> oldKeys = new ArrayList<>(); + for (Map row : rows) + { + //issue 13719: use CaseInsensitiveHashMaps. Also allow either values or oldKeys to be null + CaseInsensitiveHashMap newMap = row.get(SaveRowsAction.PROP_VALUES) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_VALUES)).toMap()) : new CaseInsensitiveHashMap<>(); + newRows.add(newMap); + + CaseInsensitiveHashMap oldMap = row.get(SaveRowsAction.PROP_OLD_KEYS) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_OLD_KEYS)).toMap()) : new CaseInsensitiveHashMap<>(); + oldKeys.add(oldMap); + } + BatchValidationException errors = new BatchValidationException(); + List> updatedRows = qus.insertRows(user, container, newRows, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + // Issue 42519: Submitter role not able to insert + // as per the definition of submitter, should allow insert without read + if (qus.hasPermission(user, ReadPermission.class) && shouldReselect(configParameters)) + { + updatedRows = qus.getRows(user, container, updatedRows); + } + List> results = new ArrayList<>(); + for (int i = 0; i < updatedRows.size(); i++) + { + Map result = new HashMap<>(); + result.put(SaveRowsAction.PROP_VALUES, updatedRows.get(i)); + result.put(SaveRowsAction.PROP_OLD_KEYS, oldKeys.get(i)); + results.add(result); + } + return results; + } + }, + importRows(InsertPermission.class, QueryService.AuditAction.INSERT) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, BatchValidationException + { + BatchValidationException errors = new BatchValidationException(); + DataIteratorBuilder it = new ListofMapsDataIterator.Builder(rows.get(0).keySet(), rows); + qus.importRows(user, container, it, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + return Collections.emptyList(); + } + }, + moveRows(MoveEntitiesPermission.class, QueryService.AuditAction.UPDATE) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException + { + BatchValidationException errors = new BatchValidationException(); + + Container targetContainer = (Container) configParameters.get(QueryUpdateService.ConfigParameters.TargetContainer); + Map updatedCounts = qus.moveRows(user, container, targetContainer, rows, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + return Collections.singletonList(updatedCounts); + } + }, + update(UpdatePermission.class, QueryService.AuditAction.UPDATE) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException + { + BatchValidationException errors = new BatchValidationException(); + List> updatedRows = qus.updateRows(user, container, rows, null, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + return shouldReselect(configParameters) ? qus.getRows(user, container, updatedRows) : updatedRows; + } + }, + updateChangingKeys(UpdatePermission.class, QueryService.AuditAction.UPDATE) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException + { + List> newRows = new ArrayList<>(); + List> oldKeys = new ArrayList<>(); + for (Map row : rows) + { + // issue 13719: use CaseInsensitiveHashMaps. Also allow either values or oldKeys to be null. + // this should never happen on an update, but we will let it fail later with a better error message instead of the NPE here + CaseInsensitiveHashMap newMap = row.get(SaveRowsAction.PROP_VALUES) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_VALUES)).toMap()) : new CaseInsensitiveHashMap<>(); + newRows.add(newMap); + + CaseInsensitiveHashMap oldMap = row.get(SaveRowsAction.PROP_OLD_KEYS) != null ? new CaseInsensitiveHashMap<>(((JSONObject)row.get(SaveRowsAction.PROP_OLD_KEYS)).toMap()) : new CaseInsensitiveHashMap<>(); + oldKeys.add(oldMap); + } + BatchValidationException errors = new BatchValidationException(); + List> updatedRows = qus.updateRows(user, container, newRows, oldKeys, errors, configParameters, extraContext); + if (errors.hasErrors()) + throw errors; + if (shouldReselect(configParameters)) + updatedRows = qus.getRows(user, container, updatedRows); + List> results = new ArrayList<>(); + for (int i = 0; i < updatedRows.size(); i++) + { + Map result = new HashMap<>(); + result.put(SaveRowsAction.PROP_VALUES, updatedRows.get(i)); + result.put(SaveRowsAction.PROP_OLD_KEYS, oldKeys.get(i)); + results.add(result); + } + return results; + } + }, + delete(DeletePermission.class, QueryService.AuditAction.DELETE) + { + @Override + public List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException + { + return qus.deleteRows(user, container, rows, configParameters, extraContext); + } + }; + + private final Class _permission; + private final QueryService.AuditAction _auditAction; + + CommandType(Class permission, QueryService.AuditAction auditAction) + { + _permission = permission; + _auditAction = auditAction; + } + + public Class getPermission() + { + return _permission; + } + + public QueryService.AuditAction getAuditAction() + { + return _auditAction; + } + + public static boolean shouldReselect(Map configParameters) + { + if (configParameters == null || !configParameters.containsKey(QueryUpdateService.ConfigParameters.SkipReselectRows)) + return true; + + return Boolean.TRUE != configParameters.get(QueryUpdateService.ConfigParameters.SkipReselectRows); + } + + public abstract List> saveRows(QueryUpdateService qus, List> rows, User user, Container container, Map configParameters, Map extraContext) + throws SQLException, InvalidKeyException, QueryUpdateServiceException, BatchValidationException, DuplicateKeyException; + } + + /** + * Base action class for insert/update/delete actions + */ + protected abstract static class BaseSaveRowsAction
extends MutatingApiAction + { + public static final String PROP_SCHEMA_NAME = "schemaName"; + public static final String PROP_QUERY_NAME = "queryName"; + public static final String PROP_CONTAINER_PATH = "containerPath"; + public static final String PROP_TARGET_CONTAINER_PATH = "targetContainerPath"; + public static final String PROP_COMMAND = "command"; + public static final String PROP_ROWS = "rows"; + + private JSONObject _json; + + @Override + public void validateForm(FORM apiSaveRowsForm, Errors errors) + { + _json = apiSaveRowsForm.getJsonObject(); + + // if the POST was done using FormData, the apiSaveRowsForm would not have bound the json data, so + // we'll instead look for that data in the request param directly + if (_json == null && getViewContext().getRequest() != null && getViewContext().getRequest().getParameter("json") != null) + _json = new JSONObject(getViewContext().getRequest().getParameter("json")); + } + + protected JSONObject getJsonObject() + { + return _json; + } + + protected Container getContainerForCommand(JSONObject json) + { + return getContainerForCommand(json, PROP_CONTAINER_PATH, getContainer()); + } + + protected Container getContainerForCommand(JSONObject json, String containerPathProp, @Nullable Container defaultContainer) + { + Container container; + String containerPath = StringUtils.trimToNull(json.optString(containerPathProp)); + if (containerPath == null) + { + if (defaultContainer != null) + container = defaultContainer; + else + throw new IllegalArgumentException(containerPathProp + " is required but was not provided."); + } + else + { + container = ContainerManager.getForPath(containerPath); + if (container == null) + { + throw new IllegalArgumentException("Unknown container: " + containerPath); + } + } + + // Issue 21850: Verify that the user has at least some sort of basic access to the container. We'll check for more downstream + if (!container.hasPermission(getUser(), ReadPermission.class) && + !container.hasPermission(getUser(), DeletePermission.class) && + !container.hasPermission(getUser(), InsertPermission.class) && + !container.hasPermission(getUser(), UpdatePermission.class)) + { + throw new UnauthorizedException(); + } + + return container; + } + + protected String getTargetContainerProp() + { + JSONObject json = getJsonObject(); + return json.optString(PROP_TARGET_CONTAINER_PATH, null); + } + + protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors) throws Exception + { + return executeJson(json, commandType, allowTransaction, errors, false); + } + + protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors, boolean isNestedTransaction) throws Exception + { + return executeJson(json, commandType, allowTransaction, errors, isNestedTransaction, null); + } + + protected JSONObject executeJson(JSONObject json, CommandType commandType, boolean allowTransaction, Errors errors, boolean isNestedTransaction, @Nullable Integer commandIndex) throws Exception + { + JSONObject response = new JSONObject(); + Container container = getContainerForCommand(json); + User user = getUser(); + + if (json == null) + throw new ValidationException("Empty request"); + + JSONArray rows; + try + { + rows = json.getJSONArray(PROP_ROWS); + if (rows.isEmpty()) + throw new ValidationException("No '" + PROP_ROWS + "' array supplied."); + } + catch (JSONException x) + { + throw new ValidationException("No '" + PROP_ROWS + "' array supplied."); + } + + String schemaName = json.getString(PROP_SCHEMA_NAME); + String queryName = json.getString(PROP_QUERY_NAME); + TableInfo table = getTableInfo(container, user, schemaName, queryName); + + if (!table.hasPermission(user, commandType.getPermission())) + throw new UnauthorizedException(); + + if (commandType != CommandType.insert && table.getPkColumns().isEmpty()) + throw new IllegalArgumentException("The table '" + table.getPublicSchemaName() + "." + + table.getPublicName() + "' cannot be updated because it has no primary key defined!"); + + QueryUpdateService qus = table.getUpdateService(); + if (null == qus) + throw new IllegalArgumentException("The query '" + queryName + "' in the schema '" + schemaName + + "' is not updatable via the HTTP-based APIs."); + + int rowsAffected = 0; + + List> rowsToProcess = new ArrayList<>(); + + // NOTE RowMapFactory is faster, but for update it's important to preserve missing v explicit NULL values + // Do we need to support some sort of UNDEFINED and NULL instance of MvFieldWrapper? + RowMapFactory f = null; + if (commandType == CommandType.insert || commandType == CommandType.insertWithKeys || commandType == CommandType.delete) + f = new RowMapFactory<>(); + CaseInsensitiveHashMap referenceCasing = new CaseInsensitiveHashMap<>(); + + for (int idx = 0; idx < rows.length(); ++idx) + { + JSONObject jsonObj; + try + { + jsonObj = rows.getJSONObject(idx); + } + catch (JSONException x) + { + throw new IllegalArgumentException("rows[" + idx + "] is not an object."); + } + if (null != jsonObj) + { + Map rowMap = null == f ? new CaseInsensitiveHashMap<>(new HashMap<>(), referenceCasing) : f.getRowMap(); + // Use shallow copy since jsonObj.toMap() will translate contained JSONObjects into Maps, which we don't want + boolean conflictingCasing = JsonUtil.fillMapShallow(jsonObj, rowMap); + if (conflictingCasing) + { + // Issue 52616 + LOG.error("Row contained conflicting casing for key names in the incoming row: {}", jsonObj); + } + if (allowRowAttachments()) + addRowAttachments(table, rowMap, idx, commandIndex); + + rowsToProcess.add(rowMap); + rowsAffected++; + } + } + + Map extraContext = json.has("extraContext") ? json.getJSONObject("extraContext").toMap() : new CaseInsensitiveHashMap<>(); + + Map configParameters = new HashMap<>(); + + // Check first if the audit behavior has been defined for the table either in code or through XML. + // If not defined there, check for the audit behavior defined in the action form (json). + AuditBehaviorType behaviorType = table.getEffectiveAuditBehavior(json.optString("auditBehavior", null)); + if (behaviorType != null) + { + configParameters.put(DetailedAuditLogDataIterator.AuditConfigs.AuditBehavior, behaviorType); + String auditComment = json.optString("auditUserComment", null); + if (!StringUtils.isEmpty(auditComment)) + configParameters.put(DetailedAuditLogDataIterator.AuditConfigs.AuditUserComment, auditComment); + } + + boolean skipReselectRows = json.optBoolean("skipReselectRows", false); + if (skipReselectRows) + configParameters.put(QueryUpdateService.ConfigParameters.SkipReselectRows, true); + + if (getTargetContainerProp() != null) + { + Container targetContainer = getContainerForCommand(json, PROP_TARGET_CONTAINER_PATH, null); + configParameters.put(QueryUpdateService.ConfigParameters.TargetContainer, targetContainer); + } + + //set up the response, providing the schema name, query name, and operation + //so that the client can sort out which request this response belongs to + //(clients often submit these async) + response.put(PROP_SCHEMA_NAME, schemaName); + response.put(PROP_QUERY_NAME, queryName); + response.put("command", commandType.name()); + response.put("containerPath", container.getPath()); + + //we will transact operations by default, but the user may + //override this by sending a "transacted" property set to false + // 11741: A transaction may already be active if we're trying to + // insert/update/delete from within a transformation/validation script. + boolean transacted = allowTransaction && json.optBoolean("transacted", true); + TransactionAuditProvider.TransactionAuditEvent auditEvent = null; + try (DbScope.Transaction transaction = transacted ? table.getSchema().getScope().ensureTransaction() : NO_OP_TRANSACTION) + { + if (behaviorType != null && behaviorType != AuditBehaviorType.NONE) + { + DbScope.Transaction auditTransaction = !transacted && isNestedTransaction ? table.getSchema().getScope().getCurrentTransaction() : transaction; + if (auditTransaction == null) + auditTransaction = NO_OP_TRANSACTION; + + if (auditTransaction.getAuditEvent() != null) + auditEvent = auditTransaction.getAuditEvent(); + else + { + auditEvent = AbstractQueryUpdateService.createTransactionAuditEvent(container, commandType.getAuditAction()); + AbstractQueryUpdateService.addTransactionAuditEvent(auditTransaction, getUser(), auditEvent); + } + } + + QueryService.get().setEnvironment(QueryService.Environment.CONTAINER, container); + List> responseRows = + commandType.saveRows(qus, rowsToProcess, getUser(), container, configParameters, extraContext); + if (auditEvent != null) + auditEvent.addComment(commandType.getAuditAction(), responseRows.size()); + + if (commandType == CommandType.moveRows) + { + // moveRows returns a single map of updateCounts + response.put("updateCounts", responseRows.get(0)); + } + else if (commandType != CommandType.importRows) + { + response.put("rows", responseRows.stream() + .map(JsonUtil::toMapPreserveNonFinite) + .map(JsonUtil::toJsonPreserveNulls) + .collect(LabKeyCollectors.toJSONArray())); + } + + // if there is any provenance information, save it here + ProvenanceService svc = ProvenanceService.get(); + if (json.has("provenance")) + { + JSONObject provenanceJSON = json.getJSONObject("provenance"); + ProvenanceRecordingParams params = svc.createRecordingParams(getViewContext(), provenanceJSON, ProvenanceService.ADD_RECORDING); + RecordedAction action = svc.createRecordedAction(getViewContext(), params); + if (action != null && params.getRecordingId() != null) + { + // check for any row level provenance information + if (json.has("rows")) + { + Object rowObject = json.get("rows"); + if (rowObject instanceof JSONArray jsonArray) + { + // we need to match any provenance object inputs to the object outputs from the response rows, this typically would + // be the row lsid but it configurable in the provenance recording params + // + List> provenanceMap = svc.createProvenanceMapFromRows(getViewContext(), params, jsonArray, responseRows); + if (!provenanceMap.isEmpty()) + { + action.getProvenanceMap().addAll(provenanceMap); + } + svc.addRecordingStep(getViewContext().getRequest(), params.getRecordingId(), action); + } + else + { + errors.reject(SpringActionController.ERROR_MSG, "Unable to process provenance information, the rows object was not an array"); + } + } + } + } + transaction.commit(); + } + catch (OptimisticConflictException e) + { + //issue 13967: provide better message for OptimisticConflictException + errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); + } + catch (QueryUpdateServiceException | ConversionException | DuplicateKeyException | DataIntegrityViolationException e) + { + //Issue 14294: improve handling of ConversionException (and DuplicateKeyException (Issue 28037), and DataIntegrity (uniqueness) (Issue 22779) + errors.reject(SpringActionController.ERROR_MSG, e.getMessage() == null ? e.toString() : e.getMessage()); + } + catch (BatchValidationException e) + { + if (isSuccessOnValidationError()) + { + response.put("errors", createResponseWriter().toJSON(e)); + } + else + { + ExceptionUtil.decorateException(e, ExceptionUtil.ExceptionInfo.SkipMothershipLogging, "true", true); + throw e; + } + } + if (auditEvent != null) + { + response.put("transactionAuditId", auditEvent.getRowId()); + response.put("reselectRowCount", auditEvent.hasMultiActions()); + } + + response.put("rowsAffected", rowsAffected); + + return response; + } + + protected boolean allowRowAttachments() + { + return false; + } + + private void addRowAttachments(TableInfo tableInfo, Map rowMap, int rowIndex, @Nullable Integer commandIndex) + { + if (getFileMap() != null) + { + for (Map.Entry fileEntry : getFileMap().entrySet()) + { + // Allow for the fileMap key to include the row index, and optionally command index, for defining + // which row to attach this file to + String fullKey = fileEntry.getKey(); + String fieldKey = fullKey; + // Issue 52827: Cannot attach a file if the field name contains :: + // use lastIndexOf instead of split to get the proper parts + int lastDelimIndex = fullKey.lastIndexOf(ROW_ATTACHMENT_INDEX_DELIM); + if (lastDelimIndex > -1) + { + String fieldKeyExcludeIndex = fullKey.substring(0, lastDelimIndex); + String fieldRowIndex = fullKey.substring(lastDelimIndex + ROW_ATTACHMENT_INDEX_DELIM.length()); + if (!fieldRowIndex.equals(rowIndex+"")) continue; + + if (commandIndex == null) + { + // Single command, so we're parsing file names in the format of: FileField::0 + fieldKey = fieldKeyExcludeIndex; + } + else + { + // Multi-command, so we're parsing file names in the format of: FileField::0::1 + int subDelimIndex = fieldKeyExcludeIndex.lastIndexOf(ROW_ATTACHMENT_INDEX_DELIM); + if (subDelimIndex > -1) + { + fieldKey = fieldKeyExcludeIndex.substring(0, subDelimIndex); + String fieldCommandIndex = fieldKeyExcludeIndex.substring(subDelimIndex + ROW_ATTACHMENT_INDEX_DELIM.length()); + if (!fieldCommandIndex.equals(commandIndex+"")) + continue; + } + else + continue; + } + } + + SpringAttachmentFile file = new SpringAttachmentFile(fileEntry.getValue()); + rowMap.put(fieldKey, file.isEmpty() ? null : file); + } + } + + for (ColumnInfo col : tableInfo.getColumns()) + DataIteratorUtil.MatchType.multiPartFormData.updateRowMap(col, rowMap); + } + + protected boolean isSuccessOnValidationError() + { + return getRequestedApiVersion() >= 13.2; + } + + @NotNull + protected TableInfo getTableInfo(Container container, User user, String schemaName, String queryName) + { + if (null == schemaName || null == queryName) + throw new IllegalArgumentException("You must supply a schemaName and queryName!"); + + UserSchema schema = QueryService.get().getUserSchema(user, container, schemaName); + if (null == schema) + throw new IllegalArgumentException("The schema '" + schemaName + "' does not exist."); + + TableInfo table = schema.getTableForInsert(queryName); + if (table == null) + throw new IllegalArgumentException("The query '" + queryName + "' in the schema '" + schemaName + "' does not exist."); + return table; + } + } + + // Issue: 20522 - require read access to the action but executeJson will check for update privileges from the table + // + @RequiresPermission(ReadPermission.class) //will check below + @ApiVersion(8.3) + public static class UpdateRowsAction extends BaseSaveRowsAction + { + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + JSONObject response = executeJson(getJsonObject(), CommandType.update, true, errors); + if (response == null || errors.hasErrors()) + return null; + return new ApiSimpleResponse(response); + } + + @Override + protected boolean allowRowAttachments() + { + return true; + } + } + + @RequiresAnyOf({ReadPermission.class, InsertPermission.class}) //will check below + @ApiVersion(8.3) + public static class InsertRowsAction extends BaseSaveRowsAction + { + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + JSONObject response = executeJson(getJsonObject(), CommandType.insert, true, errors); + if (response == null || errors.hasErrors()) + return null; + + return new ApiSimpleResponse(response); + } + + @Override + protected boolean allowRowAttachments() + { + return true; + } + } + + @RequiresPermission(ReadPermission.class) //will check below + @ApiVersion(8.3) + public static class ImportRowsAction extends BaseSaveRowsAction + { + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + JSONObject response = executeJson(getJsonObject(), CommandType.importRows, true, errors); + if (response == null || errors.hasErrors()) + return null; + return new ApiSimpleResponse(response); + } + } + + @ActionNames("deleteRows, delRows") + @RequiresPermission(ReadPermission.class) //will check below + @ApiVersion(8.3) + public static class DeleteRowsAction extends BaseSaveRowsAction + { + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + JSONObject response = executeJson(getJsonObject(), CommandType.delete, true, errors); + if (response == null || errors.hasErrors()) + return null; + return new ApiSimpleResponse(response); + } + } + + @RequiresPermission(ReadPermission.class) //will check below + public static class MoveRowsAction extends BaseSaveRowsAction + { + private Container _targetContainer; + + @Override + public void validateForm(MoveRowsForm form, Errors errors) + { + super.validateForm(form, errors); + + JSONObject json = getJsonObject(); + if (json == null) + { + errors.reject(ERROR_GENERIC, "Empty request"); + } + else + { + // Since we are moving between containers, we know we have product folders enabled + if (getContainer().getProject().getAuditCommentsRequired() && StringUtils.isBlank(json.optString("auditUserComment"))) + errors.reject(ERROR_GENERIC, "A reason for the move of data is required."); + else + { + String queryName = json.optString(PROP_QUERY_NAME, null); + _targetContainer = ContainerManager.getMoveTargetContainer(queryName, getContainer(), getUser(), getTargetContainerProp(), errors); + } + } + } + + @Override + public ApiResponse execute(MoveRowsForm form, BindException errors) throws Exception + { + // if JSON does not have rows array, see if they were provided via selectionKey + if (!getJsonObject().has(PROP_ROWS)) + setRowsFromSelectionKey(form); + + JSONObject response = executeJson(getJsonObject(), CommandType.moveRows, true, errors); + if (response == null || errors.hasErrors()) + return null; + + updateSelections(form); + + response.put("success", true); + response.put("containerPath", _targetContainer.getPath()); + return new ApiSimpleResponse(response); + } + + private void updateSelections(MoveRowsForm form) + { + String selectionKey = form.getDataRegionSelectionKey(); + if (selectionKey != null) + { + Set rowIds = form.getIds(getViewContext(), false) + .stream().map(Object::toString).collect(Collectors.toSet()); + DataRegionSelection.setSelected(getViewContext(), selectionKey, rowIds, false); + + // if moving entities from a type, the selections from other selectionKeys in that container will + // possibly be holding onto invalid keys after the move, so clear them based on the containerPath and selectionKey suffix + String[] keyParts = selectionKey.split("|"); + if (keyParts.length > 1) + DataRegionSelection.clearRelatedByContainerPath(getViewContext(), keyParts[keyParts.length - 1]); + } + } + + private void setRowsFromSelectionKey(MoveRowsForm form) + { + Set rowIds = form.getIds(getViewContext(), false); // handle clear of selectionKey after move complete + + // convert rowIds to a JSONArray of JSONObjects with a single property "RowId" + JSONArray rows = new JSONArray(); + for (Long rowId : rowIds) + { + JSONObject row = new JSONObject(); + row.put("RowId", rowId); + rows.put(row); + } + getJsonObject().put(PROP_ROWS, rows); + } + } + + public static class MoveRowsForm extends ApiSaveRowsForm + { + private String _dataRegionSelectionKey; + private boolean _useSnapshotSelection; + + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + public void setDataRegionSelectionKey(String dataRegionSelectionKey) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + } + + public boolean isUseSnapshotSelection() + { + return _useSnapshotSelection; + } + + public void setUseSnapshotSelection(boolean useSnapshotSelection) + { + _useSnapshotSelection = useSnapshotSelection; + } + + @Override + public void bindJson(JSONObject json) + { + super.bindJson(json); + _dataRegionSelectionKey = json.optString("dataRegionSelectionKey", null); + _useSnapshotSelection = json.optBoolean("useSnapshotSelection", false); + } + + public Set getIds(ViewContext context, boolean clear) + { + if (_useSnapshotSelection) + return new HashSet<>(DataRegionSelection.getSnapshotSelectedIntegers(context, getDataRegionSelectionKey())); + else + return DataRegionSelection.getSelectedIntegers(context, getDataRegionSelectionKey(), clear); + } + } + + @RequiresNoPermission //will check below + public static class SaveRowsAction extends BaseSaveRowsAction + { + public static final String PROP_VALUES = "values"; + public static final String PROP_OLD_KEYS = "oldKeys"; + + @Override + protected boolean isFailure(BindException errors) + { + return !isSuccessOnValidationError() && super.isFailure(errors); + } + + @Override + protected boolean allowRowAttachments() + { + return true; + } + + @Override + public ApiResponse execute(ApiSaveRowsForm apiSaveRowsForm, BindException errors) throws Exception + { + // Issue 21850: Verify that the user has at least some sort of basic access to the container. We'll check for more + // specific permissions later once we've figured out exactly what they're trying to do. This helps us + // give a better HTTP response code when they're trying to access a resource that's not available to guests + if (!getContainer().hasPermission(getUser(), ReadPermission.class) && + !getContainer().hasPermission(getUser(), DeletePermission.class) && + !getContainer().hasPermission(getUser(), InsertPermission.class) && + !getContainer().hasPermission(getUser(), UpdatePermission.class)) + { + throw new UnauthorizedException(); + } + + JSONObject json = getJsonObject(); + if (json == null) + throw new IllegalArgumentException("Empty request"); + + JSONArray commands = json.optJSONArray("commands"); + if (commands == null || commands.isEmpty()) + { + throw new NotFoundException("Empty request"); + } + + boolean validateOnly = json.optBoolean("validateOnly", false); + // If we are going to validate and not commit, we need to be sure we're transacted as well. Otherwise, + // respect the client's request. + boolean transacted = validateOnly || json.optBoolean("transacted", true); + + // Keep track of whether we end up committing or not + boolean committed = false; + + DbScope scope = null; + if (transacted) + { + for (int i = 0; i < commands.length(); i++) + { + JSONObject commandJSON = commands.getJSONObject(i); + String schemaName = commandJSON.getString(PROP_SCHEMA_NAME); + String queryName = commandJSON.getString(PROP_QUERY_NAME); + Container container = getContainerForCommand(commandJSON); + TableInfo tableInfo = getTableInfo(container, getUser(), schemaName, queryName); + if (scope == null) + { + scope = tableInfo.getSchema().getScope(); + } + else if (scope != tableInfo.getSchema().getScope()) + { + throw new IllegalArgumentException("All queries must be from the same source database"); + } + } + assert scope != null; + } + + JSONArray resultArray = new JSONArray(); + JSONObject extraContext = json.optJSONObject("extraContext"); + + int startingErrorIndex = 0; + int errorCount = 0; + // 11741: A transaction may already be active if we're trying to + // insert/update/delete from within a transformation/validation script. + + try (DbScope.Transaction transaction = transacted ? scope.ensureTransaction() : NO_OP_TRANSACTION) + { + for (int i = 0; i < commands.length(); i++) + { + JSONObject commandObject = commands.getJSONObject(i); + String commandName = commandObject.getString(PROP_COMMAND); + if (commandName == null) + { + throw new ApiUsageException(PROP_COMMAND + " is required but was missing"); + } + CommandType command = CommandType.valueOf(commandName); + + // Copy the top-level 'extraContext' and merge in the command-level extraContext. + Map commandExtraContext = new HashMap<>(); + if (extraContext != null) + commandExtraContext.putAll(extraContext.toMap()); + if (commandObject.has("extraContext")) + { + commandExtraContext.putAll(commandObject.getJSONObject("extraContext").toMap()); + } + commandObject.put("extraContext", commandExtraContext); + + JSONObject commandResponse = executeJson(commandObject, command, !transacted, errors, transacted, i); + // Bail out immediately if we're going to return a failure-type response message + if (commandResponse == null || (errors.hasErrors() && !isSuccessOnValidationError())) + return null; + + //this would be populated in executeJson when a BatchValidationException is thrown + if (commandResponse.has("errors")) + { + errorCount += commandResponse.getJSONObject("errors").getInt("errorCount"); + } + + // If we encountered errors with this particular command and the client requested that don't treat + // the whole request as a failure (non-200 HTTP status code), stash the errors for this particular + // command in its response section. + // NOTE: executeJson should handle and serialize BatchValidationException + // these errors upstream + if (errors.getErrorCount() > startingErrorIndex && isSuccessOnValidationError()) + { + commandResponse.put("errors", ApiResponseWriter.convertToJSON(errors, startingErrorIndex).getValue()); + startingErrorIndex = errors.getErrorCount(); + } + + resultArray.put(commandResponse); + } + + // Don't commit if we had errors or if the client requested that we only validate (and not commit) + if (!errors.hasErrors() && !validateOnly && errorCount == 0) + { + transaction.commit(); + committed = true; + } + } + + errorCount += errors.getErrorCount(); + JSONObject result = new JSONObject(); + result.put("result", resultArray); + result.put("committed", committed); + result.put("errorCount", errorCount); + + return new ApiSimpleResponse(result); + } + } + + @RequiresPermission(ReadPermission.class) + public static class ApiTestAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/query/view/apitest.jsp"); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("API Test"); + } + } + + + @RequiresPermission(AdminPermission.class) + public static class AdminAction extends SimpleViewAction + { + @SuppressWarnings("UnusedDeclaration") + public AdminAction() + { + } + + public AdminAction(ViewContext ctx) + { + setViewContext(ctx); + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + setHelpTopic("externalSchemas"); + return new JspView<>("/org/labkey/query/view/admin.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Schema Administration", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ResetRemoteConnectionsForm + { + private boolean _reset; + + public boolean isReset() + { + return _reset; + } + + public void setReset(boolean reset) + { + _reset = reset; + } + } + + + @RequiresPermission(AdminPermission.class) + public static class ManageRemoteConnectionsAction extends FormViewAction + { + @Override + public void validateCommand(ResetRemoteConnectionsForm target, Errors errors) {} + + @Override + public boolean handlePost(ResetRemoteConnectionsForm form, BindException errors) + { + if (form.isReset()) + { + PropertyManager.getEncryptedStore().deletePropertySet(getContainer(), RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY); + } + return true; + } + + @Override + public URLHelper getSuccessURL(ResetRemoteConnectionsForm queryForm) + { + return new ActionURL(ManageRemoteConnectionsAction.class, getContainer()); + } + + @Override + public ModelAndView getView(ResetRemoteConnectionsForm queryForm, boolean reshow, BindException errors) + { + Map connectionMap; + try + { + // if the encrypted property store is configured but no values have yet been set, and empty map is returned + connectionMap = PropertyManager.getEncryptedStore().getProperties(getContainer(), RemoteConnections.REMOTE_QUERY_CONNECTIONS_CATEGORY); + } + catch (Exception e) + { + connectionMap = null; // render the failure page + } + setHelpTopic("remoteConnection"); + return new JspView<>("/org/labkey/query/view/manageRemoteConnections.jsp", connectionMap, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Manage Remote Connections", new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer())); + } + } + + private abstract static class BaseInsertExternalSchemaAction, T extends AbstractExternalSchemaDef> extends FormViewAction + { + protected BaseInsertExternalSchemaAction(Class commandClass) + { + super(commandClass); + } + + @Override + public void validateCommand(F form, Errors errors) + { + form.validate(errors); + } + + @Override + public boolean handlePost(F form, BindException errors) throws Exception + { + try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) + { + form.doInsert(); + auditSchemaAdminActivity(form.getBean(), "created", getContainer(), getUser()); + QueryManager.get().updateExternalSchemas(getContainer()); + + t.commit(); + } + catch (RuntimeSQLException e) + { + if (e.isConstraintException()) + { + errors.reject(ERROR_MSG, "A schema by that name is already defined in this folder"); + return false; + } + + throw e; + } + + return true; + } + + @Override + public ActionURL getSuccessURL(F form) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new AdminAction(getViewContext()).addNavTrail(root); + root.addChild("Define Schema", new ActionURL(getClass(), getContainer())); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class InsertLinkedSchemaAction extends BaseInsertExternalSchemaAction + { + public InsertLinkedSchemaAction() + { + super(LinkedSchemaForm.class); + } + + @Override + public ModelAndView getView(LinkedSchemaForm form, boolean reshow, BindException errors) + { + setHelpTopic("filterSchema"); + return new JspView<>("/org/labkey/query/view/linkedSchema.jsp", new LinkedSchemaBean(getContainer(), form.getBean(), true), errors); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class InsertExternalSchemaAction extends BaseInsertExternalSchemaAction + { + public InsertExternalSchemaAction() + { + super(ExternalSchemaForm.class); + } + + @Override + public ModelAndView getView(ExternalSchemaForm form, boolean reshow, BindException errors) + { + setHelpTopic("externalSchemas"); + return new JspView<>("/org/labkey/query/view/externalSchema.jsp", new ExternalSchemaBean(getContainer(), form.getBean(), true), errors); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class DeleteSchemaAction extends ConfirmAction + { + @Override + public String getConfirmText() + { + return "Delete"; + } + + @Override + public ModelAndView getConfirmView(SchemaForm form, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Delete Schema"); + + AbstractExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), AbstractExternalSchemaDef.class); + if (def == null) + throw new NotFoundException(); + + String schemaName = isBlank(def.getUserSchemaName()) ? "this schema" : "the schema '" + def.getUserSchemaName() + "'"; + return new HtmlView(HtmlString.of("Are you sure you want to delete " + schemaName + "? The tables and queries defined in this schema will no longer be accessible.")); + } + + @Override + public boolean handlePost(SchemaForm form, BindException errors) + { + AbstractExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), AbstractExternalSchemaDef.class); + if (def == null) + throw new NotFoundException(); + + try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) + { + auditSchemaAdminActivity(def, "deleted", getContainer(), getUser()); + QueryManager.get().delete(def); + t.commit(); + } + return true; + } + + @Override + public void validateCommand(SchemaForm form, Errors errors) + { + } + + @Override + @NotNull + public ActionURL getSuccessURL(SchemaForm form) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); + } + } + + private static void auditSchemaAdminActivity(AbstractExternalSchemaDef def, String action, Container container, User user) + { + String comment = StringUtils.capitalize(def.getSchemaType().toString()) + " schema '" + def.getUserSchemaName() + "' " + action; + AuditTypeEvent event = new AuditTypeEvent(ContainerAuditProvider.CONTAINER_AUDIT_EVENT, container, comment); + AuditLogService.get().addEvent(user, event); + } + + + private abstract static class BaseEditSchemaAction, T extends AbstractExternalSchemaDef> extends FormViewAction + { + protected BaseEditSchemaAction(Class commandClass) + { + super(commandClass); + } + + @Override + public void validateCommand(F form, Errors errors) + { + form.validate(errors); + } + + @Nullable + protected abstract T getCurrent(int externalSchemaId); + + @NotNull + protected T getDef(F form, boolean reshow) + { + T def; + Container defContainer; + + if (reshow) + { + def = form.getBean(); + T current = getCurrent(def.getExternalSchemaId()); + if (current == null) + throw new NotFoundException(); + + defContainer = current.lookupContainer(); + } + else + { + form.refreshFromDb(); + if (!form.isDataLoaded()) + throw new NotFoundException(); + + def = form.getBean(); + if (def == null) + throw new NotFoundException(); + + defContainer = def.lookupContainer(); + } + + if (!getContainer().equals(defContainer)) + throw new UnauthorizedException(); + + return def; + } + + @Override + public boolean handlePost(F form, BindException errors) throws Exception + { + T def = form.getBean(); + T fromDb = getCurrent(def.getExternalSchemaId()); + + // Unauthorized if def in the database reports a different container + if (!getContainer().equals(fromDb.lookupContainer())) + throw new UnauthorizedException(); + + try (DbScope.Transaction t = QueryManager.get().getDbSchema().getScope().ensureTransaction()) + { + form.doUpdate(); + auditSchemaAdminActivity(def, "updated", getContainer(), getUser()); + QueryManager.get().updateExternalSchemas(getContainer()); + t.commit(); + } + catch (RuntimeSQLException e) + { + if (e.isConstraintException()) + { + errors.reject(ERROR_MSG, "A schema by that name is already defined in this folder"); + return false; + } + + throw e; + } + return true; + } + + @Override + public ActionURL getSuccessURL(F externalSchemaForm) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new AdminAction(getViewContext()).addNavTrail(root); + root.addChild("Edit Schema", new ActionURL(getClass(), getContainer())); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class EditLinkedSchemaAction extends BaseEditSchemaAction + { + public EditLinkedSchemaAction() + { + super(LinkedSchemaForm.class); + } + + @Nullable + @Override + protected LinkedSchemaDef getCurrent(int externalId) + { + return QueryManager.get().getLinkedSchemaDef(getContainer(), externalId); + } + + @Override + public ModelAndView getView(LinkedSchemaForm form, boolean reshow, BindException errors) + { + LinkedSchemaDef def = getDef(form, reshow); + + setHelpTopic("filterSchema"); + return new JspView<>("/org/labkey/query/view/linkedSchema.jsp", new LinkedSchemaBean(getContainer(), def, false), errors); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class EditExternalSchemaAction extends BaseEditSchemaAction + { + public EditExternalSchemaAction() + { + super(ExternalSchemaForm.class); + } + + @Nullable + @Override + protected ExternalSchemaDef getCurrent(int externalId) + { + return QueryManager.get().getExternalSchemaDef(getContainer(), externalId); + } + + @Override + public ModelAndView getView(ExternalSchemaForm form, boolean reshow, BindException errors) + { + ExternalSchemaDef def = getDef(form, reshow); + + setHelpTopic("externalSchemas"); + return new JspView<>("/org/labkey/query/view/externalSchema.jsp", new ExternalSchemaBean(getContainer(), def, false), errors); + } + } + + + public static class DataSourceInfo + { + public final String sourceName; + public final String displayName; + public final boolean editable; + + public DataSourceInfo(DbScope scope) + { + this(scope.getDataSourceName(), scope.getDisplayName(), scope.getSqlDialect().isEditable()); + } + + public DataSourceInfo(Container c) + { + this(c.getId(), c.getName(), false); + } + + public DataSourceInfo(String sourceName, String displayName, boolean editable) + { + this.sourceName = sourceName; + this.displayName = displayName; + this.editable = editable; + } + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DataSourceInfo that = (DataSourceInfo) o; + return sourceName != null ? sourceName.equals(that.sourceName) : that.sourceName == null; + } + + @Override + public int hashCode() + { + return sourceName != null ? sourceName.hashCode() : 0; + } + } + + public static abstract class BaseExternalSchemaBean + { + protected final Container _c; + protected final T _def; + protected final boolean _insert; + protected final Map _help = new HashMap<>(); + + public BaseExternalSchemaBean(Container c, T def, boolean insert) + { + _c = c; + _def = def; + _insert = insert; + + TableInfo ti = QueryManager.get().getTableInfoExternalSchema(); + + ti.getColumns() + .stream() + .filter(ci -> null != ci.getDescription()) + .forEach(ci -> _help.put(ci.getName(), ci.getDescription())); + } + + public abstract DataSourceInfo getInitialSource(); + + public T getSchemaDef() + { + return _def; + } + + public boolean isInsert() + { + return _insert; + } + + public ActionURL getReturnURL() + { + return new ActionURL(AdminAction.class, _c); + } + + public ActionURL getDeleteURL() + { + return new QueryUrlsImpl().urlDeleteSchema(_c, _def); + } + + public String getHelpHTML(String fieldName) + { + return _help.get(fieldName); + } + } + + public static class LinkedSchemaBean extends BaseExternalSchemaBean + { + public LinkedSchemaBean(Container c, LinkedSchemaDef def, boolean insert) + { + super(c, def, insert); + } + + @Override + public DataSourceInfo getInitialSource() + { + Container sourceContainer = getInitialContainer(); + return new DataSourceInfo(sourceContainer); + } + + private @NotNull Container getInitialContainer() + { + LinkedSchemaDef def = getSchemaDef(); + Container sourceContainer = def.lookupSourceContainer(); + if (sourceContainer == null) + sourceContainer = def.lookupContainer(); + if (sourceContainer == null) + sourceContainer = _c; + return sourceContainer; + } + } + + public static class ExternalSchemaBean extends BaseExternalSchemaBean + { + protected final Map> _sourcesAndSchemas = new LinkedHashMap<>(); + protected final Map> _sourcesAndSchemasIncludingSystem = new LinkedHashMap<>(); + + public ExternalSchemaBean(Container c, ExternalSchemaDef def, boolean insert) + { + super(c, def, insert); + initSources(); + } + + public Collection getSources() + { + return _sourcesAndSchemas.keySet(); + } + + public Collection getSchemaNames(DataSourceInfo source, boolean includeSystem) + { + if (includeSystem) + return _sourcesAndSchemasIncludingSystem.get(source); + else + return _sourcesAndSchemas.get(source); + } + + @Override + public DataSourceInfo getInitialSource() + { + ExternalSchemaDef def = getSchemaDef(); + DbScope scope = def.lookupDbScope(); + if (scope == null) + scope = DbScope.getLabKeyScope(); + return new DataSourceInfo(scope); + } + + protected void initSources() + { + ModuleLoader moduleLoader = ModuleLoader.getInstance(); + + for (DbScope scope : DbScope.getDbScopes()) + { + SqlDialect dialect = scope.getSqlDialect(); + + Collection schemaNames = new LinkedList<>(); + Collection schemaNamesIncludingSystem = new LinkedList<>(); + + for (String schemaName : scope.getSchemaNames()) + { + schemaNamesIncludingSystem.add(schemaName); + + if (dialect.isSystemSchema(schemaName)) + continue; + + if (null != moduleLoader.getModule(scope, schemaName)) + continue; + + schemaNames.add(schemaName); + } + + DataSourceInfo source = new DataSourceInfo(scope); + _sourcesAndSchemas.put(source, schemaNames); + _sourcesAndSchemasIncludingSystem.put(source, schemaNamesIncludingSystem); + } + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class GetTablesForm + { + private String _dataSource; + private String _schemaName; + private boolean _sorted; + + public String getDataSource() + { + return _dataSource; + } + + public void setDataSource(String dataSource) + { + _dataSource = dataSource; + } + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public boolean isSorted() + { + return _sorted; + } + + public void setSorted(boolean sorted) + { + _sorted = sorted; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class GetTablesAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(GetTablesForm form, BindException errors) + { + List> rows = new LinkedList<>(); + List tableNames = new ArrayList<>(); + + if (null != form.getSchemaName()) + { + DbScope scope = DbScope.getDbScope(form.getDataSource()); + if (null != scope) + { + DbSchema schema = scope.getSchema(form.getSchemaName(), DbSchemaType.Bare); + tableNames.addAll(schema.getTableNames()); + } + else + { + Container c = ContainerManager.getForId(form.getDataSource()); + if (null != c) + { + UserSchema schema = QueryService.get().getUserSchema(getUser(), c, form.getSchemaName()); + if (null != schema) + { + if (form.isSorted()) + for (TableInfo table : schema.getSortedTables()) + tableNames.add(table.getName()); + else + tableNames.addAll(schema.getTableAndQueryNames(true)); + } + } + } + } + + Collections.sort(tableNames); + + for (String tableName : tableNames) + { + Map row = new LinkedHashMap<>(); + row.put("table", tableName); + rows.add(row); + } + + Map properties = new HashMap<>(); + properties.put("rows", rows); + + return new ApiSimpleResponse(properties); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class SchemaTemplateForm + { + private String _name; + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + } + + + @RequiresPermission(AdminOperationsPermission.class) + public static class SchemaTemplateAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(SchemaTemplateForm form, BindException errors) + { + String name = form.getName(); + if (name == null) + throw new IllegalArgumentException("name required"); + + Container c = getContainer(); + TemplateSchemaType template = QueryServiceImpl.get().getSchemaTemplate(c, name); + if (template == null) + throw new NotFoundException("template not found"); + + JSONObject templateJson = QueryServiceImpl.get().schemaTemplateJson(name, template); + + return new ApiSimpleResponse("template", templateJson); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class SchemaTemplatesAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(Object form, BindException errors) + { + Container c = getContainer(); + QueryServiceImpl svc = QueryServiceImpl.get(); + Map templates = svc.getSchemaTemplates(c); + + JSONArray ret = new JSONArray(); + for (String key : templates.keySet()) + { + TemplateSchemaType template = templates.get(key); + JSONObject templateJson = svc.schemaTemplateJson(key, template); + ret.put(templateJson); + } + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("templates", ret); + resp.put("success", true); + return resp; + } + } + + @RequiresPermission(AdminPermission.class) + public static class ReloadExternalSchemaAction extends FormHandlerAction + { + private String _userSchemaName; + + @Override + public void validateCommand(SchemaForm form, Errors errors) + { + } + + @Override + public boolean handlePost(SchemaForm form, BindException errors) + { + ExternalSchemaDef def = ExternalSchemaDefCache.getSchemaDef(getContainer(), form.getExternalSchemaId(), ExternalSchemaDef.class); + if (def == null) + throw new NotFoundException(); + + QueryManager.get().reloadExternalSchema(def); + _userSchemaName = def.getUserSchemaName(); + + return true; + } + + @Override + public ActionURL getSuccessURL(SchemaForm form) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "Schema " + _userSchemaName + " was reloaded successfully."); + } + } + + + @RequiresPermission(AdminPermission.class) + public static class ReloadAllUserSchemas extends FormHandlerAction + { + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + QueryManager.get().reloadAllExternalSchemas(getContainer()); + return true; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "All schemas in this folder were reloaded successfully."); + } + } + + @RequiresPermission(AdminPermission.class) + public static class ReloadFailedConnectionsAction extends FormHandlerAction + { + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + DbScope.clearFailedDbScopes(); + return true; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return new QueryUrlsImpl().urlExternalSchemaAdmin(getContainer(), "Reconnection was attempted on all data sources that failed previous connection attempts."); + } + } + + @RequiresPermission(ReadPermission.class) + public static class TableInfoAction extends SimpleViewAction + { + @Override + public ModelAndView getView(TableInfoForm form, BindException errors) throws Exception + { + TablesDocument ret = TablesDocument.Factory.newInstance(); + TablesType tables = ret.addNewTables(); + + FieldKey[] fields = form.getFieldKeys(); + if (fields.length != 0) + { + TableInfo tinfo = QueryView.create(form, errors).getTable(); + Map columnMap = CustomViewImpl.getColumnInfos(tinfo, Arrays.asList(fields)); + TableXML.initTable(tables.addNewTable(), tinfo, null, columnMap.values()); + } + + for (FieldKey tableKey : form.getTableKeys()) + { + TableInfo tableInfo = form.getTableInfo(tableKey); + TableType xbTable = tables.addNewTable(); + TableXML.initTable(xbTable, tableInfo, tableKey); + } + getViewContext().getResponse().setContentType("text/xml"); + getViewContext().getResponse().getWriter().write(ret.toString()); + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + // Issue 18870: Guest user can't revert unsaved custom view changes + // Permission will be checked inline (guests are allowed to delete their session custom views) + @RequiresNoPermission + @Action(ActionType.Configure.class) + public static class DeleteViewAction extends MutatingApiAction + { + @Override + public ApiResponse execute(DeleteViewForm form, BindException errors) + { + CustomView view = form.getCustomView(); + if (view == null) + { + throw new NotFoundException(); + } + + if (getUser().isGuest()) + { + // Guests can only delete session custom views. + if (!view.isSession()) + throw new UnauthorizedException(); + } + else + { + // Logged in users must have read permission + if (!getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException(); + } + + if (view.isShared()) + { + if (!getContainer().hasPermission(getUser(), EditSharedViewPermission.class)) + throw new UnauthorizedException(); + } + + view.delete(getUser(), getViewContext().getRequest()); + + // Delete the first shadowed custom view, if available. + if (form.isComplete()) + { + form.reset(); + CustomView shadowed = form.getCustomView(); + if (shadowed != null && shadowed.isEditable() && !(shadowed instanceof ModuleCustomView)) + { + if (!shadowed.isShared() || getContainer().hasPermission(getUser(), EditSharedViewPermission.class)) + shadowed.delete(getUser(), getViewContext().getRequest()); + } + } + + // Try to get a custom view of the same name as the view we just deleted. + // The deleted view may have been a session view or a personal view masking shared view with the same name. + form.reset(); + view = form.getCustomView(); + String nextViewName = null; + if (view != null) + nextViewName = view.getName(); + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("viewName", nextViewName); + return response; + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class SaveSessionViewForm extends QueryForm + { + private String newName; + private boolean inherit; + private boolean shared; + private boolean hidden; + private boolean replace; + private String containerPath; + + public String getNewName() + { + return newName; + } + + public void setNewName(String newName) + { + this.newName = newName; + } + + public boolean isInherit() + { + return inherit; + } + + public void setInherit(boolean inherit) + { + this.inherit = inherit; + } + + public boolean isShared() + { + return shared; + } + + public void setShared(boolean shared) + { + this.shared = shared; + } + + public String getContainerPath() + { + return containerPath; + } + + public void setContainerPath(String containerPath) + { + this.containerPath = containerPath; + } + + public boolean isHidden() + { + return hidden; + } + + public void setHidden(boolean hidden) + { + this.hidden = hidden; + } + + public boolean isReplace() + { + return replace; + } + + public void setReplace(boolean replace) + { + this.replace = replace; + } + } + + // Moves a session view into the database. + @RequiresPermission(ReadPermission.class) + public static class SaveSessionViewAction extends MutatingApiAction + { + @Override + public ApiResponse execute(SaveSessionViewForm form, BindException errors) + { + CustomView view = form.getCustomView(); + if (view == null) + { + throw new NotFoundException(); + } + if (!view.isSession()) + throw new IllegalArgumentException("This action only supports saving session views."); + + //if (!getContainer().getId().equals(view.getContainer().getId())) + // throw new IllegalArgumentException("View may only be saved from container it was created in."); + + assert !view.canInherit() && !view.isShared() && view.isEditable(): "Session view should never be inheritable or shared and always be editable"; + + // Users may save views to a location other than the current container + String containerPath = form.getContainerPath(); + Container container; + if (form.isInherit() && containerPath != null) + { + // Only respect this request if it's a view that is inheritable in subfolders + container = ContainerManager.getForPath(containerPath); + } + else + { + // Otherwise, save it in the current container + container = getContainer(); + } + + if (container == null) + throw new NotFoundException("No such container: " + containerPath); + + if (form.isShared() || form.isInherit()) + { + if (!container.hasPermission(getUser(), EditSharedViewPermission.class)) + throw new UnauthorizedException(); + } + + DbScope scope = QueryManager.get().getDbSchema().getScope(); + try (DbScope.Transaction tx = scope.ensureTransaction()) + { + // Delete the session view. The view will be restored if an exception is thrown. + view.delete(getUser(), getViewContext().getRequest()); + + // Get any previously existing non-session view. + // The session custom view and the view-to-be-saved may have different names. + // If they do have different names, we may need to delete an existing session view with that name. + // UNDONE: If the view has a different name, we will clobber it without asking. + CustomView existingView = form.getQueryDef().getCustomView(getUser(), null, form.getNewName()); + if (existingView != null && existingView.isSession()) + { + // Delete any session view we are overwriting. + existingView.delete(getUser(), getViewContext().getRequest()); + existingView = form.getQueryDef().getCustomView(getUser(), null, form.getNewName()); + } + + // save a new private view if shared is false but existing view is shared + if (existingView != null && !form.isShared() && existingView.getOwner() == null) + { + existingView = null; + } + + if (existingView != null && !form.isReplace() && !StringUtils.isEmpty(form.getNewName())) + throw new IllegalArgumentException("A saved view by the name \"" + form.getNewName() + "\" already exists. "); + + if (existingView == null || (existingView instanceof ModuleCustomView && existingView.isEditable())) + { + User owner = form.isShared() ? null : getUser(); + + CustomViewImpl viewCopy = new CustomViewImpl(form.getQueryDef(), owner, form.getNewName()); + viewCopy.setColumns(view.getColumns()); + viewCopy.setCanInherit(form.isInherit()); + viewCopy.setFilterAndSort(view.getFilterAndSort()); + viewCopy.setColumnProperties(view.getColumnProperties()); + viewCopy.setIsHidden(form.isHidden()); + if (form.isInherit()) + viewCopy.setContainer(container); + + viewCopy.save(getUser(), getViewContext().getRequest()); + } + else if (!existingView.isEditable()) + { + throw new IllegalArgumentException("Existing view '" + form.getNewName() + "' is not editable. You may save this view with a different name."); + } + else + { + // UNDONE: changing shared property of an existing view is unimplemented. Not sure if it makes sense from a usability point of view. + existingView.setColumns(view.getColumns()); + existingView.setFilterAndSort(view.getFilterAndSort()); + existingView.setColumnProperties(view.getColumnProperties()); + existingView.setCanInherit(form.isInherit()); + if (form.isInherit()) + ((CustomViewImpl)existingView).setContainer(container); + existingView.setIsHidden(form.isHidden()); + + existingView.save(getUser(), getViewContext().getRequest()); + } + + tx.commit(); + return new ApiSimpleResponse("success", true); + } + catch (Exception e) + { + // dirty the view then save the deleted session view back in session state + view.setName(view.getName()); + view.save(getUser(), getViewContext().getRequest()); + + throw e; + } + } + } + + /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ + @RequiresPermission(AdminPermission.class) + public class ManageViewsAction extends SimpleViewAction + { + @SuppressWarnings("UnusedDeclaration") + public ManageViewsAction() + { + } + + public ManageViewsAction(ViewContext ctx) + { + setViewContext(ctx); + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + return new JspView<>("/org/labkey/query/view/manageViews.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + new BeginAction(getViewContext()).addNavTrail(root); + root.addChild("Manage Views", QueryController.this.getViewContext().getActionURL()); + } + } + + + /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ + @RequiresPermission(AdminPermission.class) + public class InternalDeleteView extends ConfirmAction + { + @Override + public ModelAndView getConfirmView(InternalViewForm form, BindException errors) + { + return new JspView<>("/org/labkey/query/view/internalDeleteView.jsp", form, errors); + } + + @Override + public boolean handlePost(InternalViewForm form, BindException errors) + { + CstmView view = form.getViewAndCheckPermission(); + QueryManager.get().delete(view); + return true; + } + + @Override + public void validateCommand(InternalViewForm internalViewForm, Errors errors) + { + } + + @Override + @NotNull + public ActionURL getSuccessURL(InternalViewForm internalViewForm) + { + return new ActionURL(ManageViewsAction.class, getContainer()); + } + } + + /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ + @RequiresPermission(AdminPermission.class) + public class InternalSourceViewAction extends FormViewAction + { + @Override + public void validateCommand(InternalSourceViewForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(InternalSourceViewForm form, boolean reshow, BindException errors) + { + CstmView view = form.getViewAndCheckPermission(); + form.ff_inherit = QueryManager.get().canInherit(view.getFlags()); + form.ff_hidden = QueryManager.get().isHidden(view.getFlags()); + form.ff_columnList = view.getColumns(); + form.ff_filter = view.getFilter(); + return new JspView<>("/org/labkey/query/view/internalSourceView.jsp", form, errors); + } + + @Override + public boolean handlePost(InternalSourceViewForm form, BindException errors) + { + CstmView view = form.getViewAndCheckPermission(); + int flags = view.getFlags(); + flags = QueryManager.get().setCanInherit(flags, form.ff_inherit); + flags = QueryManager.get().setIsHidden(flags, form.ff_hidden); + view.setFlags(flags); + view.setColumns(form.ff_columnList); + view.setFilter(form.ff_filter); + QueryManager.get().update(getUser(), view); + return true; + } + + @Override + public ActionURL getSuccessURL(InternalSourceViewForm form) + { + return new ActionURL(ManageViewsAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + new ManageViewsAction(getViewContext()).addNavTrail(root); + root.addChild("Edit source of Grid View"); + } + } + + /** Minimalist, secret UI to help users recover if they've created a broken view somehow */ + @RequiresPermission(AdminPermission.class) + public class InternalNewViewAction extends FormViewAction + { + int _customViewId = 0; + + @Override + public void validateCommand(InternalNewViewForm form, Errors errors) + { + if (StringUtils.trimToNull(form.ff_schemaName) == null) + { + errors.reject(ERROR_MSG, "Schema name cannot be blank."); + } + if (StringUtils.trimToNull(form.ff_queryName) == null) + { + errors.reject(ERROR_MSG, "Query name cannot be blank"); + } + } + + @Override + public ModelAndView getView(InternalNewViewForm form, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/query/view/internalNewView.jsp", form, errors); + } + + @Override + public boolean handlePost(InternalNewViewForm form, BindException errors) + { + if (form.ff_share) + { + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException(); + } + List existing = QueryManager.get().getCstmViews(getContainer(), form.ff_schemaName, form.ff_queryName, form.ff_viewName, form.ff_share ? null : getUser(), false, false); + CstmView view; + if (!existing.isEmpty()) + { + } + else + { + view = new CstmView(); + view.setSchema(form.ff_schemaName); + view.setQueryName(form.ff_queryName); + view.setName(form.ff_viewName); + view.setContainerId(getContainer().getId()); + if (form.ff_share) + { + view.setCustomViewOwner(null); + } + else + { + view.setCustomViewOwner(getUser().getUserId()); + } + if (form.ff_inherit) + { + view.setFlags(QueryManager.get().setCanInherit(view.getFlags(), form.ff_inherit)); + } + InternalViewForm.checkEdit(getViewContext(), view); + try + { + view = QueryManager.get().insert(getUser(), view); + } + catch (Exception e) + { + LogManager.getLogger(QueryController.class).error("Error", e); + errors.reject(ERROR_MSG, "An exception occurred: " + e); + return false; + } + _customViewId = view.getCustomViewId(); + } + return true; + } + + @Override + public ActionURL getSuccessURL(InternalNewViewForm form) + { + ActionURL forward = new ActionURL(InternalSourceViewAction.class, getContainer()); + forward.addParameter("customViewId", Integer.toString(_customViewId)); + return forward; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Create New Grid View"); + } + } + + + @ActionNames("clearSelected, selectNone") + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public static class SelectNoneAction extends MutatingApiAction + { + @Override + public void validateForm(SelectForm form, Errors errors) + { + if (form.getSchemaName().isEmpty() != (form.getQueryName() == null)) + { + errors.reject(ERROR_MSG, "Both schemaName and queryName are required"); + } + } + + @Override + public ApiResponse execute(final SelectForm form, BindException errors) throws Exception + { + if (form.getQueryName() == null) + { + DataRegionSelection.clearAll(getViewContext(), form.getKey()); + return new DataRegionSelection.SelectionResponse(0); + } + + int count = DataRegionSelection.setSelectedFromForm(form); + return new DataRegionSelection.SelectionResponse(count); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class SelectForm extends QueryForm + { + protected boolean clearSelected; + protected String key; + + public boolean isClearSelected() + { + return clearSelected; + } + + public void setClearSelected(boolean clearSelected) + { + this.clearSelected = clearSelected; + } + + public String getKey() + { + return key; + } + + public void setKey(String key) + { + this.key = key; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectData.class) + public static class SelectAllAction extends MutatingApiAction + { + @Override + public void validateForm(QueryForm form, Errors errors) + { + if (form.getSchemaName().isEmpty() || form.getQueryName() == null) + { + errors.reject(ERROR_MSG, "schemaName and queryName required"); + } + } + + @Override + public ApiResponse execute(final QueryForm form, BindException errors) throws Exception + { + int count = DataRegionSelection.setSelectionForAll(form, true); + return new DataRegionSelection.SelectionResponse(count); + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetSelectedAction extends ReadOnlyApiAction + { + @Override + public void validateForm(SelectForm form, Errors errors) + { + if (form.getSchemaName().isEmpty() != (form.getQueryName() == null)) + { + errors.reject(ERROR_MSG, "Both schemaName and queryName are required"); + } + } + + @Override + public ApiResponse execute(final SelectForm form, BindException errors) throws Exception + { + getViewContext().getResponse().setHeader("Content-Type", CONTENT_TYPE_JSON); + if (form.getQueryName() == null) + { + Set selected = DataRegionSelection.getSelected(getViewContext(), form.getKey(), form.isClearSelected()); + return new ApiSimpleResponse("selected", selected); + } + else + { + Set selected = DataRegionSelection.getSelected(form, form.isClearSelected()); + return new ApiSimpleResponse("selected", selected); + } + } + } + + @ActionNames("setSelected, setCheck") + @RequiresPermission(ReadPermission.class) + public static class SetCheckAction extends MutatingApiAction + { + @Override + public ApiResponse execute(final SetCheckForm form, BindException errors) throws Exception + { + String[] ids = form.getId(getViewContext().getRequest()); + Set selection = new LinkedHashSet<>(); + if (ids != null) + { + for (String id : ids) + { + if (StringUtils.isNotBlank(id)) + selection.add(id); + } + } + + int count; + if (form.getQueryName() != null && form.isValidateIds() && form.isChecked()) + { + selection = DataRegionSelection.getValidatedIds(selection, form); + } + + count = DataRegionSelection.setSelected( + getViewContext(), form.getKey(), + selection, form.isChecked()); + + return new DataRegionSelection.SelectionResponse(count); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class SetCheckForm extends SelectForm + { + protected String[] ids; + protected boolean checked; + protected boolean validateIds; + + public String[] getId(HttpServletRequest request) + { + // 5025 : DataRegion checkbox names may contain comma + // Beehive parses a single parameter value with commas into an array + // which is not what we want. + String[] paramIds = request.getParameterValues("id"); + return paramIds == null ? ids: paramIds; + } + + public void setId(String[] ids) + { + this.ids = ids; + } + + public boolean isChecked() + { + return checked; + } + + public void setChecked(boolean checked) + { + this.checked = checked; + } + + public boolean isValidateIds() + { + return validateIds; + } + + public void setValidateIds(boolean validateIds) + { + this.validateIds = validateIds; + } + } + + @RequiresPermission(ReadPermission.class) + public static class ReplaceSelectedAction extends MutatingApiAction + { + @Override + public ApiResponse execute(final SetCheckForm form, BindException errors) + { + String[] ids = form.getId(getViewContext().getRequest()); + List selection = new ArrayList<>(); + if (ids != null) + { + for (String id : ids) + { + if (StringUtils.isNotBlank(id)) + selection.add(id); + } + } + + + DataRegionSelection.clearAll(getViewContext(), form.getKey()); + int count = DataRegionSelection.setSelected( + getViewContext(), form.getKey(), + selection, true); + return new DataRegionSelection.SelectionResponse(count); + } + } + + @RequiresPermission(ReadPermission.class) + public static class SetSnapshotSelectionAction extends MutatingApiAction + { + @Override + public ApiResponse execute(final SetCheckForm form, BindException errors) + { + String[] ids = form.getId(getViewContext().getRequest()); + List selection = new ArrayList<>(); + if (ids != null) + { + for (String id : ids) + { + if (StringUtils.isNotBlank(id)) + selection.add(id); + } + } + + DataRegionSelection.clearAll(getViewContext(), form.getKey(), true); + int count = DataRegionSelection.setSelected( + getViewContext(), form.getKey(), + selection, true, true); + return new DataRegionSelection.SelectionResponse(count); + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetSnapshotSelectionAction extends ReadOnlyApiAction + { + @Override + public void validateForm(SelectForm form, Errors errors) + { + if (StringUtils.isEmpty(form.getKey())) + { + errors.reject(ERROR_MSG, "Selection key is required"); + } + } + + @Override + public ApiResponse execute(final SelectForm form, BindException errors) throws Exception + { + List selected = DataRegionSelection.getSnapshotSelected(getViewContext(), form.getKey()); + return new ApiSimpleResponse("selected", selected); + } + } + + public static String getMessage(SqlDialect d, SQLException x) + { + return x.getMessage(); + } + + + public static class GetSchemasForm + { + private boolean _includeHidden = true; + private SchemaKey _schemaName; + + public SchemaKey getSchemaName() + { + return _schemaName; + } + + @SuppressWarnings("unused") + public void setSchemaName(SchemaKey schemaName) + { + _schemaName = schemaName; + } + + public boolean isIncludeHidden() + { + return _includeHidden; + } + + @SuppressWarnings("unused") + public void setIncludeHidden(boolean includeHidden) + { + _includeHidden = includeHidden; + } + } + + + @RequiresPermission(ReadPermission.class) + @ApiVersion(12.3) + public static class GetSchemasAction extends ReadOnlyApiAction + { + @Override + protected long getLastModified(GetSchemasForm form) + { + return QueryService.get().metadataLastModified(); + } + + @Override + public ApiResponse execute(GetSchemasForm form, BindException errors) + { + final Container container = getContainer(); + final User user = getUser(); + + final boolean includeHidden = form.isIncludeHidden(); + if (getRequestedApiVersion() >= 9.3) + { + SimpleSchemaTreeVisitor visitor = new SimpleSchemaTreeVisitor<>(includeHidden) + { + @Override + public Void visitUserSchema(UserSchema schema, Path path, JSONObject json) + { + JSONObject schemaProps = new JSONObject(); + + schemaProps.put("schemaName", schema.getName()); + schemaProps.put("fullyQualifiedName", schema.getSchemaName()); + schemaProps.put("description", schema.getDescription()); + schemaProps.put("hidden", schema.isHidden()); + NavTree tree = schema.getSchemaBrowserLinks(user); + if (tree != null && tree.hasChildren()) + schemaProps.put("menu", tree.toJSON()); + + // Collect children schemas + JSONObject children = new JSONObject(); + visit(schema.getSchemas(_includeHidden), path, children); + if (!children.isEmpty()) + schemaProps.put("schemas", children); + + // Add node's schemaProps to the parent's json. + json.put(schema.getName(), schemaProps); + return null; + } + }; + + // By default, start from the root. + QuerySchema schema; + if (form.getSchemaName() != null) + schema = DefaultSchema.get(user, container, form.getSchemaName()); + else + schema = DefaultSchema.get(user, container); + + // Ensure consistent exception as other query actions + QueryForm.ensureSchemaNotNull(schema); + + // Create the JSON response by visiting the schema children. The parent schema information isn't included. + JSONObject ret = new JSONObject(); + visitor.visitTop(schema.getSchemas(includeHidden), ret); + + return new ApiSimpleResponse(ret); + } + else + { + return new ApiSimpleResponse("schemas", DefaultSchema.get(user, container).getUserSchemaPaths(includeHidden)); + } + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class GetQueriesForm + { + private String _schemaName; + private boolean _includeUserQueries = true; + private boolean _includeSystemQueries = true; + private boolean _includeColumns = true; + private boolean _includeViewDataUrl = true; + private boolean _includeTitle = true; + private boolean _queryDetailColumns = false; + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public boolean isIncludeUserQueries() + { + return _includeUserQueries; + } + + public void setIncludeUserQueries(boolean includeUserQueries) + { + _includeUserQueries = includeUserQueries; + } + + public boolean isIncludeSystemQueries() + { + return _includeSystemQueries; + } + + public void setIncludeSystemQueries(boolean includeSystemQueries) + { + _includeSystemQueries = includeSystemQueries; + } + + public boolean isIncludeColumns() + { + return _includeColumns; + } + + public void setIncludeColumns(boolean includeColumns) + { + _includeColumns = includeColumns; + } + + public boolean isQueryDetailColumns() + { + return _queryDetailColumns; + } + + public void setQueryDetailColumns(boolean queryDetailColumns) + { + _queryDetailColumns = queryDetailColumns; + } + + public boolean isIncludeViewDataUrl() + { + return _includeViewDataUrl; + } + + public void setIncludeViewDataUrl(boolean includeViewDataUrl) + { + _includeViewDataUrl = includeViewDataUrl; + } + + public boolean isIncludeTitle() + { + return _includeTitle; + } + + public void setIncludeTitle(boolean includeTitle) + { + _includeTitle = includeTitle; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) + public static class GetQueriesAction extends ReadOnlyApiAction + { + @Override + protected long getLastModified(GetQueriesForm form) + { + return QueryService.get().metadataLastModified(); + } + + @Override + public ApiResponse execute(GetQueriesForm form, BindException errors) + { + if (null == StringUtils.trimToNull(form.getSchemaName())) + throw new IllegalArgumentException("You must supply a value for the 'schemaName' parameter!"); + + ApiSimpleResponse response = new ApiSimpleResponse(); + UserSchema uschema = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()); + if (null == uschema) + throw new NotFoundException("The schema name '" + form.getSchemaName() + + "' was not found within the folder '" + getContainer().getPath() + "'"); + + response.put("schemaName", form.getSchemaName()); + + List> qinfos = new ArrayList<>(); + + //user-defined queries + if (form.isIncludeUserQueries()) + { + for (QueryDefinition qdef : uschema.getQueryDefs().values()) + { + if (!qdef.isTemporary()) + { + ActionURL viewDataUrl = form.isIncludeViewDataUrl() ? uschema.urlFor(QueryAction.executeQuery, qdef) : null; + qinfos.add(getQueryProps(qdef, viewDataUrl, true, uschema, form.isIncludeColumns(), form.isQueryDetailColumns(), form.isIncludeTitle())); + } + } + } + + //built-in tables + if (form.isIncludeSystemQueries()) + { + for (String qname : uschema.getVisibleTableNames()) + { + // Go direct against the UserSchema instead of calling into QueryService, which takes a schema and + // query name as strings and therefore has to create new instances + QueryDefinition qdef = uschema.getQueryDefForTable(qname); + if (qdef != null) + { + ActionURL viewDataUrl = form.isIncludeViewDataUrl() ? uschema.urlFor(QueryAction.executeQuery, qdef) : null; + qinfos.add(getQueryProps(qdef, viewDataUrl, false, uschema, form.isIncludeColumns(), form.isQueryDetailColumns(), form.isIncludeTitle())); + } + } + } + response.put("queries", qinfos); + + return response; + } + + protected Map getQueryProps(QueryDefinition qdef, ActionURL viewDataUrl, boolean isUserDefined, UserSchema schema, boolean includeColumns, boolean useQueryDetailColumns, boolean includeTitle) + { + Map qinfo = new HashMap<>(); + qinfo.put("hidden", qdef.isHidden()); + qinfo.put("snapshot", qdef.isSnapshot()); + qinfo.put("inherit", qdef.canInherit()); + qinfo.put("isUserDefined", isUserDefined); + boolean canEdit = qdef.canEdit(getUser()); + qinfo.put("canEdit", canEdit); + qinfo.put("canEditSharedViews", getContainer().hasPermission(getUser(), EditSharedViewPermission.class)); + // CONSIDER: do we want to separate the 'canEditMetadata' property and 'isMetadataOverridable' properties to differentiate between capability and the permission check? + qinfo.put("isMetadataOverrideable", qdef.isMetadataEditable() && qdef.canEditMetadata(getUser())); + + if (isUserDefined) + qinfo.put("moduleName", qdef.getModuleName()); + boolean isInherited = qdef.canInherit() && !getContainer().equals(qdef.getDefinitionContainer()); + qinfo.put("isInherited", isInherited); + if (isInherited) + qinfo.put("containerPath", qdef.getDefinitionContainer().getPath()); + qinfo.put("isIncludedForLookups", qdef.isIncludedForLookups()); + + if (null != qdef.getDescription()) + qinfo.put("description", qdef.getDescription()); + if (viewDataUrl != null) + qinfo.put("viewDataUrl", viewDataUrl); + + String title = qdef.getName(); + String name = qdef.getName(); + try + { + // get the TableInfo if the user requested column info or title, otherwise skip (it can be expensive) + if (includeColumns || includeTitle) + { + TableInfo table = qdef.getTable(schema, null, true); + + if (null != table) + { + if (includeColumns) + { + Collection> columns; + + if (useQueryDetailColumns) + { + columns = JsonWriter + .getNativeColProps(table, Collections.emptyList(), null, false, false) + .values(); + } + else + { + columns = new ArrayList<>(); + for (ColumnInfo col : table.getColumns()) + { + Map cinfo = new HashMap<>(); + cinfo.put("name", col.getName()); + if (null != col.getLabel()) + cinfo.put("caption", col.getLabel()); + if (null != col.getShortLabel()) + cinfo.put("shortCaption", col.getShortLabel()); + if (null != col.getDescription()) + cinfo.put("description", col.getDescription()); + + columns.add(cinfo); + } + } + + if (!columns.isEmpty()) + qinfo.put("columns", columns); + } + + if (includeTitle) + { + name = table.getPublicName(); + title = table.getTitle(); + } + } + } + } + catch(Exception e) + { + //may happen due to query failing parse + } + + qinfo.put("title", title); + qinfo.put("name", name); + return qinfo; + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class GetQueryViewsForm + { + private String _schemaName; + private String _queryName; + private String _viewName; + private boolean _metadata; + private boolean _excludeSessionView; + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public String getQueryName() + { + return _queryName; + } + + public void setQueryName(String queryName) + { + _queryName = queryName; + } + + public String getViewName() + { + return _viewName; + } + + public void setViewName(String viewName) + { + _viewName = viewName; + } + + public boolean isMetadata() + { + return _metadata; + } + + public void setMetadata(boolean metadata) + { + _metadata = metadata; + } + + public boolean isExcludeSessionView() + { + return _excludeSessionView; + } + + public void setExcludeSessionView(boolean excludeSessionView) + { + _excludeSessionView = excludeSessionView; + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) + public static class GetQueryViewsAction extends ReadOnlyApiAction + { + @Override + protected long getLastModified(GetQueryViewsForm form) + { + return QueryService.get().metadataLastModified(); + } + + @Override + public ApiResponse execute(GetQueryViewsForm form, BindException errors) + { + if (null == StringUtils.trimToNull(form.getSchemaName())) + throw new IllegalArgumentException("You must pass a value for the 'schemaName' parameter!"); + if (null == StringUtils.trimToNull(form.getQueryName())) + throw new IllegalArgumentException("You must pass a value for the 'queryName' parameter!"); + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()); + if (null == schema) + throw new NotFoundException("The schema name '" + form.getSchemaName() + + "' was not found within the folder '" + getContainer().getPath() + "'"); + + QueryDefinition querydef = QueryService.get().createQueryDefForTable(schema, form.getQueryName()); + if (null == querydef || querydef.getTable(null, true) == null) + throw new NotFoundException("The query '" + form.getQueryName() + "' was not found within the '" + + form.getSchemaName() + "' schema in the container '" + + getContainer().getPath() + "'!"); + + Map views = querydef.getCustomViews(getUser(), getViewContext().getRequest(), true, false, form.isExcludeSessionView()); + if (null == views) + views = Collections.emptyMap(); + + Map> columnMetadata = new HashMap<>(); + + List> viewInfos = Collections.emptyList(); + if (getViewContext().getBindPropertyValues().contains("viewName")) + { + // Get info for a named view or the default view (null) + String viewName = StringUtils.trimToNull(form.getViewName()); + CustomView view = views.get(viewName); + if (view != null) + { + viewInfos = Collections.singletonList(CustomViewUtil.toMap(view, getUser(), form.isMetadata())); + } + else if (viewName == null) + { + // The default view was requested but it hasn't been customized yet. Create the 'default default' view. + viewInfos = Collections.singletonList(CustomViewUtil.toMap(getViewContext(), schema, form.getQueryName(), null, form.isMetadata(), true, columnMetadata)); + } + } + else + { + boolean foundDefault = false; + viewInfos = new ArrayList<>(views.size()); + for (CustomView view : views.values()) + { + if (view.getName() == null) + foundDefault = true; + viewInfos.add(CustomViewUtil.toMap(view, getUser(), form.isMetadata())); + } + + if (!foundDefault) + { + // The default view hasn't been customized yet. Create the 'default default' view. + viewInfos.add(CustomViewUtil.toMap(getViewContext(), schema, form.getQueryName(), null, form.isMetadata(), true, columnMetadata)); + } + } + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("schemaName", form.getSchemaName()); + response.put("queryName", form.getQueryName()); + response.put("views", viewInfos); + + return response; + } + } + + @RequiresNoPermission + public static class GetServerDateAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(Object o, BindException errors) + { + return new ApiSimpleResponse("date", new Date()); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + private static class SaveApiTestForm + { + private String _getUrl; + private String _postUrl; + private String _postData; + private String _response; + + public String getGetUrl() + { + return _getUrl; + } + + public void setGetUrl(String getUrl) + { + _getUrl = getUrl; + } + + public String getPostUrl() + { + return _postUrl; + } + + public void setPostUrl(String postUrl) + { + _postUrl = postUrl; + } + + public String getResponse() + { + return _response; + } + + public void setResponse(String response) + { + _response = response; + } + + public String getPostData() + { + return _postData; + } + + public void setPostData(String postData) + { + _postData = postData; + } + } + + + @RequiresPermission(ReadPermission.class) + public static class SaveApiTestAction extends MutatingApiAction + { + @Override + public ApiResponse execute(SaveApiTestForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + ApiTestsDocument doc = ApiTestsDocument.Factory.newInstance(); + + TestCaseType test = doc.addNewApiTests().addNewTest(); + test.setName("recorded test case"); + ActionURL url = null; + + if (!StringUtils.isEmpty(form.getGetUrl())) + { + test.setType("get"); + url = new ActionURL(form.getGetUrl()); + } + else if (!StringUtils.isEmpty(form.getPostUrl())) + { + test.setType("post"); + test.setFormData(form.getPostData()); + url = new ActionURL(form.getPostUrl()); + } + + if (url != null) + { + String uri = url.getLocalURIString(); + if (uri.startsWith(url.getContextPath())) + uri = uri.substring(url.getContextPath().length() + 1); + + test.setUrl(uri); + } + test.setResponse(form.getResponse()); + + XmlOptions opts = new XmlOptions(); + opts.setSaveCDataEntityCountThreshold(0); + opts.setSaveCDataLengthThreshold(0); + opts.setSavePrettyPrint(); + opts.setUseDefaultNamespace(); + + response.put("xml", doc.xmlText(opts)); + + return response; + } + } + + + private abstract static class ParseAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + List qpe = new ArrayList<>(); + String expr = getViewContext().getRequest().getParameter("q"); + ArrayList html = new ArrayList<>(); + PageConfig config = getPageConfig(); + var inputId = config.makeId("submit_"); + config.addHandler(inputId, "click", "Ext.getBody().mask();"); + html.add("
\n" + + "" + ); + + QNode e = null; + if (null != expr) + { + try + { + e = _parse(expr,qpe); + } + catch (RuntimeException x) + { + qpe.add(new QueryParseException(x.getMessage(),x, 0, 0)); + } + } + + Tree tree = null; + if (null != expr) + { + try + { + tree = _tree(expr); + } catch (Exception x) + { + qpe.add(new QueryParseException(x.getMessage(),x, 0, 0)); + } + } + + for (Throwable x : qpe) + { + if (null != x.getCause() && x != x.getCause()) + x = x.getCause(); + html.add("
" + PageFlowUtil.filter(x.toString())); + LogManager.getLogger(QueryController.class).debug(expr,x); + } + if (null != e) + { + String prefix = SqlParser.toPrefixString(e); + html.add("
"); + html.add(PageFlowUtil.filter(prefix)); + } + if (null != tree) + { + String prefix = SqlParser.toPrefixString(tree); + html.add("
"); + html.add(PageFlowUtil.filter(prefix)); + } + html.add(""); + return HtmlView.unsafe(StringUtils.join(html,"")); + } + + @Override + public void addNavTrail(NavTree root) + { + } + + abstract QNode _parse(String e, List errors); + abstract Tree _tree(String e) throws Exception; + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class ParseExpressionAction extends ParseAction + { + @Override + QNode _parse(String s, List errors) + { + return new SqlParser().parseExpr(s, true, errors); + } + + @Override + Tree _tree(String e) + { + return null; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class ParseQueryAction extends ParseAction + { + @Override + QNode _parse(String s, List errors) + { + return new SqlParser().parseQuery(s, errors, null); + } + + @Override + Tree _tree(String s) throws Exception + { + return new SqlParser().rawQuery(s); + } + } + + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.SelectMetaData.class) + public static class ValidateQueryMetadataAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(QueryForm form, BindException errors) + { + UserSchema schema = form.getSchema(); + + if (null == schema) + { + errors.reject(ERROR_MSG, "could not resolve schema: " + form.getSchemaName()); + return null; + } + + List parseErrors = new ArrayList<>(); + List parseWarnings = new ArrayList<>(); + ApiSimpleResponse response = new ApiSimpleResponse(); + + try + { + TableInfo table = schema.getTable(form.getQueryName(), null); + + if (null == table) + { + errors.reject(ERROR_MSG, "could not resolve table: " + form.getQueryName()); + return null; + } + + if (!QueryManager.get().validateQuery(table, true, parseErrors, parseWarnings)) + { + for (QueryParseException e : parseErrors) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + return response; + } + + SchemaKey schemaKey = SchemaKey.fromString(form.getSchemaName()); + QueryManager.get().validateQueryMetadata(schemaKey, form.getQueryName(), getUser(), getContainer(), parseErrors, parseWarnings); + QueryManager.get().validateQueryViews(schemaKey, form.getQueryName(), getUser(), getContainer(), parseErrors, parseWarnings); + } + catch (QueryParseException e) + { + parseErrors.add(e); + } + + for (QueryParseException e : parseErrors) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + + for (QueryParseException e : parseWarnings) + { + errors.reject(ERROR_MSG, "WARNING: " + e.getMessage()); + } + + return response; + } + + @Override + protected ApiResponseWriter createResponseWriter() throws IOException + { + ApiResponseWriter result = super.createResponseWriter(); + // Issue 44875 - don't send a 400 or 500 response code when there's a bogus query or metadata + result.setErrorResponseStatus(HttpServletResponse.SC_OK); + return result; + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class QueryExportAuditForm + { + private int rowId; + + public int getRowId() + { + return rowId; + } + + public void setRowId(int rowId) + { + this.rowId = rowId; + } + } + + /** + * Action used to redirect QueryAuditProvider [details] column to the exported table's grid view. + */ + @RequiresPermission(AdminPermission.class) + public static class QueryExportAuditRedirectAction extends SimpleRedirectAction + { + @Override + public URLHelper getRedirectURL(QueryExportAuditForm form) + { + if (form.getRowId() == 0) + throw new NotFoundException("Query export audit rowid required"); + + UserSchema auditSchema = QueryService.get().getUserSchema(getUser(), getContainer(), AbstractAuditTypeProvider.QUERY_SCHEMA_NAME); + TableInfo queryExportAuditTable = auditSchema.getTable(QueryExportAuditProvider.QUERY_AUDIT_EVENT, null); + if (null == queryExportAuditTable) + throw new NotFoundException(); + + TableSelector selector = new TableSelector(queryExportAuditTable, + PageFlowUtil.set( + QueryExportAuditProvider.COLUMN_NAME_SCHEMA_NAME, + QueryExportAuditProvider.COLUMN_NAME_QUERY_NAME, + QueryExportAuditProvider.COLUMN_NAME_DETAILS_URL), + new SimpleFilter(FieldKey.fromParts(AbstractAuditTypeProvider.COLUMN_NAME_ROW_ID), form.getRowId()), null); + + Map result = selector.getMap(); + if (result == null) + throw new NotFoundException("Query export audit event not found for rowId"); + + String schemaName = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_SCHEMA_NAME); + String queryName = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_QUERY_NAME); + String detailsURL = (String)result.get(QueryExportAuditProvider.COLUMN_NAME_DETAILS_URL); + + if (schemaName == null || queryName == null) + throw new NotFoundException("Query export audit event has not schemaName or queryName"); + + ActionURL url = new ActionURL(ExecuteQueryAction.class, getContainer()); + + // Apply the sorts and filters + if (detailsURL != null) + { + ActionURL sortFilterURL = new ActionURL(detailsURL); + url.setPropertyValues(sortFilterURL.getPropertyValues()); + } + + if (url.getParameter(QueryParam.schemaName) == null) + url.addParameter(QueryParam.schemaName, schemaName); + if (url.getParameter(QueryParam.queryName) == null && url.getParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName) == null) + url.addParameter(QueryParam.queryName, queryName); + + return url; + } + } + + @RequiresPermission(ReadPermission.class) + public static class AuditHistoryAction extends SimpleViewAction + { + @Override + public ModelAndView getView(QueryForm form, BindException errors) + { + return QueryUpdateAuditProvider.createHistoryQueryView(getViewContext(), form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Audit History"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class AuditDetailsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(QueryDetailsForm form, BindException errors) + { + return QueryUpdateAuditProvider.createDetailsQueryView(getViewContext(), form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Audit History"); + } + } + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class QueryDetailsForm extends QueryForm + { + String _keyValue; + + public String getKeyValue() + { + return _keyValue; + } + + public void setKeyValue(String keyValue) + { + _keyValue = keyValue; + } + } + + @RequiresPermission(ReadPermission.class) + @Action(ActionType.Export.class) + public static class ExportTablesAction extends FormViewAction + { + private ActionURL _successUrl; + + @Override + public void validateCommand(ExportTablesForm form, Errors errors) + { + } + + @Override + public boolean handlePost(ExportTablesForm form, BindException errors) + { + HttpServletResponse httpResponse = getViewContext().getResponse(); + Container container = getContainer(); + QueryServiceImpl svc = (QueryServiceImpl)QueryService.get(); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); OutputStream outputStream = new BufferedOutputStream(baos)) + { + try (ZipFile zip = new ZipFile(outputStream, true)) + { + svc.writeTables(container, getUser(), zip, form.getSchemas(), form.getHeaderType()); + } + + PageFlowUtil.streamFileBytes(httpResponse, FileUtil.makeFileNameWithTimestamp(container.getName(), "tables.zip"), baos.toByteArray(), false); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, e.getMessage() != null ? e.getMessage() : e.getClass().getName()); + LOG.error("Errror exporting tables", e); + } + + if (errors.hasErrors()) + { + _successUrl = new ActionURL(ExportTablesAction.class, getContainer()); + } + + return !errors.hasErrors(); + } + + @Override + public ModelAndView getView(ExportTablesForm form, boolean reshow, BindException errors) + { + // When exporting the zip to the browser, the base action will attempt to reshow the view since we returned + // null as the success URL; returning null here causes the base action to stop pestering the action. + if (reshow && !errors.hasErrors()) + return null; + + return new JspView<>("/org/labkey/query/view/exportTables.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Export Tables"); + } + + @Override + public ActionURL getSuccessURL(ExportTablesForm form) + { + return _successUrl; + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class ExportTablesForm implements HasBindParameters + { + ColumnHeaderType _headerType = ColumnHeaderType.DisplayFieldKey; + Map>> _schemas = new HashMap<>(); + + public ColumnHeaderType getHeaderType() + { + return _headerType; + } + + public void setHeaderType(ColumnHeaderType headerType) + { + _headerType = headerType; + } + + public Map>> getSchemas() + { + return _schemas; + } + + public void setSchemas(Map>> schemas) + { + _schemas = schemas; + } + + @Override + public @NotNull BindException bindParameters(PropertyValues values) + { + BindException errors = new NullSafeBindException(this, "form"); + + PropertyValue schemasProperty = values.getPropertyValue("schemas"); + if (schemasProperty != null && schemasProperty.getValue() != null) + { + try + { + _schemas = JsonUtil.DEFAULT_MAPPER.readValue((String)schemasProperty.getValue(), _schemas.getClass()); + } + catch (IOException e) + { + errors.rejectValue("schemas", ERROR_MSG, e.getMessage()); + } + } + + PropertyValue headerTypeProperty = values.getPropertyValue("headerType"); + if (headerTypeProperty != null && headerTypeProperty.getValue() != null) + { + try + { + _headerType = ColumnHeaderType.valueOf(String.valueOf(headerTypeProperty.getValue())); + } + catch (IllegalArgumentException ex) + { + // ignore + } + } + + return errors; + } + } + + + @RequiresPermission(ReadPermission.class) + public static class SaveNamedSetAction extends MutatingApiAction + { + @Override + public Object execute(NamedSetForm namedSetForm, BindException errors) + { + QueryService.get().saveNamedSet(namedSetForm.getSetName(), namedSetForm.parseSetList()); + return new ApiSimpleResponse("success", true); + } + } + + + @SuppressWarnings({"unused", "WeakerAccess"}) + public static class NamedSetForm + { + String setName; + String[] setList; + + public String getSetName() + { + return setName; + } + + public void setSetName(String setName) + { + this.setName = setName; + } + + public String[] getSetList() + { + return setList; + } + + public void setSetList(String[] setList) + { + this.setList = setList; + } + + public List parseSetList() + { + return Arrays.asList(setList); + } + } + + + @RequiresPermission(ReadPermission.class) + public static class DeleteNamedSetAction extends MutatingApiAction + { + + @Override + public Object execute(NamedSetForm namedSetForm, BindException errors) + { + QueryService.get().deleteNamedSet(namedSetForm.getSetName()); + return new ApiSimpleResponse("success", true); + } + } + + @RequiresPermission(ReadPermission.class) + public static class AnalyzeQueriesAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + JSONObject ret = new JSONObject(); + + try + { + QueryService.QueryAnalysisService analysisService = QueryService.get().getQueryAnalysisService(); + if (analysisService != null) + { + DefaultSchema start = DefaultSchema.get(getUser(), getContainer()); + var deps = new HashSetValuedHashMap(); + + analysisService.analyzeFolder(start, deps); + ret.put("success", true); + + JSONObject objects = new JSONObject(); + for (var from : deps.keySet()) + { + objects.put(from.getKey(), from.toJSON()); + for (var to : deps.get(from)) + objects.put(to.getKey(), to.toJSON()); + } + ret.put("objects", objects); + + JSONArray dependants = new JSONArray(); + for (var from : deps.keySet()) + { + for (var to : deps.get(from)) + dependants.put(new String[] {from.getKey(), to.getKey()}); + } + ret.put("graph", dependants); + } + else + { + ret.put("success", false); + } + return ret; + } + catch (Throwable e) + { + LOG.error(e); + throw UnexpectedException.wrap(e); + } + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetQueryEditorMetadataAction extends ReadOnlyApiAction + { + @Override + protected ObjectMapper createRequestObjectMapper() + { + PropertyService propertyService = PropertyService.get(); + if (null != propertyService) + { + ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy(); + mapper.addMixIn(GWTPropertyDescriptor.class, MetadataTableJSONMixin.class); + return mapper; + } + else + { + throw new RuntimeException("Could not serialize request object"); + } + } + + @Override + protected ObjectMapper createResponseObjectMapper() + { + return createRequestObjectMapper(); + } + + @Override + public Object execute(QueryForm queryForm, BindException errors) throws Exception + { + QueryDefinition queryDef = queryForm.getQueryDef(); + return MetadataTableJSON.getMetadata(queryDef.getSchema().getSchemaName(), queryDef.getName(), getUser(), getContainer()); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + public static class SaveQueryMetadataAction extends MutatingApiAction + { + @Override + protected ObjectMapper createRequestObjectMapper() + { + PropertyService propertyService = PropertyService.get(); + if (null != propertyService) + { + ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy(); + propertyService.configureObjectMapper(mapper, null); + return mapper; + } + else + { + throw new RuntimeException("Could not serialize request object"); + } + } + + @Override + protected ObjectMapper createResponseObjectMapper() + { + return createRequestObjectMapper(); + } + + @Override + public Object execute(QueryMetadataApiForm queryMetadataApiForm, BindException errors) throws Exception + { + String schemaName = queryMetadataApiForm.getSchemaName(); + MetadataTableJSON domain = queryMetadataApiForm.getDomain(); + MetadataTableJSON.saveMetadata(schemaName, domain.getName(), null, domain.getFields(true), queryMetadataApiForm.isUserDefinedQuery(), false, getUser(), getContainer()); + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", true); + resp.put("domain", MetadataTableJSON.getMetadata(schemaName, domain.getName(), getUser(), getContainer())); + return resp; + } + } + + @Marshal(Marshaller.Jackson) + @RequiresAllOf({EditQueriesPermission.class, UpdatePermission.class}) + public static class ResetQueryMetadataAction extends MutatingApiAction + { + @Override + public Object execute(QueryForm queryForm, BindException errors) throws Exception + { + QueryDefinition queryDef = queryForm.getQueryDef(); + return MetadataTableJSON.resetToDefault(queryDef.getSchema().getSchemaName(), queryDef.getName(), getUser(), getContainer()); + } + } + + private static class QueryMetadataApiForm + { + private MetadataTableJSON _domain; + private String _schemaName; + private boolean _userDefinedQuery; + + public MetadataTableJSON getDomain() + { + return _domain; + } + + @SuppressWarnings("unused") + public void setDomain(MetadataTableJSON domain) + { + _domain = domain; + } + + public String getSchemaName() + { + return _schemaName; + } + + @SuppressWarnings("unused") + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public boolean isUserDefinedQuery() + { + return _userDefinedQuery; + } + + @SuppressWarnings("unused") + public void setUserDefinedQuery(boolean userDefinedQuery) + { + _userDefinedQuery = userDefinedQuery; + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetDefaultVisibleColumnsAction extends ReadOnlyApiAction + { + @Override + public Object execute(GetQueryDetailsAction.Form form, BindException errors) throws Exception + { + ApiSimpleResponse resp = new ApiSimpleResponse(); + + Container container = getContainer(); + User user = getUser(); + + if (StringUtils.isEmpty(form.getSchemaName())) + throw new NotFoundException("SchemaName not specified"); + + QuerySchema querySchema = DefaultSchema.get(user, container, form.getSchemaName()); + if (!(querySchema instanceof UserSchema schema)) + throw new NotFoundException("Could not find the specified schema in the folder '" + container.getPath() + "'"); + + QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, form.getQueryName()); + QueryDefinition queryDef = settings.getQueryDef(schema); + if (null == queryDef) + // Don't echo the provided query name, but schema name is legit since it was found. See #44528. + throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'"); + + TableInfo tinfo = queryDef.getTable(null, true); + if (null == tinfo) + throw new NotFoundException("Could not find the specified query '" + form.getQueryName() + "' in the schema '" + form.getSchemaName() + "'"); + + List fields = tinfo.getDefaultVisibleColumns(); + + List displayColumns = QueryService.get().getColumns(tinfo, fields) + .values() + .stream() + .filter(cinfo -> fields.contains(cinfo.getFieldKey())) + .map(cinfo -> cinfo.getDisplayColumnFactory().createRenderer(cinfo)) + .collect(Collectors.toList()); + + resp.put("columns", JsonWriter.getNativeColProps(displayColumns, null, false)); + + return resp; + } + } + + public static class ParseForm implements ApiJsonForm + { + String expression = ""; + Map columnMap = new HashMap<>(); + List phiColumns = new ArrayList<>(); + + Map getColumnMap() + { + return columnMap; + } + + public String getExpression() + { + return expression; + } + + public void setExpression(String expression) + { + this.expression = expression; + } + + public List getPhiColumns() + { + return phiColumns; + } + + public void setPhiColumns(List phiColumns) + { + this.phiColumns = phiColumns; + } + + @Override + public void bindJson(JSONObject json) + { + if (json.has("expression")) + setExpression(json.getString("expression")); + if (json.has("phiColumns")) + setPhiColumns(json.getJSONArray("phiColumns").toList().stream().map(s -> FieldKey.fromParts(s.toString())).collect(Collectors.toList())); + if (json.has("columnMap")) + { + JSONObject columnMap = json.getJSONObject("columnMap"); + for (String key : columnMap.keySet()) + { + try + { + getColumnMap().put(FieldKey.fromParts(key), JdbcType.valueOf(String.valueOf(columnMap.get(key)))); + } + catch (IllegalArgumentException iae) + { + getColumnMap().put(FieldKey.fromParts(key), JdbcType.OTHER); + } + } + } + } + } + + + /** + * Since this api purpose is to return parse errors, it does not generally return success:false. + *
+ * The API expects JSON like this, note that column names should be in FieldKey.toString() encoded to match the response JSON format. + *
+     *     { "expression": "A$ + B", "columnMap":{"A$D":"VARCHAR", "X":"VARCHAR"}}
+     * 
+ * and returns a response like this + *
+     *     {
+     *       "jdbcType" : "OTHER",
+     *       "success" : true,
+     *       "columnMap" : {"A$D":"VARCHAR", "B":"OTHER"}
+     *       "errors" : [ { "msg" : "\"B\" not found.", "type" : "sql" } ]
+     *     }
+     * 
+ * The columnMap object keys are the names of columns found in the expression. Names are returned + * in FieldKey.toString() formatting e.g. dollar-sign encoded. The object structure + * is compatible with the columnMap input parameter, so it can be used as a template to make a second request + * with types filled in. If provided, the type will be copied from the input columnMap, otherwise it will be "OTHER". + *
+ * Parse exceptions may contain a line (usually 1) and col location e.g. + *
+     * {
+     *     "msg" : "Error on line 1: Syntax error near 'error', expected 'EOF'
+     *     "col" : 2,
+     *     "line" : 1,
+     *     "type" : "sql",
+     *     "errorStr" : "A error B"
+     *   }
+     * 
+ */ + @RequiresNoPermission + @CSRF(CSRF.Method.NONE) + public static class ParseCalculatedColumnAction extends ReadOnlyApiAction + { + @Override + public Object execute(ParseForm form, BindException errors) throws Exception + { + if (errors.hasErrors()) + return errors; + JSONObject result = new JSONObject(Map.of("success",true)); + var requiredColumns = new HashSet(); + JdbcType jdbcType = JdbcType.OTHER; + try + { + var schema = DefaultSchema.get(getViewContext().getUser(), getViewContext().getContainer()).getUserSchema("core"); + var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; + ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), form.getExpression(), null); + Map columns = new HashMap<>(); + for (var entry : form.getColumnMap().entrySet()) + { + BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); + // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions + // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects + if (form.getPhiColumns().contains(entry.getKey())) + entryCol.setPHI(PHI.PHI); + columns.put(entry.getKey(), entryCol); + table.addColumn(entryCol); + } + // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() + QueryServiceImpl.get().bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); + jdbcType = calculatedCol.getJdbcType(); + } + catch (QueryException x) + { + JSONArray parseErrors = new JSONArray(); + parseErrors.put(x.toJSON(form.getExpression())); + result.put("errors", parseErrors); + } + finally + { + if (!requiredColumns.isEmpty()) + { + JSONObject columnMap = new JSONObject(); + for (FieldKey fk : requiredColumns) + { + JdbcType type = Objects.requireNonNullElse(form.getColumnMap().get(fk), JdbcType.OTHER); + columnMap.put(fk.toString(), type); + } + result.put("columnMap", columnMap); + } + } + result.put("jdbcType", jdbcType.name()); + return result; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class QueryImportTemplateForm + { + private String schemaName; + private String queryName; + private String auditUserComment; + private List templateLabels; + private List templateUrls; + private Long _lastKnownModified; + + public void setQueryName(String queryName) + { + this.queryName = queryName; + } + + public List getTemplateLabels() + { + return templateLabels == null ? Collections.emptyList() : templateLabels; + } + + public void setTemplateLabels(List templateLabels) + { + this.templateLabels = templateLabels; + } + + public List getTemplateUrls() + { + return templateUrls == null ? Collections.emptyList() : templateUrls; + } + + public void setTemplateUrls(List templateUrls) + { + this.templateUrls = templateUrls; + } + + public String getSchemaName() + { + return schemaName; + } + + @SuppressWarnings("unused") + public void setSchemaName(String schemaName) + { + this.schemaName = schemaName; + } + + public String getQueryName() + { + return queryName; + } + + public Long getLastKnownModified() + { + return _lastKnownModified; + } + + public void setLastKnownModified(Long lastKnownModified) + { + _lastKnownModified = lastKnownModified; + } + + public String getAuditUserComment() + { + return auditUserComment; + } + + public void setAuditUserComment(String auditUserComment) + { + this.auditUserComment = auditUserComment; + } + + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) //Real permissions will be enforced later on by the DomainKind + public static class UpdateQueryImportTemplateAction extends MutatingApiAction + { + private DomainKind _kind; + private UserSchema _schema; + private TableInfo _tInfo; + private QueryDefinition _queryDef; + private Domain _domain; + + @Override + protected ObjectMapper createResponseObjectMapper() + { + return this.createRequestObjectMapper(); + } + + @Override + public void validateForm(QueryImportTemplateForm form, Errors errors) + { + User user = getUser(); + Container container = getContainer(); + String domainURI = PropertyService.get().getDomainURI(form.getSchemaName(), form.getQueryName(), container, user); + _kind = PropertyService.get().getDomainKind(domainURI); + _domain = PropertyService.get().getDomain(container, domainURI); + if (_domain == null) + throw new IllegalArgumentException("Domain '" + domainURI + "' not found."); + + if (!_kind.canEditDefinition(user, _domain)) + throw new UnauthorizedException("You don't have permission to update import templates for this domain."); + + QuerySchema querySchema = DefaultSchema.get(user, container, form.getSchemaName()); + if (!(querySchema instanceof UserSchema _schema)) + throw new NotFoundException("Could not find the specified schema in the folder '" + container.getPath() + "'."); + QuerySettings settings = _schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, form.getQueryName()); + _queryDef = settings.getQueryDef(_schema); + if (null == _queryDef) + throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'."); + if (!_queryDef.isMetadataEditable()) + throw new UnsupportedOperationException("Query metadata is not editable."); + _tInfo = _queryDef.getTable(_schema, new ArrayList<>(), true, true); + if (_tInfo == null) + throw new NotFoundException("Could not find the specified query in the schema '" + form.getSchemaName() + "'."); + + } + + private Map getRowFiles() + { + Map rowFiles = new IntHashMap<>(); + if (getFileMap() != null) + { + for (Map.Entry fileEntry : getFileMap().entrySet()) + { + // allow for the fileMap key to include the row index for defining which row to attach this file to + // ex: "templateFile::0", "templateFile::1" + String fieldKey = fileEntry.getKey(); + int delimIndex = fieldKey.lastIndexOf("::"); + if (delimIndex > -1) + { + Integer fieldRowIndex = Integer.parseInt(fieldKey.substring(delimIndex + 2)); + SpringAttachmentFile file = new SpringAttachmentFile(fileEntry.getValue()); + rowFiles.put(fieldRowIndex, file.isEmpty() ? null : file); + } + } + } + return rowFiles; + } + + private List> getUploadedTemplates(QueryImportTemplateForm form, DomainKind kind) throws ValidationException, QueryUpdateServiceException, ExperimentException + { + FileContentService fcs = FileContentService.get(); + if (fcs == null) + throw new IllegalStateException("Unable to load file service."); + + User user = getUser(); + Container container = getContainer(); + + Map rowFiles = getRowFiles(); + List templateLabels = form.getTemplateLabels(); + Set labels = new HashSet<>(templateLabels); + if (labels.size() < templateLabels.size()) + throw new IllegalArgumentException("Duplicate template name is not allowed."); + + List templateUrls = form.getTemplateUrls(); + List> uploadedTemplates = new ArrayList<>(); + for (int rowIndex = 0; rowIndex < form.getTemplateLabels().size(); rowIndex++) + { + String templateLabel = templateLabels.get(rowIndex); + if (StringUtils.isBlank(templateLabel.trim())) + throw new IllegalArgumentException("Template name cannot be blank."); + String templateUrl = templateUrls.get(rowIndex); + Object file = rowFiles.get(rowIndex); + if (StringUtils.isEmpty(templateUrl) && file == null) + throw new IllegalArgumentException("Template file is not provided."); + + if (file instanceof MultipartFile || file instanceof SpringAttachmentFile) + { + String fileName; + if (file instanceof MultipartFile f) + fileName = f.getName(); + else + { + SpringAttachmentFile f = (SpringAttachmentFile) file; + fileName = f.getFilename(); + } + String fileNameValidation = FileUtil.validateFileName(fileName); + if (!StringUtils.isEmpty(fileNameValidation)) + throw new IllegalArgumentException(fileNameValidation); + + FileLike uploadDir = ensureUploadDirectory(container, kind.getDomainFileDirectory()); + uploadDir = uploadDir.resolveChild("_templates"); + Object savedFile = saveFile(user, container, "template file", file, uploadDir); + Path savedFilePath; + + if (savedFile instanceof File ioFile) + savedFilePath = ioFile.toPath(); + else if (savedFile instanceof FileLike fl) + savedFilePath = fl.toNioPathForRead(); + else + throw UnexpectedException.wrap(null,"Unable to upload template file."); + + templateUrl = fcs.getWebDavUrl(savedFilePath, container, FileContentService.PathType.serverRelative).toString(); + } + + uploadedTemplates.add(Pair.of(templateLabel, templateUrl)); + } + return uploadedTemplates; + } + + @Override + public Object execute(QueryImportTemplateForm form, BindException errors) throws ValidationException, QueryUpdateServiceException, SQLException, ExperimentException, MetadataUnavailableException + { + User user = getUser(); + Container container = getContainer(); + String schemaName = form.getSchemaName(); + String queryName = form.getQueryName(); + QueryDef queryDef = QueryManager.get().getQueryDef(container, schemaName, queryName, false); + if (queryDef != null && queryDef.getQueryDefId() != 0) + { + Long lastKnownModified = form.getLastKnownModified(); + if (lastKnownModified == null || lastKnownModified != queryDef.getModified().getTime()) + throw new ApiUsageException("Unable to save import templates. The templates appear out of date, reload the page and try again."); + } + + List> updatedTemplates = getUploadedTemplates(form, _kind); + + List> existingTemplates = _tInfo.getImportTemplates(getViewContext()); + List> existingCustomTemplates = new ArrayList<>(); + for (Pair template_ : existingTemplates) + { + if (!template_.second.toLowerCase().contains("exportexceltemplate")) + existingCustomTemplates.add(template_); + } + if (!updatedTemplates.equals(existingCustomTemplates)) + { + TablesDocument doc = null; + TableType xmlTable = null; + TableType.ImportTemplates xmlImportTemplates; + + if (queryDef != null) + { + try + { + doc = parseDocument(queryDef.getMetaData()); + } + catch (XmlException e) + { + throw new MetadataUnavailableException(e.getMessage()); + } + xmlTable = getTableType(form.getQueryName(), doc); + // when there is a queryDef but xmlTable is null it means the xmlMetaData contains tableName which does not + // match with actual queryName then reconstruct the xml table metadata : See Issue 43523 + if (xmlTable == null) + { + doc = null; + } + } + else + { + queryDef = new QueryDef(); + queryDef.setSchema(schemaName); + queryDef.setContainer(container.getId()); + queryDef.setName(queryName); + } + + if (doc == null) + { + doc = TablesDocument.Factory.newInstance(); + } + + if (xmlTable == null) + { + TablesType tables = doc.addNewTables(); + xmlTable = tables.addNewTable(); + xmlTable.setTableName(queryName); + } + + if (xmlTable.getTableDbType() == null) + { + xmlTable.setTableDbType("NOT_IN_DB"); + } + + // remove existing templates + if (xmlTable.isSetImportTemplates()) + xmlTable.unsetImportTemplates(); + xmlImportTemplates = xmlTable.addNewImportTemplates(); + + // set new templates + if (!updatedTemplates.isEmpty()) + { + for (Pair template_ : updatedTemplates) + { + ImportTemplateType importTemplateType = xmlImportTemplates.addNewTemplate(); + importTemplateType.setLabel(template_.first); + importTemplateType.setUrl(template_.second); + } + } + + XmlOptions xmlOptions = new XmlOptions(); + xmlOptions.setSavePrettyPrint(); + // Don't use an explicit namespace, making the XML much more readable + xmlOptions.setUseDefaultNamespace(); + queryDef.setMetaData(doc.xmlText(xmlOptions)); + if (queryDef.getQueryDefId() == 0) + { + QueryManager.get().insert(user, queryDef); + } + else + { + QueryManager.get().update(user, queryDef); + } + + DomainAuditProvider.DomainAuditEvent event = new DomainAuditProvider.DomainAuditEvent(getContainer(), "Import templates updated."); + event.setUserComment(form.getAuditUserComment()); + event.setDomainUri(_domain.getTypeURI()); + event.setDomainName(_domain.getName()); + AuditLogService.get().addEvent(user, event); + } + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", true); + return resp; + } + } + + + public static class TestCase extends AbstractActionPermissionTest + { + @Override + public void testActionPermissions() + { + User user = TestContext.get().getUser(); + assertTrue(user.hasSiteAdminPermission()); + + QueryController controller = new QueryController(); + + // @RequiresPermission(ReadPermission.class) + assertForReadPermission(user, false, + new BrowseAction(), + new BeginAction(), + controller.new SchemaAction(), + controller.new SourceQueryAction(), + controller.new ExecuteQueryAction(), + controller.new PrintRowsAction(), + new ExportScriptAction(), + new ExportRowsExcelAction(), + new ExportRowsXLSXAction(), + new ExportQueriesXLSXAction(), + new ExportExcelTemplateAction(), + new ExportRowsTsvAction(), + new ExcelWebQueryDefinitionAction(), + controller.new SaveQueryViewsAction(), + controller.new PropertiesQueryAction(), + controller.new SelectRowsAction(), + new GetDataAction(), + controller.new ExecuteSqlAction(), + controller.new SelectDistinctAction(), + controller.new GetColumnSummaryStatsAction(), + controller.new ImportAction(), + new ExportSqlAction(), + new UpdateRowsAction(), + new ImportRowsAction(), + new DeleteRowsAction(), + new TableInfoAction(), + new SaveSessionViewAction(), + new GetSchemasAction(), + new GetQueriesAction(), + new GetQueryViewsAction(), + new SaveApiTestAction(), + new ValidateQueryMetadataAction(), + new AuditHistoryAction(), + new AuditDetailsAction(), + new ExportTablesAction(), + new SaveNamedSetAction(), + new DeleteNamedSetAction(), + new ApiTestAction(), + new GetDefaultVisibleColumnsAction() + ); + + + // submitter should be allowed for InsertRows + assertForReadPermission(user, true, new InsertRowsAction()); + + // @RequiresPermission(DeletePermission.class) + assertForUpdateOrDeletePermission(user, + new DeleteQueryRowsAction() + ); + + // @RequiresPermission(AdminPermission.class) + assertForAdminPermission(user, + new DeleteQueryAction(), + controller.new MetadataQueryAction(), + controller.new NewQueryAction(), + new SaveSourceQueryAction(), + + new TruncateTableAction(), + new AdminAction(), + new ManageRemoteConnectionsAction(), + new ReloadExternalSchemaAction(), + new ReloadAllUserSchemas(), + controller.new ManageViewsAction(), + controller.new InternalDeleteView(), + controller.new InternalSourceViewAction(), + controller.new InternalNewViewAction(), + new QueryExportAuditRedirectAction() + ); + + // @RequiresPermission(AdminOperationsPermission.class) + assertForAdminOperationsPermission(user, + new EditRemoteConnectionAction(), + new DeleteRemoteConnectionAction(), + new TestRemoteConnectionAction(), + controller.new RawTableMetaDataAction(), + controller.new RawSchemaMetaDataAction(), + new InsertLinkedSchemaAction(), + new InsertExternalSchemaAction(), + new DeleteSchemaAction(), + new EditLinkedSchemaAction(), + new EditExternalSchemaAction(), + new GetTablesAction(), + new SchemaTemplateAction(), + new SchemaTemplatesAction(), + new ParseExpressionAction(), + new ParseQueryAction() + ); + + // @AdminConsoleAction + assertForAdminPermission(ContainerManager.getRoot(), user, + new DataSourceAdminAction() + ); + + // In addition to administrators (tested above), trusted analysts who are editors can create and edit queries + assertTrustedEditorPermission( + new DeleteQueryAction(), + controller.new MetadataQueryAction(), + controller.new NewQueryAction(), + new SaveSourceQueryAction() + ); + } + } + + public static class SaveRowsTestCase extends Assert + { + private static final String PROJECT_NAME1 = "SaveRowsTestProject1"; + private static final String PROJECT_NAME2 = "SaveRowsTestProject2"; + + private static final String USER_EMAIL = "saveRows@action.test"; + + private static final String LIST1 = "List1"; + private static final String LIST2 = "List2"; + + @Before + public void doSetup() throws Exception + { + doCleanup(); + + Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_NAME1, TestContext.get().getUser()); + Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_NAME2, TestContext.get().getUser()); + + //disable search so we dont get conflicts when deleting folder quickly + ContainerManager.updateSearchable(project1, false, TestContext.get().getUser()); + ContainerManager.updateSearchable(project2, false, TestContext.get().getUser()); + + ListDefinition ld1 = ListService.get().createList(project1, LIST1, ListDefinition.KeyType.Varchar); + ld1.getDomain().addProperty(new PropertyStorageSpec("TextField", JdbcType.VARCHAR)); + ld1.setKeyName("TextField"); + ld1.save(TestContext.get().getUser()); + + ListDefinition ld2 = ListService.get().createList(project2, LIST2, ListDefinition.KeyType.Varchar); + ld2.getDomain().addProperty(new PropertyStorageSpec("TextField", JdbcType.VARCHAR)); + ld2.setKeyName("TextField"); + ld2.save(TestContext.get().getUser()); + } + + @After + public void doCleanup() throws Exception + { + Container project = ContainerManager.getForPath(PROJECT_NAME1); + if (project != null) + { + ContainerManager.deleteAll(project, TestContext.get().getUser()); + } + + Container project2 = ContainerManager.getForPath(PROJECT_NAME2); + if (project2 != null) + { + ContainerManager.deleteAll(project2, TestContext.get().getUser()); + } + + User u = UserManager.getUser(new ValidEmail(USER_EMAIL)); + if (u != null) + { + UserManager.deleteUser(u.getUserId()); + } + } + + private JSONObject getCommand(String val1, String val2) + { + JSONObject command1 = new JSONObject(); + command1.put("containerPath", ContainerManager.getForPath(PROJECT_NAME1).getPath()); + command1.put("command", "insert"); + command1.put("schemaName", "lists"); + command1.put("queryName", LIST1); + command1.put("rows", getTestRows(val1)); + + JSONObject command2 = new JSONObject(); + command2.put("containerPath", ContainerManager.getForPath(PROJECT_NAME2).getPath()); + command2.put("command", "insert"); + command2.put("schemaName", "lists"); + command2.put("queryName", LIST2); + command2.put("rows", getTestRows(val2)); + + JSONObject json = new JSONObject(); + json.put("commands", Arrays.asList(command1, command2)); + + return json; + } + + private MockHttpServletResponse makeRequest(JSONObject json, User user) throws Exception + { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + + HttpServletRequest request = ViewServlet.mockRequest(RequestMethod.POST.name(), DetailsURL.fromString("/query/saveRows.view").copy(ContainerManager.getForPath(PROJECT_NAME1)).getActionURL(), user, headers, json.toString()); + return ViewServlet.mockDispatch(request, null); + } + + @Test + public void testCrossFolderSaveRows() throws Exception + { + User user = TestContext.get().getUser(); + assertTrue(user.hasSiteAdminPermission()); + + JSONObject json = getCommand(PROJECT_NAME1, PROJECT_NAME2); + MockHttpServletResponse response = makeRequest(json, TestContext.get().getUser()); + if (response.getStatus() != HttpServletResponse.SC_OK) + { + JSONObject responseJson = new JSONObject(response.getContentAsString()); + throw new RuntimeException("Problem saving rows across folders: " + responseJson.getString("exception")); + } + + Container project1 = ContainerManager.getForPath(PROJECT_NAME1); + Container project2 = ContainerManager.getForPath(PROJECT_NAME2); + + TableInfo list1 = ListService.get().getList(project1, LIST1).getTable(TestContext.get().getUser()); + TableInfo list2 = ListService.get().getList(project2, LIST2).getTable(TestContext.get().getUser()); + + assertEquals("Incorrect row count, list1", 1L, new TableSelector(list1).getRowCount()); + assertEquals("Incorrect row count, list2", 1L, new TableSelector(list2).getRowCount()); + + assertEquals("Incorrect value", PROJECT_NAME1, new TableSelector(list1, PageFlowUtil.set("TextField")).getObject(PROJECT_NAME1, String.class)); + assertEquals("Incorrect value", PROJECT_NAME2, new TableSelector(list2, PageFlowUtil.set("TextField")).getObject(PROJECT_NAME2, String.class)); + + list1.getUpdateService().truncateRows(TestContext.get().getUser(), project1, null, null); + list2.getUpdateService().truncateRows(TestContext.get().getUser(), project2, null, null); + } + + @Test + public void testWithoutPermissions() throws Exception + { + // Now test failure without appropriate permissions: + User withoutPermissions = SecurityManager.addUser(new ValidEmail(USER_EMAIL), TestContext.get().getUser()).getUser(); + + User user = TestContext.get().getUser(); + assertTrue(user.hasSiteAdminPermission()); + + Container project1 = ContainerManager.getForPath(PROJECT_NAME1); + Container project2 = ContainerManager.getForPath(PROJECT_NAME2); + + MutableSecurityPolicy securityPolicy = new MutableSecurityPolicy(SecurityPolicyManager.getPolicy(project1)); + securityPolicy.addRoleAssignment(withoutPermissions, EditorRole.class); + SecurityPolicyManager.savePolicyForTests(securityPolicy, TestContext.get().getUser()); + + assertTrue("Should have insert permission", project1.hasPermission(withoutPermissions, InsertPermission.class)); + assertFalse("Should not have insert permission", project2.hasPermission(withoutPermissions, InsertPermission.class)); + + // repeat insert: + JSONObject json = getCommand("ShouldFail1", "ShouldFail2"); + MockHttpServletResponse response = makeRequest(json, withoutPermissions); + if (response.getStatus() != HttpServletResponse.SC_FORBIDDEN) + { + JSONObject responseJson = new JSONObject(response.getContentAsString()); + throw new RuntimeException("Problem saving rows across folders: " + responseJson.getString("exception")); + } + + TableInfo list1 = ListService.get().getList(project1, LIST1).getTable(TestContext.get().getUser()); + TableInfo list2 = ListService.get().getList(project2, LIST2).getTable(TestContext.get().getUser()); + + // The insert should have failed + assertEquals("Incorrect row count, list1", 0L, new TableSelector(list1).getRowCount()); + assertEquals("Incorrect row count, list2", 0L, new TableSelector(list2).getRowCount()); + } + + private JSONArray getTestRows(String val) + { + JSONArray rows = new JSONArray(); + rows.put(Map.of("TextField", val)); + + return rows; + } + } +} From 125705735cbaa79fb57beac474569330bcb7fbac Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 7 Oct 2025 12:09:03 -0700 Subject: [PATCH 4/7] Refer to constant --- api/webapp/clientapi/dom/DataRegion.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index f8ce7b85b86..84db3946a79 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -16,7 +16,7 @@ if (!LABKEY.DataRegions) { var ALL_ROWS_MAX = 5_000; var CUSTOM_VIEW_PANELID = '~~customizeView~~'; var DEFAULT_TIMEOUT = 30_000; - const MAX_SELECTION_SIZE = 1_000; + const MAX_SELECTION_SIZE = LABKEY.moduleContext.query.maxQuerySelection; var PARAM_PREFIX = '.param.'; var SORT_ASC = '+'; var SORT_DESC = '-'; From e336472fd1004d2f7607ce2f351a39c674d117e0 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 8 Oct 2025 11:35:51 -0700 Subject: [PATCH 5/7] Update "Select All" in nav tree --- api/src/org/labkey/api/data/DataRegion.java | 3 +- .../labkey/api/data/DataRegionSelection.java | 46 ++++++--- api/src/org/labkey/api/view/NavTree.java | 7 +- api/webapp/clientapi/dom/DataRegion.js | 95 ++++++++++++++----- 4 files changed, 112 insertions(+), 39 deletions(-) diff --git a/api/src/org/labkey/api/data/DataRegion.java b/api/src/org/labkey/api/data/DataRegion.java index 3aa785ea704..e5bac1b8929 100644 --- a/api/src/org/labkey/api/data/DataRegion.java +++ b/api/src/org/labkey/api/data/DataRegion.java @@ -1558,7 +1558,8 @@ private HtmlWriter renderNavTree(HtmlWriter out) final String jsObject = getJavaScriptObjectReference(); NavTree navtree = new NavTree(); - NavTree selectAll = new NavTree("Select All"); + NavTree selectAll = new NavTree("Select All Rows"); + selectAll.setId(getDomId() + "-navtree-select-all"); selectAll.setScript(jsObject + ".selectAll();"); navtree.addChild(selectAll); diff --git a/api/src/org/labkey/api/data/DataRegionSelection.java b/api/src/org/labkey/api/data/DataRegionSelection.java index 192ece7a6bd..862663239f5 100644 --- a/api/src/org/labkey/api/data/DataRegionSelection.java +++ b/api/src/org/labkey/api/data/DataRegionSelection.java @@ -267,6 +267,18 @@ public static int setSelected(ViewContext context, String key, Collection selection, boolean checked, boolean useSnapshot) + { + return setSelected(context, key, selection, checked, useSnapshot, false); + } + + private static int setSelected( + ViewContext context, + String key, + Collection selection, + boolean checked, + boolean useSnapshot, + boolean replaceSelection + ) { if (checked && selection.size() > MAX_QUERY_SELECTION_SIZE) throw new BadRequestException(selectionTooLargeMessage(selection.size())); @@ -274,22 +286,29 @@ public static int setSelected(ViewContext context, String key, Collection selectedValues = getSet(context, key, true, useSnapshot); if (checked) { - // Verify that adding these selections will not result in a set that is too large - if (selectedValues.size() + selection.size() > MAX_QUERY_SELECTION_SIZE) + synchronized (selectedValues) { - // Do not modify the actual selected values - int current = selectedValues.size(); - int distinctAdds = 0; - - for (String id : selection) + if (replaceSelection) { - if (!selectedValues.contains(id)) - distinctAdds++; + selectedValues.clear(); } + else if (selectedValues.size() + selection.size() > MAX_QUERY_SELECTION_SIZE) + { + // Verify that adding these selections will not result in a set that is too large + // Do not modify the actual selected values yet + int current = selectedValues.size(); + int distinctAdds = 0; - int prospective = current + distinctAdds; - if (prospective > MAX_QUERY_SELECTION_SIZE) - throw new BadRequestException(selectionTooLargeMessage(prospective)); + for (String id : selection) + { + if (!selectedValues.contains(id)) + distinctAdds++; + } + + int prospective = current + distinctAdds; + if (prospective > MAX_QUERY_SELECTION_SIZE) + throw new BadRequestException(selectionTooLargeMessage(prospective)); + } } selectedValues.addAll(selection); @@ -387,7 +406,6 @@ public static Set getSelected(QueryForm form, boolean clearSelected) thr if (clearSelected && !selection.isEmpty()) { - //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (selection) { items.forEach(selection::remove); @@ -486,7 +504,7 @@ public static int setSelectionForAll(QueryView view, String key, boolean checked try (Timing ignored = MiniProfiler.step("selectAll"); ResultSet rs = rgn.getResults(rc)) { var selection = createSelectionSet(rc, rgn, rs, null); - return setSelected(view.getViewContext(), key, selection, checked); + return setSelected(view.getViewContext(), key, selection, checked, false, true); } catch (SQLException e) { diff --git a/api/src/org/labkey/api/view/NavTree.java b/api/src/org/labkey/api/view/NavTree.java index aa39c319568..4faf50532ff 100644 --- a/api/src/org/labkey/api/view/NavTree.java +++ b/api/src/org/labkey/api/view/NavTree.java @@ -755,8 +755,13 @@ public LinkBuilder toLinkBuilder(@Nullable String cls) } } + String id = getId(); + if (id == null) + id = config.makeId("popupMenuView"); + id = id.replaceAll(" ", ""); + LinkBuilder builder = LinkBuilder.simpleLink(html) - .id(config.makeId("popupMenuView")) + .id(id) .target(getTarget()) .title(getDescription()) .tabindex(0) diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index 84db3946a79..27c07cef71c 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -16,7 +16,7 @@ if (!LABKEY.DataRegions) { var ALL_ROWS_MAX = 5_000; var CUSTOM_VIEW_PANELID = '~~customizeView~~'; var DEFAULT_TIMEOUT = 30_000; - const MAX_SELECTION_SIZE = LABKEY.moduleContext.query.maxQuerySelection; + const MAX_SELECTION_SIZE = LABKEY.moduleContext.query?.maxQuerySelection ?? 100_000; var PARAM_PREFIX = '.param.'; var SORT_ASC = '+'; var SORT_DESC = '-'; @@ -852,6 +852,10 @@ if (!LABKEY.DataRegions) { if (!_selDocClick) { _selDocClick = $(document).on('click', _onDocumentClick); } + + if (_isShowSelectAll(this)) { + _getNavTreeSelectAllSelector(this).html(_getSelectAllText(this)); + } }; var _selClickLock; // lock to prevent removing a row highlight that was just applied @@ -911,10 +915,10 @@ if (!LABKEY.DataRegions) { LABKEY.DataRegion.clearSelected(config); } - if (this.showRows == 'selected') { + if (this.showRows === 'selected') { _removeParameters(this, [SHOW_ROWS_PREFIX]); } - else if (this.showRows == 'unselected') { + else if (this.showRows === 'unselected') { // keep "SHOW_ROWS_PREFIX=unselected" parameter window.location.reload(true); } @@ -1036,16 +1040,26 @@ if (!LABKEY.DataRegions) { LABKEY.DataRegion.selectAll(config); - if (this.showRows === "selected") { + if (this.showRows === 'selected') { // keep "SHOW_ROWS_PREFIX=selected" parameter window.location.reload(true); } - else if (this.showRows === "unselected") { + else if (this.showRows === 'unselected') { _removeParameters(this, [SHOW_ROWS_PREFIX]); } - else { + else if (this.totalRows <= MAX_SELECTION_SIZE) { _toggleAllRows(this, true); } + else { + const lastRowIdx = this.offset + this.rowCount; + if (lastRowIdx < MAX_SELECTION_SIZE) { + _toggleAllRows(this, true); + } else if (this.offset < MAX_SELECTION_SIZE && MAX_SELECTION_SIZE < lastRowIdx) { + _checkRows(this, MAX_SELECTION_SIZE - this.offset); + } else { + _toggleAllRows(this, false); + } + } } }; @@ -1080,15 +1094,15 @@ if (!LABKEY.DataRegions) { var msg; if (me.totalRows) { if (count == me.totalRows) { - msg = 'All ' + this.totalRows + ' rows selected.'; + msg = 'All ' + this.totalRows.toLocaleString() + ' rows selected.'; } else { - msg = 'Selected ' + count + ' of ' + this.totalRows + ' rows.'; + msg = 'Selected ' + count.toLocaleString() + ' of ' + this.totalRows.toLocaleString() + ' rows.'; } } else { // totalRows isn't available when showing all rows. - msg = 'Selected ' + count + ' rows.'; + msg = 'Selected ' + count.toLocaleString() + ' rows.'; } _showSelectMessage(me, msg); } @@ -3153,20 +3167,26 @@ if (!LABKEY.DataRegions) { return exists; }; + var _getDomIdSelector = function(region, suffix) { + let selector = '#' + region.domId; + if (suffix) selector += suffix; + return $(selector); + }; + var _getAllRowSelectors = function(region) { return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".toggle"]'); }; var _getBarSelector = function(region) { - return $('#' + region.domId + '-headerbar'); + return _getDomIdSelector(region, '-headerbar'); }; var _getContextBarSelector = function(region) { - return $('#' + region.domId + '-ctxbar'); + return _getDomIdSelector(region, '-ctxbar'); }; var _getDrawerSelector = function(region) { - return $('#' + region.domId + '-drawer'); + return _getDomIdSelector(region, '-drawer'); }; var _getFormSelector = function(region) { @@ -3174,14 +3194,14 @@ if (!LABKEY.DataRegions) { // derived DataRegion's may not include the form id if (form.length === 0) { - form = $('#' + region.domId).closest('form'); + form = _getDomIdSelector(region).closest('form'); } return form; }; var _getHeaderSelector = function(region) { - return $('#' + region.domId + '-header'); + return _getDomIdSelector(region, '-header'); }; var _getRowSelectors = function(region) { @@ -3189,7 +3209,7 @@ if (!LABKEY.DataRegions) { }; var _getSectionSelector = function(region, dir) { - return $('#' + region.domId + '-section-' + dir); + return _getDomIdSelector(region, '-section-' + dir); }; var _getShowFirstSelector = function(region) { @@ -3204,6 +3224,10 @@ if (!LABKEY.DataRegions) { return $('#' + region.showAllID); }; + var _getNavTreeSelectAllSelector = function(region) { + return _getDomIdSelector(region, '-navtree-select-all'); + } + // Formerly, LABKEY.DataRegion.getParamValPairsFromString / LABKEY.DataRegion.getParamValPairs var _getParameters = function(region, skipPrefixSet /* optional */) { @@ -3327,7 +3351,7 @@ if (!LABKEY.DataRegions) { }; var _getViewBarSelector = function(region) { - return $('#' + region.domId + '-viewbar'); + return _getDomIdSelector(region, '-viewbar'); }; var _buttonSelectionBind = function(region, cls, fn) { @@ -3548,14 +3572,22 @@ if (!LABKEY.DataRegions) { _setParameter(region, SHOW_ROWS_PREFIX, showRowsEnum, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); }; + var _getSelectAllText = function(region) { + let text = 'Select All Rows'; + if (region.totalRows > MAX_SELECTION_SIZE) { + text = `Select First ${MAX_SELECTION_SIZE.toLocaleString()} Rows`; + } + return text; + }; + + var _isShowSelectAll = function(region) { + return region.totalRows && region.totalRows !== region.selectedCount && region.selectedCount < MAX_SELECTION_SIZE; + }; + var _showSelectMessage = function(region, msg) { if (region.showRecordSelectors) { - if (region.totalRows && region.totalRows !== region.selectedCount && region.selectedCount < MAX_SELECTION_SIZE) { - let text = 'Select All Rows'; - if (region.totalRows > MAX_SELECTION_SIZE) { - text = `Select First ${MAX_SELECTION_SIZE.toLocaleString()} Rows`; - } - msg += " " + text + ""; + if (_isShowSelectAll(region)) { + msg += " " + _getSelectAllText(region) + ""; } msg += " " + "Select None"; @@ -3583,10 +3615,27 @@ if (!LABKEY.DataRegions) { } }); - _getAllRowSelectors(region).each(function() { this.checked = checked === true; }); + _getAllRowSelectors(region).each(function() { + this.checked = checked === true; + }); return ids; }; + var _checkRows = function(region, numRows) { + const rowSelectors = _getRowSelectors(region); + + for (let i = 0; i < rowSelectors.length; i++) { + const el = rowSelectors[i]; + if (!el.disabled) { + el.checked = i < numRows; + } + } + + _getAllRowSelectors(region).each(function() { + this.checked = true; + }); + } + /** * Asynchronous loader for a DataRegion * @param region {DataRegion} From 025db4d3ae4da96e2b9ddc0c393a63e51d66e1fc Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 9 Oct 2025 08:58:39 -0700 Subject: [PATCH 6/7] Code review feedback --- api/src/org/labkey/api/data/DataRegionSelection.java | 2 +- api/webapp/clientapi/dom/DataRegion.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/data/DataRegionSelection.java b/api/src/org/labkey/api/data/DataRegionSelection.java index 862663239f5..7a8818ad2bb 100644 --- a/api/src/org/labkey/api/data/DataRegionSelection.java +++ b/api/src/org/labkey/api/data/DataRegionSelection.java @@ -61,7 +61,7 @@ public class DataRegionSelection public static final String SEPARATOR = "$"; public static final String DATA_REGION_SELECTION_KEY = "dataRegionSelectionKey"; - // Issue 53997: Establish a maximum number of selected items allowed for a query. + // Issue 53997: Establish a maximum size for query selections public static final int MAX_QUERY_SELECTION_SIZE = 100_000; // set/updated using query-setSnapshotSelection diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index 27c07cef71c..2310bafd0c5 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -853,6 +853,7 @@ if (!LABKEY.DataRegions) { _selDocClick = $(document).on('click', _onDocumentClick); } + // Issue 53997: Establish a maximum size for query selections if (_isShowSelectAll(this)) { _getNavTreeSelectAllSelector(this).html(_getSelectAllText(this)); } @@ -1051,12 +1052,17 @@ if (!LABKEY.DataRegions) { _toggleAllRows(this, true); } else { + // The number of selected rows exceeds MAX_SELECTION_SIZE, so here we determine + // which rows should be checked given which page (offset) we're on. const lastRowIdx = this.offset + this.rowCount; if (lastRowIdx < MAX_SELECTION_SIZE) { + // On a page where ALL rows are within the first MAX_SELECTION_SIZE rows, _toggleAllRows(this, true); } else if (this.offset < MAX_SELECTION_SIZE && MAX_SELECTION_SIZE < lastRowIdx) { + // On a page where SOME rows are within the first MAX_SELECTION_SIZE rows. _checkRows(this, MAX_SELECTION_SIZE - this.offset); } else { + // On a page where NONE rows are within the first MAX_SELECTION_SIZE rows. _toggleAllRows(this, false); } } From 4715dd709a5a3178361648764eca8d7ff7be1113 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 9 Oct 2025 17:22:34 -0700 Subject: [PATCH 7/7] Review feedback --- .../labkey/api/data/DataRegionSelection.java | 17 ++++++++--------- .../query/controllers/QueryController.java | 14 ++++++-------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/api/src/org/labkey/api/data/DataRegionSelection.java b/api/src/org/labkey/api/data/DataRegionSelection.java index 7a8818ad2bb..acdf4b5c976 100644 --- a/api/src/org/labkey/api/data/DataRegionSelection.java +++ b/api/src/org/labkey/api/data/DataRegionSelection.java @@ -208,8 +208,7 @@ public static String getSelectionKeyFromRequest(ViewContext context) values = context.getRequest().getParameterValues(DataRegion.SELECT_CHECKBOX_NAME); if (null != values && values.length == 1 && values[0].contains("\t")) values = StringUtils.split(values[0],'\t'); - List parameterSelected = values == null ? new ArrayList<>() : Arrays.asList(values); - Set result = new LinkedHashSet<>(parameterSelected); + Set result = values == null ? new LinkedHashSet<>() : new LinkedHashSet<>(Arrays.asList(values)); Set sessionSelected = getSet(context, key, false); synchronized (sessionSelected) @@ -284,9 +283,9 @@ private static int setSelected( throw new BadRequestException(selectionTooLargeMessage(selection.size())); Set selectedValues = getSet(context, key, true, useSnapshot); - if (checked) + synchronized (selectedValues) { - synchronized (selectedValues) + if (checked) { if (replaceSelection) { @@ -309,12 +308,12 @@ else if (selectedValues.size() + selection.size() > MAX_QUERY_SELECTION_SIZE) if (prospective > MAX_QUERY_SELECTION_SIZE) throw new BadRequestException(selectionTooLargeMessage(prospective)); } - } - selectedValues.addAll(selection); + selectedValues.addAll(selection); + } + else + selectedValues.removeAll(selection); } - else - selectedValues.removeAll(selection); return selectedValues.size(); } @@ -412,7 +411,7 @@ public static Set getSelected(QueryForm form, boolean clearSelected) thr } } - return items; + return Collections.unmodifiableSet(items); } private static Pair getDataRegionContext(QueryView view) diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 9ad1056cd37..4b37da7c7dd 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -6583,16 +6583,14 @@ public void validateForm(SelectForm form, Errors errors) public ApiResponse execute(final SelectForm form, BindException errors) throws Exception { getViewContext().getResponse().setHeader("Content-Type", CONTENT_TYPE_JSON); + Set selected; + if (form.getQueryName() == null) - { - Set selected = DataRegionSelection.getSelected(getViewContext(), form.getKey(), form.isClearSelected()); - return new ApiSimpleResponse("selected", selected); - } + selected = DataRegionSelection.getSelected(getViewContext(), form.getKey(), form.isClearSelected()); else - { - Set selected = DataRegionSelection.getSelected(form, form.isClearSelected()); - return new ApiSimpleResponse("selected", selected); - } + selected = DataRegionSelection.getSelected(form, form.isClearSelected()); + + return new ApiSimpleResponse("selected", selected); } }