diff --git a/api/src/org/labkey/api/action/BaseViewAction.java b/api/src/org/labkey/api/action/BaseViewAction.java index dfce3258d4f..2a7f515b772 100644 --- a/api/src/org/labkey/api/action/BaseViewAction.java +++ b/api/src/org/labkey/api/action/BaseViewAction.java @@ -66,7 +66,6 @@ import org.springframework.web.bind.ServletRequestDataBinder; import org.springframework.web.bind.ServletRequestParameterPropertyValues; import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.multipart.MultipartHttpServletRequest; import org.springframework.web.servlet.ModelAndView; import java.beans.PropertyDescriptor; @@ -76,7 +75,6 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Predicate; @@ -376,7 +374,6 @@ protected Object newInstance(Class c) public static @NotNull BindException springBindParameters(Object command, String commandName, PropertyValues params) { - Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); ServletRequestDataBinder binder = new ServletRequestDataBinder(command, commandName); String[] fields = binder.getDisallowedFields(); @@ -391,6 +388,7 @@ protected Object newInstance(Class c) try { // most paths probably called getPropertyValuesForFormBinding() already, but this is a public static method, so call it again + Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); binder.bind(getPropertyValuesForFormBinding(params, allow)); BindException errors = new NullSafeBindException(binder.getBindingResult()); return errors; @@ -432,13 +430,13 @@ static BindingErrorProcessor getBindingErrorProcessor(final BindingErrorProcesso return new BindingErrorProcessor() { @Override - public void processMissingFieldError(String missingField, BindingResult bindingResult) + public void processMissingFieldError(@NotNull String missingField, @NotNull BindingResult bindingResult) { defaultBEP.processMissingFieldError(missingField, bindingResult); } @Override - public void processPropertyAccessException(PropertyAccessException ex, BindingResult bindingResult) + public void processPropertyAccessException(@NotNull PropertyAccessException ex, @NotNull BindingResult bindingResult) { Object newValue = ex.getPropertyChangeEvent().getNewValue(); if (newValue instanceof String) diff --git a/api/src/org/labkey/api/action/FormViewAction.java b/api/src/org/labkey/api/action/FormViewAction.java index fa34304a341..7929be1c528 100644 --- a/api/src/org/labkey/api/action/FormViewAction.java +++ b/api/src/org/labkey/api/action/FormViewAction.java @@ -16,11 +16,14 @@ package org.labkey.api.action; +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.ObjectFactory; import org.labkey.api.miniprofiler.MiniProfiler; import org.labkey.api.miniprofiler.Timing; import org.labkey.api.util.ExceptionUtil; import org.labkey.api.util.URLHelper; import org.labkey.api.view.HttpView; +import org.springframework.beans.PropertyValue; import org.springframework.beans.PropertyValues; import org.springframework.validation.BindException; import org.springframework.validation.Errors; @@ -28,6 +31,8 @@ import org.springframework.web.servlet.ModelAndView; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; /** * Is this better than BaseCommandController? Probably not, but it understands TableViewForm. @@ -116,7 +121,7 @@ public ModelAndView handleRequest(FORM form, BindException errors) throws Except if (errors != null && errors.hasErrors()) { StringBuilder errorTextBuilder = new StringBuilder(); - String newLine = System.getProperty("line.separator"); + String newLine = System.lineSeparator(); List errorsList = errors.getAllErrors(); for (int i = 0; i < errorsList.size(); i++) @@ -136,7 +141,6 @@ public ModelAndView handleRequest(FORM form, BindException errors) throws Except } } - @Override protected String getCommandClassMethodName() { @@ -145,11 +149,26 @@ protected String getCommandClassMethodName() public BindException bindParameters(PropertyValues m) throws Exception { - return defaultBindParameters(getCommand(), m); + Class commandClass = getCommandClass(); + return commandClass.isRecord() ? defaultBindParametersToRecord(commandClass, m) : defaultBindParameters(getCommand(), m); + } + + // Simple binding for Java records: no support for binding errors, arrays, lists, etc. + private BindException defaultBindParametersToRecord(Class recordClass, PropertyValues pvs) + { + // Note: We don't support record-based forms implementing HasAllowBindParameter since we must populate all + // properties at record construction time and therefore can't invoke allowBindParameter() prior to that. + PropertyValues m = getPropertyValuesForFormBinding(pvs, HasAllowBindParameter.getDefaultPredicate()); + ObjectFactory factory = ObjectFactory.Registry.getFactory(recordClass); + Map map = m.stream() + .filter(pv -> pv.getValue() != null) + .collect(Collectors.toMap(PropertyValue::getName, PropertyValue::getValue)); + R record = factory.fromMap(map); + return new NullSafeBindException(record, "Form"); } @Override - public void validate(Object target, Errors errors) + public void validate(@NotNull Object target, @NotNull Errors errors) { if (target instanceof HasValidator) { diff --git a/api/src/org/labkey/api/data/TableInfo.java b/api/src/org/labkey/api/data/TableInfo.java index 15cde936ec4..9a91a9b1a91 100644 --- a/api/src/org/labkey/api/data/TableInfo.java +++ b/api/src/org/labkey/api/data/TableInfo.java @@ -209,6 +209,12 @@ void addColumn(ColumnInfo column) { columns.add(column); } + + public String display() + { + String display = indexType.name().toUpperCase() + " " + name + " " + columns.stream().map(ColumnInfo::getName).toList(); + return filterCondition == null ? display : display + " + " + filterCondition; + } } /** Get a list of columns that specifies a unique key, may return the same result as getPKColumns() diff --git a/core/src/org/labkey/core/junit/JunitController.java b/core/src/org/labkey/core/junit/JunitController.java index 4fec3407617..70645b658a9 100644 --- a/core/src/org/labkey/core/junit/JunitController.java +++ b/core/src/org/labkey/core/junit/JunitController.java @@ -91,10 +91,10 @@ public JunitController() public static class JUnitViewBean { - public final Map> testCases; + public final Map>> testCases; public final boolean showRunButtons; - JUnitViewBean(Map> tests, boolean buttons) + JUnitViewBean(Map>> tests, boolean buttons) { this.testCases = tests; this.showRunButtons = buttons; @@ -186,7 +186,7 @@ else if (!StringUtils.isEmpty(form.getTestCase())) // To allow performance tests to be selected change the scope to PERFORMANCE. form._scope = TestWhen.When.PERFORMANCE; - for (List list : JunitManager.getTestCases().values()) + for (List> list : JunitManager.getTestCases().values()) { list.stream() .filter((test) -> test.getName().equals(form.getTestCase())) @@ -237,14 +237,14 @@ public ModelAndView getView(TestForm form, BindException errors) throws Exceptio } else { - List testClasses = getTestClasses(form); + List> testClasses = getTestClasses(form); TestContext.setTestContext(getViewContext().getRequest(), getUser()); getPageConfig().setTemplate(PageConfig.Template.Dialog); results = new LinkedList<>(); HttpServletResponse response = getViewContext().getResponse(); response.setContentType("text/plain"); - for (Class testClass : testClasses) + for (Class testClass : testClasses) { // show status. this also stops the tests if the client goes away. response.getWriter().println(testClass.getName()); @@ -260,13 +260,13 @@ public ModelAndView getView(TestForm form, BindException errors) throws Exceptio return view; } - private List getTestClasses(TestForm form) + private List> getTestClasses(TestForm form) { String module = form.getModule(); if (null != module) { - List moduleTests = JunitManager.getTestCases().get(module); + List> moduleTests = JunitManager.getTestCases().get(module); if (moduleTests == null || moduleTests.isEmpty()) { throw new NotFoundException("No tests for module: " + module); @@ -274,18 +274,18 @@ private List getTestClasses(TestForm form) return moduleTests; } - Set allTestClasses = new LinkedHashSet<>(); + Set> allTestClasses = new LinkedHashSet<>(); JunitManager.getTestCases() - .values() - .forEach(moduleTests -> allTestClasses.addAll(moduleTests)); + .values() + .forEach(allTestClasses::addAll); final String testCase = form.getTestCase(); if (!StringUtils.isBlank(testCase)) { - Class specifiedTest = allTestClasses.parallelStream() - .filter(clazz -> testCase.equals(clazz.getName())) - .findAny() - .orElseThrow(() -> new NotFoundException("No such test: " + testCase)); + Class specifiedTest = allTestClasses.parallelStream() + .filter(clazz -> testCase.equals(clazz.getName())) + .findAny() + .orElseThrow(() -> new NotFoundException("No such test: " + testCase)); return Collections.singletonList(specifiedTest); } @@ -302,23 +302,23 @@ public void addNavTrail(NavTree root) @RequiresSiteAdmin public static class Run2Action extends StatusReportingRunnableAction { - private List getTestClasses(TestForm form) + private List> getTestClasses(TestForm form) { - Map> allTestClasses = JunitManager.getTestCases(); + Map>> allTestClasses = JunitManager.getTestCases(); String module = form.getModule(); if (null != module) return JunitManager.getTestCases().get(module); - List testClasses = new LinkedList<>(); + List> testClasses = new LinkedList<>(); String testCase = form.getTestCase(); if (null == testCase || !testCase.isEmpty()) { for (String m : allTestClasses.keySet()) { - for (Class clazz : allTestClasses.get(m)) + for (Class clazz : allTestClasses.get(m)) { // include test if (null == testCase || testCase.equals(clazz.getName())) @@ -333,7 +333,7 @@ private List getTestClasses(TestForm form) @Override protected StatusReportingRunnable newStatusReportingRunnable() { - List testClasses = getTestClasses(new TestForm()); + List> testClasses = getTestClasses(new TestForm()); List results = new LinkedList<>(); return new JunitRunnable(testClasses, results, getViewContext().getRequest(), getUser()); } @@ -344,11 +344,11 @@ private static class JunitRunnable implements StatusReportingRunnable { private final StatusAppender _appender; private final Logger _log; - private final List _testClasses; + private final List> _testClasses; private final List _results; private volatile boolean _running = true; - private JunitRunnable(List testClasses, List results, HttpServletRequest request, User user) // TODO: Make this a Callable instead? + private JunitRunnable(List> testClasses, List results, HttpServletRequest request, User user) // TODO: Make this a Callable instead? { _testClasses = testClasses; _results = results; @@ -395,14 +395,14 @@ public static class Testlist extends ReadOnlyApiAction @Override public ApiResponse execute(Object o, BindException errors) { - Map> testCases = JunitManager.getTestCases(); + Map>> testCases = JunitManager.getTestCases(); Map>> values = new HashMap<>(); for (String module : testCases.keySet()) { List> tests = new ArrayList<>(); values.put("Remote " + module, tests); - for (Class clazz : testCases.get(module)) + for (Class clazz : testCases.get(module)) { int timeout = TestTimeout.DEFAULT; // Check if the test has requested a non-standard timeout @@ -545,13 +545,13 @@ public void setWhen(String when) } - private static Class findTestClass(String testCase) + private static Class findTestClass(String testCase) { - Map> testCases = JunitManager.getTestCases(); + Map>> testCases = JunitManager.getTestCases(); for (String module : testCases.keySet()) { - for (Class clazz : testCases.get(module)) + for (Class clazz : testCases.get(module)) { if (null == testCase || testCase.equals(clazz.getName())) return clazz; diff --git a/core/src/org/labkey/core/junit/JunitManager.java b/core/src/org/labkey/core/junit/JunitManager.java index 8a8801a6271..4d71cd670f0 100644 --- a/core/src/org/labkey/core/junit/JunitManager.java +++ b/core/src/org/labkey/core/junit/JunitManager.java @@ -35,20 +35,20 @@ */ public class JunitManager { - public static synchronized Map> getTestCases() + public static synchronized Map>> getTestCases() { - Map> testCases = new TreeMap<>(); + Map>> testCases = new TreeMap<>(); for (Module module : ModuleLoader.getInstance().getModules()) { - Set moduleClazzes = new HashSet<>(); + Set> moduleClazzes = new HashSet<>(); module.getIntegrationTestFactories().forEach(f -> moduleClazzes.add(f.get())); moduleClazzes.addAll(module.getUnitTests()); if (!moduleClazzes.isEmpty()) { - List moduleClazzList = new ArrayList<>(moduleClazzes); + List> moduleClazzList = new ArrayList<>(moduleClazzes); moduleClazzList.sort(Comparator.comparing(Class::getName)); testCases.put(module.getName(), Collections.unmodifiableList(moduleClazzList)); diff --git a/devtools/src/org/labkey/devtools/DevtoolsModule.java b/devtools/src/org/labkey/devtools/DevtoolsModule.java index 342373df071..507cc890a31 100644 --- a/devtools/src/org/labkey/devtools/DevtoolsModule.java +++ b/devtools/src/org/labkey/devtools/DevtoolsModule.java @@ -64,10 +64,13 @@ protected void init() addController("testsso", TestSsoController.class); AuthenticationManager.registerProvider(new TestSsoProvider()); - OptionalFeatureService.get().addExperimentalFeatureFlag(Domain.EXPERIMENTAL_FUZZ_STORAGE_NAME, + OptionalFeatureService.get().addExperimentalFeatureFlag( + Domain.EXPERIMENTAL_FUZZ_STORAGE_NAME, "'fuzz' name of database columns used to back domain properties", "This is dev/test feature and not intended for any production usage.", - false, true); + false, + true + ); } @Override @@ -87,7 +90,8 @@ public void doStartup(ModuleContext moduleContext) public @NotNull Set> getIntegrationTests() { return Set.of( - TestController.JsonInputLimitTest.class + TestController.JsonInputLimitTest.class, + ToolsController.TestCase.class ); } } \ No newline at end of file diff --git a/devtools/src/org/labkey/devtools/ToolsController.java b/devtools/src/org/labkey/devtools/ToolsController.java index b6971385135..37dd8ac97ea 100644 --- a/devtools/src/org/labkey/devtools/ToolsController.java +++ b/devtools/src/org/labkey/devtools/ToolsController.java @@ -1,15 +1,20 @@ package org.labkey.devtools; import org.apache.commons.collections4.MultiValuedMap; -import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; +import org.apache.commons.collections4.multimap.ArrayListValuedLinkedHashMap; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; import org.labkey.api.action.FormHandlerAction; import org.labkey.api.action.FormViewAction; import org.labkey.api.action.SimpleErrorView; import org.labkey.api.action.SimpleViewAction; import org.labkey.api.action.SpringActionController; +import org.labkey.api.cache.Cache; +import org.labkey.api.cache.CacheManager; import org.labkey.api.collections.ArrayListValuedTreeMap; import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.data.BaseColumnInfo; @@ -18,10 +23,12 @@ import org.labkey.api.data.DbSchemaType; import org.labkey.api.data.DbScope; import org.labkey.api.data.FileSqlScriptProvider; +import org.labkey.api.data.JdbcType; +import org.labkey.api.data.SQLFragment; import org.labkey.api.data.SchemaTableInfo; +import org.labkey.api.data.SqlSelector; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableInfo.IndexDefinition; -import org.labkey.api.data.TableInfo.IndexType; import org.labkey.api.data.dialect.SqlDialect; import org.labkey.api.module.Module; import org.labkey.api.module.ModuleLoader; @@ -29,6 +36,7 @@ import org.labkey.api.reader.Readers; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.test.TestWhen; import org.labkey.api.util.BaseScanner.Handler; import org.labkey.api.util.ButtonBuilder; import org.labkey.api.util.DOM; @@ -36,6 +44,7 @@ import org.labkey.api.util.Formats; import org.labkey.api.util.HtmlString; import org.labkey.api.util.HtmlStringBuilder; +import org.labkey.api.util.LinkBuilder; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.StringUtilsLabKey; import org.labkey.api.util.URLHelper; @@ -66,22 +75,29 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; 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.HashMap; import java.util.HashSet; +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.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.labkey.api.data.TableInfo.IndexType.NonUnique; +import static org.labkey.api.data.TableInfo.IndexType.Primary; +import static org.labkey.api.data.TableInfo.IndexType.Unique; import static org.labkey.api.util.DOM.Attribute.style; import static org.labkey.api.util.DOM.BR; import static org.labkey.api.util.DOM.DIV; @@ -105,7 +121,7 @@ public class BeginAction extends SimpleViewAction @Override public ModelAndView getView(Object o, BindException errors) { - return new ActionListView(ToolsController.this, actionDescriptor->BeginAction.class != actionDescriptor.getActionClass()); + return new ActionListView(ToolsController.this, actionDescriptor -> BeginAction.class != actionDescriptor.getActionClass()); } @Override @@ -163,7 +179,7 @@ public void handle(Path gaPath, Stream stream) { out.println("Files listed in " + gaPath + " that don't exist:\n"); List missing = getMissingFiles(gaPath, stream); - missing.forEach(filename->out.println(filter(filename))); + missing.forEach(filename -> out.println(filter(filename))); if (!missing.isEmpty()) { out.println(); @@ -380,14 +396,14 @@ protected void renderInternal(Object model, PrintWriter out) Set copyOfJspFiles = new HashSet<>(jspFiles); jspFiles.removeAll(jspReferences); - jspFiles.forEach(path->out.println(filter(path))); + jspFiles.forEach(path -> out.println(filter(path))); out.println(); out.println("JSP references that couldn't be resolved to JSP files [plus any candidates for resolution]:"); out.println(); jspReferences.removeAll(copyOfJspFiles); - jspReferences.forEach(path-> { + jspReferences.forEach(path -> { List candidates = jspFiles.stream() .filter(s -> s.endsWith(path)) .toList(); @@ -410,7 +426,7 @@ protected void renderInternal(Object model, PrintWriter out) out.println(); out.println("The following " + (jspFiles.size() == 1 ? "JSP file is a strong candidate" : jspFiles.size() + " JSP files are strong candidates") + " for removal:"); out.println(); - jspFiles.forEach(path->out.println(filter(path))); + jspFiles.forEach(path -> out.println(filter(path))); } out.println(""); @@ -461,7 +477,8 @@ private Collection findJspReferences(Module module, PrintWriter out) String code = PageFlowUtil.getFileContentsAsString(file.toFile()); JavaScanner scanner = new JavaScanner(code); - scanner.scan(0, new Handler(){ + scanner.scan(0, new Handler() + { @Override public boolean string(int beginIndex, int endIndex) { @@ -556,7 +573,7 @@ public ModelAndView getView(Object o, BindException errors) throws IOException List actionIds = new LinkedList<>(); - // As of now, Crawler.java and the study tests are the only classes that specify crawler actions + // As of now, these are the only classes that specify crawler actions for (String path : List.of( sourcePath + "/../../clientModules/adjudication/test/src/org/labkey/test/tests/adjudication/AdjudicationAbstractBaseTest.java", sourcePath + "/../../ehrModules/ehr/test/src/org/labkey/test/tests/ehr/ComplianceTrainingTest.java", @@ -603,7 +620,7 @@ public ModelAndView getView(Object o, BindException errors) throws IOException builder .append("The following " + (missingModuleActions.size() > 1 ? "actions' controllers" : "action's controller") + " could not be resolved to a module running in this deployment:") .unsafeAppend("

\n"); - missingModuleActions.forEach(id->builder.append(id.toString()).unsafeAppend("
\n")); + missingModuleActions.forEach(id -> builder.append(id.toString()).unsafeAppend("
\n")); builder.unsafeAppend("
\n"); builder.append("The associated module(s) might not support " + DbScope.getLabKeyScope().getDatabaseProductName() + "."); builder.unsafeAppend("

\n"); @@ -614,7 +631,7 @@ public ModelAndView getView(Object o, BindException errors) throws IOException builder .append("The following " + (missingActions.size() > 1 ? "actions were" : "action was") + " not found in the action's controller:") .unsafeAppend("

\n"); - missingActions.forEach(id->builder.append(id.toString()).unsafeAppend("
\n")); + missingActions.forEach(id -> builder.append(id.toString()).unsafeAppend("
\n")); } return new HtmlView(builder); @@ -730,25 +747,65 @@ public void addNavTrail(NavTree root) } } + public record OverlappingIndicesForm(String schemaName, Boolean clearCaches) {} + public record IndexChange(TableInfo table, IndexDefinition index, ChangeType type, String description) {} + public record IndexOverlap(TableInfo table, String description) {} + @RequiresPermission(AdminPermission.class) - public class OverlappingIndicesAction extends AbstractOverlappingIndicesAction + public class OverlappingIndicesAction extends FormViewAction { @Override - public ModelAndView getView(Object o, boolean reshow, BindException errors) + public ModelAndView getView(OverlappingIndicesForm form, boolean reshow, BindException errors) { - MultiValuedMap multiMap = getOverlappingIndices(); + ActionURL url = getViewContext().getActionURL().clone(); + + if (Boolean.TRUE.equals(form.clearCaches())) + { + CacheManager.clearAllKnownCaches(); + url.deleteParameter("clearCaches"); + } + + OverlappingIndicesAnalyzer analyzer = new OverlappingIndicesAnalyzer(); + MultiValuedMap allOverlaps = analyzer.getOverlaps(form.schemaName()); + MultiValuedMap changes = analyzer.getChanges(form.schemaName()); return new VBox( new HtmlView(DOM.createHtmlFragment( - Arrays.stream(OverlapType.values()).flatMap(type -> + DOM.H3("List of all overlapping indices"), + "Some overlapping indices are expected and legitimate, typically because a non-unique index has " + + "a longer column list than a unique index or primary key, a unique index has a shorter (more " + + "restrictive) column list than the primary key, or the indices have different filter conditions.", + BR(), + allOverlaps.keySet().stream().flatMap(schemaName -> Stream.of( - type != OverlapType.UniqueOverlappingNonUnique ? BR() : null, - DOM.STRONG(StringUtilsLabKey.pluralize(multiMap.get(type).size(), "index has ", "indices have ") + type.getDescription() + ":", BR()), + BR(), + DOM.STRONG("Schema ", LinkBuilder.simpleLink(schemaName, new ActionURL(OverlappingIndicesAction.class, getContainer()).addParameter("schemaName", schemaName)), ":", BR()), DOM.TABLE( - multiMap.get(type).stream() + allOverlaps.get(schemaName).stream() + .sorted(Comparator.comparing(change -> change.table().getName())) .map(overlap -> DOM.TR( - DOM.TD(at(style, "width:120px;"), overlap.schemaName()), - DOM.TD(type.getMessage(overlap)), + DOM.TD(at(style, "width:200px;"), overlap.table().getName()), + DOM.TD(overlap.description()), + "\n" + )) + ) + ) + ) + )), + new HtmlView(DOM.createHtmlFragment( + BR(), + DOM.H3("Total number of changes needed: " + changes.keys().size()), + BR(), + changes.keySet().stream().flatMap(schemaName -> + Stream.of( + BR(), + DOM.STRONG("Schema ", LinkBuilder.simpleLink(schemaName, new ActionURL(OverlappingIndicesAction.class, getContainer()).addParameter("schemaName", schemaName)), " needs " + StringUtilsLabKey.pluralize(changes.get(schemaName).size(), "change") + ":", BR()), + DOM.TABLE( + changes.get(schemaName).stream() + .sorted(Comparator.comparing(change -> change.table().getName())) + .map(change -> DOM.TR( + DOM.TD(at(style, "width:200px;"), change.table().getName()), + DOM.TD(change.description()), "\n" )) ) @@ -757,8 +814,10 @@ public ModelAndView getView(Object o, boolean reshow, BindException errors) )), new HtmlView(DOM.createHtmlFragment( BR(), - new ButtonBuilder("Create SQL Scripts That Drop Overlapping Indices").href(OverlappingIndicesAction.class, getContainer()).usePost()) - ) + changes.isEmpty() ? null : new ButtonBuilder("Create SQL Scripts That Drop Redundant Indices").href(url).usePost(), + " ", + new ButtonBuilder("Clear Caches and Refresh").href(url.addParameter("clearCaches", true)) + )) ); } @@ -770,25 +829,26 @@ public void addNavTrail(NavTree root) } @Override - public boolean handlePost(Object o, BindException errors) + public boolean handlePost(OverlappingIndicesForm form, BindException errors) { - MultiValuedMap multiMap = getOverlappingIndices(); + MultiValuedMap multiMap = new OverlappingIndicesAnalyzer().getChanges(form.schemaName()); try { - Arrays.stream(OverlapType.values()).forEach(type -> multiMap.get(type).forEach(overlap -> { - try - { - // All writers are closed below - WriterContext context = getWriterContext(overlap.schemaName()); - if (type.writeScript(context.getWriter(), overlap)) + multiMap.keySet() + .forEach(schemaName -> multiMap.get(schemaName).forEach(change -> { + try + { + // All writers are closed below in the finally + WriterContext context = getWriterContext(schemaName); + change.type().writeScript(context.getWriter(), change); context.setModified(); - } - catch (IOException e) - { - throw new RuntimeException(e); - } - })); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + })); } finally { @@ -798,6 +858,17 @@ public boolean handlePost(Object o, BindException errors) return true; } + @Override + public void validateCommand(OverlappingIndicesForm form, Errors errors) + { + } + + @Override + public URLHelper getSuccessURL(OverlappingIndicesForm form) + { + return new ActionURL(OverlappingIndicesAction.class, getContainer()); + } + private static class WriterContext { private final File _scriptDirectory; @@ -849,7 +920,7 @@ public void close() throws IOException private WriterContext getWriterContext(String schemaName) throws IOException { - return _writerContextMap.computeIfAbsent(schemaName, n -> { + return _writerContextMap.computeIfAbsent(schemaName, _ -> { DbSchema schema = DbSchema.get(schemaName, DbSchemaType.Module); Module module = schema.getModule(); @@ -894,77 +965,148 @@ private void closeAllContexts() } }); } - - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return new ActionURL(BeginAction.class, getContainer()); - } } - protected static abstract class AbstractOverlappingIndicesAction extends FormViewAction + private static class OverlappingIndicesAnalyzer { - protected MultiValuedMap getOverlappingIndices() - { - MultiValuedMap multiMap = new ArrayListValuedHashMap<>(); - DbScope scope = DbScope.getLabKeyScope(); + private final String delim = Character.toString(31); // Non-printing character that's very unlikely to be in a column name + private void enumerateTables(@Nullable String schemaName, Consumer tableConsumer) + { ModuleLoader.getInstance().getModules().stream() .flatMap(module -> module.getSchemaNames().stream().filter(name -> !module.getProvisionedSchemaNames().contains(name))) + .filter(name -> schemaName == null || name.equalsIgnoreCase(schemaName)) .sorted(String.CASE_INSENSITIVE_ORDER) - .map(name -> scope.getSchema(name, DbSchemaType.Module)) + .map(name -> DbScope.getLabKeyScope().getSchema(name, DbSchemaType.Module)) .flatMap(schema -> schema.getTableNames().stream().map(schema::getTable)) - .forEach(table -> { - var indices = table.getAllIndices(); - indices.forEach(indexDef1 -> indices.forEach(indexDef2 -> { - if (indexDef1 != indexDef2) - { - OverlapType type = overlap(indexDef1, indexDef2); + .forEach(tableConsumer); + } - if (type != null) - { - if (type != OverlapType.Identical || !alreadySeen(indexDef1.name(), indexDef2.name())) - multiMap.put(type, new Overlap(table.getSchema().getName(), table.getName(), indexDef1, indexDef2)); - } + private MultiValuedMap getChanges(@Nullable String schemaName) + { + MultiValuedMap multiMap = new ArrayListValuedLinkedHashMap<>(); + enumerateTables(schemaName, table -> + analyzeTable(table, new LinkedHashSet<>(table.getAllIndices())) + .forEach(change -> multiMap.put(change.table().getSchema().getName(), change))); + return multiMap; + } + + // Package-visible for testing. Pass null for table only in unit tests that don't inspect change.table(). + List analyzeTable(@Nullable TableInfo table, LinkedHashSet indices) + { + var changes = new LinkedList(); + Set droppedIndices = new HashSet<>(); + + // Step #1: Find the PK (if present), and drop non-unique indices whose columns are a prefix of the + // PK, plus unique indices that cover the exact same column set as the PK. A unique index with FEWER + // columns than the PK enforces a strictly stronger uniqueness guarantee (the PK cannot replace it), + // so it is left for Step #2 to evaluate. + indices.stream() + .filter(ix -> ix.indexType() == Primary) + .findFirst() + .ifPresent(pk -> indices.stream() + .filter(index -> + (index.indexType() == NonUnique && isOverlap(pk, index)) || // Non-unique indices smaller or equal to PK + (index.indexType() == Unique && isOverlap(pk, index) && index.columns().size() == pk.columns().size())) // Unique indices with column list exactly matching PK's + .forEach(index -> { + changes.add(new IndexChange(table, index, ChangeType.Drop, getDropDescription(index, pk))); + droppedIndices.add(index); + }) + ); + + Set convertedUniqueIndices = new HashSet<>(); + + // Step #2: For each unique index, switch it to a non-unique index if there's any UQ or PK that + // overlaps with a smaller or equal column set. + streamIndices(indices, droppedIndices) + .filter(index -> index.indexType() == Unique) + .forEach(uq -> streamIndices(indices, droppedIndices) + .filter(index -> index.indexType() == Primary || index.indexType() == Unique) + .filter(index -> !convertedUniqueIndices.contains(index)) + .filter(index -> isOverlap(uq, index)) + .findFirst() + .ifPresent(index -> { + changes.add(new IndexChange(table, uq, ChangeType.Convert, String.format("Converting %s from unique to non-unique index because %s overlaps it with a smaller column set", uq.display(), index.display()))); + convertedUniqueIndices.add(uq); + }) + ); + + // Step #3: For each index (unique or non-unique), delete all other non-unique indices that overlap + // with a smaller or equal column set. + streamIndices(indices, droppedIndices) + .filter(index -> index.indexType() != Primary) + .forEach(index -> streamIndices(indices, droppedIndices) + .filter(ix -> ix.indexType() == NonUnique || convertedUniqueIndices.contains(ix)) + .filter(ix -> isOverlap(index, ix)) + .forEach(ix -> { + String description = getDropDescription(ix, index); + if (convertedUniqueIndices.contains(ix)) + { + // Index was converted to non-unique but now needs to be dropped. Adjust changes, description, etc. + IndexChange convert = changes.stream() + .filter(change -> change.index().equals(ix)) + .findFirst() + .orElseThrow(); + description = description + (!description.endsWith(".") ? "." : "") + " Prior to this drop, the index was converted: " + convert.description(); + changes.remove(convert); + convertedUniqueIndices.remove(ix); } - })); - }); + changes.add(new IndexChange(table, ix, ChangeType.Drop, description)); + droppedIndices.add(ix); + }) + ); + + return changes; + } + + private MultiValuedMap getOverlaps(@Nullable String schemaName) + { + MultiValuedMap multiMap = new ArrayListValuedLinkedHashMap<>(); + + enumerateTables(schemaName, table -> { + var indices = table.getAllIndices(); + indices.forEach(index1 -> indices.stream() + .filter(index2 -> isSimpleOverlap(index1, index2)) + .forEach(index2 -> multiMap.put(table.getSchema().getName(), new IndexOverlap(table, String.format("%s overlaps with %s", index2.display(), index1.display())))) + ); + }); return multiMap; } - private final Set _alreadySeen = new HashSet<>(); + // Helper that filters out the dropped indices + private Stream streamIndices(Set indices, Set droppedIndices) + { + return indices.stream().filter(index -> !droppedIndices.contains(index)); + } - // Keep track of the identical indexes we've seen so we don't repeat them for both directions - private boolean alreadySeen(String name1, String name2) + private String getDropDescription(IndexDefinition dropIndex, IndexDefinition otherIndex) { - String key = name1.compareTo(name2) < 0 ? name1 + delim + name2 : name2 + delim + name1; - return !_alreadySeen.add(key); + String warning = dropIndex.indexType() == otherIndex.indexType() && dropIndex.columns().size() == otherIndex.columns().size() ? + ". Note: You may want to drop " + otherIndex.display() + " instead." : ""; + return String.format("Dropping %s because it overlaps with %s", dropIndex.display(), otherIndex.display()) + warning; } - private @Nullable OverlapType overlap(IndexDefinition index1, IndexDefinition index2) + // Returns true if index2 has an overlapping column set that's equal to or smaller than index1's + private boolean isSimpleOverlap(IndexDefinition index1, IndexDefinition index2) { - String key1 = getKey(index1.columns()); - String key2 = getKey(index2.columns()); - boolean sameFilterConditions = Objects.equals(index1.filterCondition(), index2.filterCondition()); - if (key1.equals(key2)) - return sameFilterConditions ? OverlapType.Identical : OverlapType.OverlappingWithDifferentFilter; - if (key2.startsWith(key1)) + boolean ret = false; + + if (!index1.equals(index2)) { - if (index2.indexType() == IndexType.NonUnique && (index1.indexType() == IndexType.Primary || index1.indexType() == IndexType.Unique)) - return OverlapType.UniqueOverlappingNonUnique; - else - return sameFilterConditions ? OverlapType.Overlapping : OverlapType.OverlappingWithDifferentFilter; + String key1 = getKey(index1.columns()); + String key2 = getKey(index2.columns()); + ret = key1.startsWith(key2); } - return null; + + return ret; } - private final String delim = Character.toString(31); // Non-printing character that's very unlikely to be in a column name + // Returns true if index2 overlaps index1, and they have the same filter condition + private boolean isOverlap(IndexDefinition index1, IndexDefinition index2) + { + return Objects.equals(index1.filterCondition(), index2.filterCondition()) && isSimpleOverlap(index1, index2); + } private String getKey(List cols) { @@ -972,135 +1114,232 @@ private String getKey(List cols) .map(col -> col.getName().toLowerCase()) .collect(Collectors.joining(delim)) + delim; } + } - private List join(List cols) + @TestWhen(TestWhen.When.BVT) + public static class TestCase extends Assert + { + @Test + public void testOverlappingIndices() { - return cols.stream() - .map(ColumnInfo::getName) - .toList(); + Assume.assumeTrue("Skipping because this server is not running on PostgreSQL", DbScope.getLabKeyScope().getSqlDialect().isPostgreSQL()); + var map = new OverlappingIndicesAnalyzer().getChanges(null); + var keys = map.keys(); + if (!keys.isEmpty()) + fail(StringUtilsLabKey.pluralize(keys.size(), "redundant index", "redundant indices") + " detected: " + map); } - } - protected record Overlap(String schemaName, String tableName, IndexDefinition indexDef1, IndexDefinition indexDef2) {} - - protected enum OverlapType - { - UniqueOverlappingNonUnique("a column list that overlaps another index's column list at the start, but the first index is a unique constraint. These are likely valid") + // Helper to build an IndexDefinition with named columns in order + private static IndexDefinition idx(String name, TableInfo.IndexType type, String... columnNames) { - @Override - boolean writeScript(Writer writer, Overlap overlap) - { - return false; // Write nothing - } - }, - OverlappingWithDifferentFilter("a column list that overlaps another index's column list at the start, but with different filter conditions. These are likely valid") + var cols = Arrays.stream(columnNames) + .map(n -> (ColumnInfo) new BaseColumnInfo(n, JdbcType.VARCHAR)) + .collect(Collectors.toCollection(ArrayList::new)); + return new IndexDefinition(name, type, cols, null); + } + + private static List analyze(IndexDefinition... indexDefs) { - @Override - boolean writeScript(Writer writer, Overlap overlap) - { - return false; // Write nothing - } - }, - Identical("a column list that's identical to another index's column list") + return new OverlappingIndicesAnalyzer().analyzeTable(null, new LinkedHashSet<>(Arrays.asList(indexDefs))); + } + + @Test + public void testNonUniqueIndexIsRedundantWithPk() { - @Override - boolean writeScript(Writer writer, Overlap overlap) throws IOException - { - IndexType type1 = overlap.indexDef1.indexType(); - IndexType type2 = overlap.indexDef2.indexType(); - String dropIndex = null; - String otherIndex = null; + // A non-unique index on (A) is redundant when the PK is on (A, B): the PK B-tree satisfies + // all the same prefix queries. This should be dropped. + var pk = idx("pk_ab", Primary, "A", "B"); + var ix = idx("ix_a", NonUnique, "A"); - // Prefer to drop the non-PK, then prefer the non-unique, otherwise "drop" them both (let the human decide) - if (type1 == IndexType.Primary) - { - dropIndex = overlap.indexDef2.name(); - otherIndex = overlap.indexDef1.name(); - } - else if (type2 == IndexType.Primary) - { - dropIndex = overlap.indexDef1.name(); - otherIndex = overlap.indexDef2.name(); - } - else if (type1 == IndexType.Unique && type2 == IndexType.NonUnique) - { - dropIndex = overlap.indexDef2.name(); - otherIndex = overlap.indexDef1.name(); - } - else if (type2 == IndexType.Unique && type1 == IndexType.NonUnique) - { - dropIndex = overlap.indexDef1.name(); - otherIndex = overlap.indexDef2.name(); - } + var changes = analyze(pk, ix); - if (dropIndex != null) - { - dropIndex(writer, overlap.schemaName, overlap.tableName, dropIndex, otherIndex); - } - else - { - writer.write("TODO: Human, please help!! You should drop only one of the following, but I couldn't decide which one:\n"); - dropIndex(writer, overlap.schemaName, overlap.tableName, overlap.indexDef1.name(), overlap.indexDef2.name()); - dropIndex(writer, overlap.schemaName, overlap.tableName, overlap.indexDef2.name(), overlap.indexDef1.name()); - writer.write('\n'); - } + boolean ixDropped = changes.stream().anyMatch(c -> c.index().equals(ix) && c.type() == ChangeType.Drop); + assertTrue("ix_a (non-unique on A) should be dropped when PK is on (A, B)", ixDropped); + } - return true; - } + @Test + public void testIdenticalUniqueIndexIsRedundantWithPk() + { + // A unique index on exactly the same columns as the PK is a true duplicate: the PK already + // enforces the same uniqueness guarantee, so the separate index should be removed. + var pk = idx("pk_ab", Primary, "A", "B"); + var uq = idx("uq_ab", Unique, "A", "B"); - @Override - String getMessage(Overlap overlap) - { - return overlap.indexDef1.name() + " vs. " + overlap.indexDef2.name() + ": " + join(overlap.indexDef1.columns()); - } - }, - Overlapping("a column list that overlaps another index's column list at the start") + var changes = analyze(pk, uq); + + boolean uqActedOn = changes.stream() + .anyMatch(c -> c.index().equals(uq) && (c.type() == ChangeType.Drop || c.type() == ChangeType.Convert)); + assertTrue("uq_ab (unique on A, B) should be dropped or converted when PK is also on (A, B)", uqActedOn); + } + + @Test + public void testUniqueNotDroppedByLongerPk() { - @Override - boolean writeScript(Writer writer, Overlap overlap) throws IOException - { - dropIndex(writer, overlap.schemaName, overlap.tableName, overlap.indexDef1.name(), overlap.indexDef2.name()); - return true; - } - }; + // PK(A,B,C) allows rows (A=1,B=1,C=1) and (A=1,B=1,C=2); Unique(A,B) does not. + // Dropping it would silently relax the uniqueness guarantee. + var pk = idx("pk_abc", Primary, "A", "B", "C"); + var uq = idx("uq_ab", Unique, "A", "B"); + + var changes = analyze(pk, uq); - private final String _description; + boolean uqDropped = changes.stream().anyMatch(c -> c.index().equals(uq) && c.type() == ChangeType.Drop); + assertFalse("uq_ab (unique on A,B) must not be dropped when pk is on (A,B,C). Changes: " + changes, uqDropped); + } - OverlapType(String description) + @Test + public void testStep2UniqueConvertedWhenPrefixUniqueExists() { - _description = description; + // If Unique(A) exists, the (A,B) pair is already guaranteed unique by the A constraint alone, + // so Unique(A,B) provides no additional uniqueness and should be converted to non-unique. + // The narrower Unique(A) must not be touched. + var uqA = idx("uq_a", Unique, "A"); + var uqAB = idx("uq_ab", Unique, "A", "B"); + + var changes = analyze(uqA, uqAB); + + boolean uqABConverted = changes.stream().anyMatch(c -> c.index().equals(uqAB) && c.type() == ChangeType.Convert); + boolean uqAConverted = changes.stream().anyMatch(c -> c.index().equals(uqA) && c.type() == ChangeType.Convert); + assertTrue("uq_ab (unique on A,B) should be converted when uq_a (unique on A) exists. Changes: " + changes, uqABConverted); + assertFalse("uq_a (unique on A) should not be converted. Changes: " + changes, uqAConverted); } - public String getDescription() + @Test + public void testStep3NonUniqueDroppedByWiderNonUnique() { - return _description; + // A B-tree index on (A,B,C) can serve all prefix queries on (A,B), making a separate + // NonUnique(A,B) index redundant. The narrower one should be dropped; the wider one kept. + var ixABC = idx("ix_abc", NonUnique, "A", "B", "C"); + var ixAB = idx("ix_ab", NonUnique, "A", "B"); + + var changes = analyze(ixABC, ixAB); + + boolean ixABDropped = changes.stream().anyMatch(c -> c.index().equals(ixAB) && c.type() == ChangeType.Drop); + boolean ixABCDropped = changes.stream().anyMatch(c -> c.index().equals(ixABC) && c.type() == ChangeType.Drop); + assertTrue("ix_ab (non-unique on A,B) should be dropped when ix_abc (non-unique on A,B,C) exists. Changes: " + changes, ixABDropped); + assertFalse("ix_abc (non-unique on A,B,C) should not be dropped. Changes: " + changes, ixABCDropped); } - // Return true if content has been written to the script file - abstract boolean writeScript(Writer writer, Overlap overlap) throws IOException; + @Test + public void testStep2And3ConvertThenDrop() + { + // Step 2 converts Unique(A,B) because Unique(A) makes its uniqueness redundant. + // Step 3 then drops the (now non-unique) Unique(A,B) because NonUnique(A,B,C) covers it. + // The final changes list must show a single Drop for uq_ab — no separate Convert entry. + var uqA = idx("uq_a", Unique, "A"); + var uqAB = idx("uq_ab", Unique, "A", "B"); + var ixABC = idx("ix_abc", NonUnique, "A", "B", "C"); + + var changes = analyze(uqA, uqAB, ixABC); + + boolean uqABDropped = changes.stream().anyMatch(c -> c.index().equals(uqAB) && c.type() == ChangeType.Drop); + boolean uqABConverted = changes.stream().anyMatch(c -> c.index().equals(uqAB) && c.type() == ChangeType.Convert); + assertTrue("uq_ab should appear as Drop (convert folded in). Changes: " + changes, uqABDropped); + assertFalse("uq_ab should not have a separate Convert entry. Changes: " + changes, uqABConverted); + } - String getMessage(Overlap overlap) + @Test + public void testNoChangesForDisjointIndices() { - return overlap.indexDef1.name() + " " + join(overlap.indexDef1.columns()) + " vs. " + overlap.indexDef2.name() + " " + join(overlap.indexDef2.columns()); + // Indices on completely different columns have no prefix relationship; nothing should change. + var ixA = idx("ix_a", NonUnique, "A"); + var ixB = idx("ix_b", NonUnique, "B"); + var uqC = idx("uq_c", Unique, "C"); + + var changes = analyze(ixA, ixB, uqC); + + assertTrue("Disjoint indices should produce no changes. Changes: " + changes, changes.isEmpty()); } - protected List join(List cols) + @Test + public void testFilteredIndexNotDroppedByFullIndex() { - return cols.stream() - .map(ColumnInfo::getName) - .toList(); + // A partial (filtered) index covers only a subset of rows. Even when its column set is a + // prefix of the PK, the filter condition means the two indices are not interchangeable. + var pk = idx("pk_ab", Primary, "A", "B"); + var cols = Arrays.stream(new String[]{"A"}) + .map(n -> (ColumnInfo) new BaseColumnInfo(n, JdbcType.VARCHAR)) + .collect(Collectors.toCollection(ArrayList::new)); + var filteredIx = new IndexDefinition("ix_a_partial", NonUnique, cols, "active = 1"); + + var changes = analyze(pk, filteredIx); + + boolean filteredDropped = changes.stream().anyMatch(c -> c.index().equals(filteredIx) && c.type() == ChangeType.Drop); + assertFalse( + "ix_a_partial (filtered non-unique on A) must not be dropped by pk_ab: different filter conditions. Changes: " + changes, + filteredDropped); } + } - protected void dropIndex(Writer writer, String schemaName, String tableName, String dropIndex, String otherIndex) throws IOException + private enum ChangeType + { + Drop { - SqlDialect dialect = DbScope.getLabKeyScope().getSqlDialect(); - writer.write("-- This index overlaps with " + otherIndex + "\n"); + @Override + void writeScript(Writer writer, IndexChange change, String schemaName, String tableName, IndexDefinition dropIndex) throws IOException + { + writer.write("-- " + change.description() + "\n"); - if (dialect.isPostgreSQL()) - writer.write("DROP INDEX " + schemaName + "." + dropIndex + ";\n"); - else - writer.write("DROP INDEX " + dropIndex + " ON " + schemaName + "." + tableName + ";\n"); + if (DbScope.getLabKeyScope().getSqlDialect().isPostgreSQL()) + { + if (dropIndex.indexType() == Unique) + { + String constraintName = getConstraintForIndex(schemaName, dropIndex.name()); + if (constraintName != null) + { + writer.write("ALTER TABLE " + schemaName + "." + tableName + " DROP CONSTRAINT " + constraintName + ";\n"); + return; + } + } + + writer.write("DROP INDEX " + schemaName + "." + dropIndex.name() + ";\n"); + } + else + writer.write("DROP INDEX " + dropIndex.name() + " ON " + schemaName + "." + tableName + ";\n"); + } + }, + Convert + { + @Override + void writeScript(Writer writer, IndexChange change, String schemaName, String tableName, IndexDefinition changeIndex) throws IOException + { + Drop.writeScript(writer, change); + String indexName = changeIndex.name().replaceFirst("^uq", "ix").replaceFirst("^unique", "index"); + writer.write("CREATE INDEX " + indexName + " ON " + schemaName + "." + tableName + "(" + changeIndex.columns().stream().map(ColumnInfo::getName).collect(Collectors.joining(", ")) + ");\n"); + } + }; + + final void writeScript(Writer writer, IndexChange change) throws IOException + { + TableInfo table = change.table(); + IndexDefinition index = change.index(); + + if (index.indexType() == Primary) + throw new IllegalStateException("Should not modify a PK! (" + change + ")"); + + writeScript(writer, change, table.getSchema().getName(), table.getName(), index); } + + abstract void writeScript(Writer writer, IndexChange change, String schemaName, String tableName, IndexDefinition index) throws IOException; + } + + private record IndexKey(String schemaName, String indexName) {} + + // If this is a unique index associated with a constraint, return that constraint name. Otherwise, return null. + // Most, but not all, unique indices are created by adding a unique constraint; in those cases, we need to drop + // the associated constraint. However, for explicitly created unique indices, we need to drop the index instead. + private static @Nullable String getConstraintForIndex(String schemaName, String indexName) + { + Cache> sharedCache = CacheManager.getSharedCache(); + var constraintMap = sharedCache.get(OverlappingIndicesAction.class.getName() + "/ConstraintForIndexMap", null, (_, _) -> Collections.unmodifiableMap( + new SqlSelector(DbScope.getLabKeyScope(), new SQLFragment(""" + SELECT NspName AS SchemaName, RelName AS IndexName, ConName AS ConstraintName FROM pg_index i + INNER JOIN pg_class cl ON cl.oid = i.indexrelid + INNER JOIN pg_namespace schema ON schema.oid = cl.relnamespace + INNER JOIN pg_constraint c ON ConNamespace = schema.oid AND ConIndId = cl.oid AND ConType = 'u' + WHERE IndIsUnique AND NOT NspName IN ('pg_toast', 'pg_catalog')""" + )).mapStream() + .collect(Collectors.toMap(map -> new IndexKey((String)map.get("SchemaName"), (String)map.get("IndexName")), map -> (String)map.get("ConstraintName"))))); + return constraintMap.get(new IndexKey(schemaName, indexName)); } @RequiresPermission(AdminPermission.class)