diff --git a/api/src/org/labkey/api/action/BaseViewAction.java b/api/src/org/labkey/api/action/BaseViewAction.java index dfce3258d4f..f41e3523344 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; @@ -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..c41895bd084 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,23 @@ 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); + } + + // Very simple binding for Java records: no support for binding errors, arrays, lists, disallowed fields, etc. + private BindException defaultBindParametersToRecord(Class recordClass, PropertyValues m) + { + 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/devtools/src/org/labkey/devtools/DevtoolsModule.java b/devtools/src/org/labkey/devtools/DevtoolsModule.java index b918e6f01a7..dcedc1b7df3 100644 --- a/devtools/src/org/labkey/devtools/DevtoolsModule.java +++ b/devtools/src/org/labkey/devtools/DevtoolsModule.java @@ -61,10 +61,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 diff --git a/devtools/src/org/labkey/devtools/ToolsController.java b/devtools/src/org/labkey/devtools/ToolsController.java index b6971385135..3d8f781d95f 100644 --- a/devtools/src/org/labkey/devtools/ToolsController.java +++ b/devtools/src/org/labkey/devtools/ToolsController.java @@ -10,6 +10,8 @@ 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,7 +20,9 @@ import org.labkey.api.data.DbSchemaType; import org.labkey.api.data.DbScope; import org.labkey.api.data.FileSqlScriptProvider; +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; @@ -36,6 +40,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; @@ -68,6 +73,7 @@ import java.sql.SQLException; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; @@ -105,7 +111,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 +169,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 +386,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 +416,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 +467,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 +563,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 +610,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 +621,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); @@ -734,9 +741,9 @@ public void addNavTrail(NavTree root) public class OverlappingIndicesAction extends AbstractOverlappingIndicesAction { @Override - public ModelAndView getView(Object o, boolean reshow, BindException errors) + public ModelAndView getView(OverlappingIndicesForm form, boolean reshow, BindException errors) { - MultiValuedMap multiMap = getOverlappingIndices(); + MultiValuedMap multiMap = getOverlappingIndices(form); return new VBox( new HtmlView(DOM.createHtmlFragment( @@ -747,7 +754,7 @@ public ModelAndView getView(Object o, boolean reshow, BindException errors) DOM.TABLE( multiMap.get(type).stream() .map(overlap -> DOM.TR( - DOM.TD(at(style, "width:120px;"), overlap.schemaName()), + DOM.TD(at(style, "width:120px;"), LinkBuilder.simpleLink(overlap.schemaName(), new ActionURL(OverlappingIndicesAction.class, getContainer()).addParameter("schemaName", overlap.schemaName()))), DOM.TD(type.getMessage(overlap)), "\n" )) @@ -757,7 +764,7 @@ 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()) + new ButtonBuilder("Create SQL Scripts That Drop Overlapping Indices").href(getViewContext().getActionURL()).usePost()) ) ); } @@ -770,9 +777,9 @@ 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 = getOverlappingIndices(form); try { @@ -896,26 +903,29 @@ private void closeAllContexts() } @Override - public void validateCommand(Object target, Errors errors) + public void validateCommand(OverlappingIndicesForm form, Errors errors) { } @Override - public URLHelper getSuccessURL(Object o) + public URLHelper getSuccessURL(OverlappingIndicesForm form) { return new ActionURL(BeginAction.class, getContainer()); } } - protected static abstract class AbstractOverlappingIndicesAction extends FormViewAction + public record OverlappingIndicesForm(String schemaName) {} + + protected static abstract class AbstractOverlappingIndicesAction extends FormViewAction { - protected MultiValuedMap getOverlappingIndices() + protected MultiValuedMap getOverlappingIndices(OverlappingIndicesForm form) { MultiValuedMap multiMap = new ArrayListValuedHashMap<>(); DbScope scope = DbScope.getLabKeyScope(); ModuleLoader.getInstance().getModules().stream() .flatMap(module -> module.getSchemaNames().stream().filter(name -> !module.getProvisionedSchemaNames().contains(name))) + .filter(schemaName -> form.schemaName() == null || schemaName.equals(form.schemaName())) .sorted(String.CASE_INSENSITIVE_ORDER) .map(name -> scope.getSchema(name, DbSchemaType.Module)) .flatMap(schema -> schema.getTableNames().stream().map(schema::getTable)) @@ -972,13 +982,6 @@ private String getKey(List cols) .map(col -> col.getName().toLowerCase()) .collect(Collectors.joining(delim)) + delim; } - - private List join(List cols) - { - return cols.stream() - .map(ColumnInfo::getName) - .toList(); - } } protected record Overlap(String schemaName, String tableName, IndexDefinition indexDef1, IndexDefinition indexDef2) {} @@ -1103,6 +1106,26 @@ protected void dropIndex(Writer writer, String schemaName, String tableName, Str } } + private record IndexKey(String schemaName, String indexName) {} + + // Most, but not all, unique indexes are created by adding a unique constraint; in those cases, we need to drop + // the associated constraint. However, for explicitly created unique indexes, we need to drop the index instead. + // If this is a unique index associated with a constraint, return that constraint name. Otherwise, return null. + private static @Nullable String getConstraintForIndex(String schemaName, String indexName) + { + Cache> sharedCache = CacheManager.getSharedCache(); + var constraintMap = sharedCache.get("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) public class ForeignKeysAction extends SimpleViewAction {