Skip to content

Commit 1abb24e

Browse files
authored
feat: DH-10139: Introduce structured errors for input table updates. (#7113)
1 parent aacf1b4 commit 1abb24e

File tree

18 files changed

+1018
-97
lines changed

18 files changed

+1018
-97
lines changed

engine/table/src/main/java/io/deephaven/engine/util/input/InputTableUpdater.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.deephaven.engine.table.ColumnDefinition;
88
import io.deephaven.engine.table.Table;
99
import io.deephaven.engine.table.TableDefinition;
10+
import org.jetbrains.annotations.Nullable;
1011

1112
import java.io.IOException;
1213
import java.util.List;
@@ -54,6 +55,19 @@ default List<String> getValueNames() {
5455
.collect(Collectors.toList());
5556
}
5657

58+
/**
59+
* If there are client-side defined restrictions on this column; return them as a JSON string to be interpreted by
60+
* the client for properly displaying the edit field.
61+
*
62+
* @param columnName the column name to query
63+
* @return a string representing the restrictions for this column, or null if no client-side restrictions are
64+
* supplied for this column
65+
*/
66+
@Nullable
67+
default String getColumnRestrictions(final String columnName) {
68+
return null;
69+
};
70+
5771
/**
5872
* Get the underlying Table definition (which includes the names and types of all of the columns).
5973
*
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
//
2+
// Copyright (c) 2016-2025 Deephaven Data Labs and Patent Pending
3+
//
4+
package io.deephaven.engine.util.input;
5+
6+
import io.deephaven.UncheckedDeephavenException;
7+
import org.jetbrains.annotations.NotNull;
8+
9+
import java.util.*;
10+
import java.util.stream.Collectors;
11+
12+
/**
13+
* Thrown when an InputTableUpdater detects an invalid modification.
14+
*
15+
* <p>
16+
* If an InputTableUpdater detects an invalid modification, by throwing this Exception a detailed error message can be
17+
* passed back to the gRPC client that initiated the operation. Using a list of {@link StructuredError structured
18+
* errors}, the client can indicate to the user which of the cells (if known) were invalid.
19+
* </p>
20+
*/
21+
public class InputTableValidationException extends UncheckedDeephavenException {
22+
/**
23+
* An error indicating that invalid data was entered; with an optional row position and column name.
24+
*
25+
* <p>
26+
* Implementers of {@link InputTableUpdater} may use {@link StructuredErrorImpl} as an implementation.
27+
* </p>
28+
* .
29+
*/
30+
public interface StructuredError {
31+
/**
32+
* @return the row position in the table to add that was invalid; or {@link OptionalLong#empty() empty} if
33+
* unknown.
34+
*/
35+
OptionalLong getRow();
36+
37+
/*
38+
* @return the column name in the table to add that was invalid; or {@link Optional#empty() empty} if unknown.
39+
*/
40+
Optional<String> getColumn();
41+
42+
/**
43+
* @return the error message for this validation failure.
44+
*/
45+
String getMessage();
46+
}
47+
48+
private final List<StructuredError> errors;
49+
50+
/**
51+
* Create an InputTableValidationException with the given message.
52+
*
53+
* <p>
54+
* No structured errors are supplied.
55+
* </p>
56+
*
57+
* @param message the error message
58+
*/
59+
public InputTableValidationException(final String message) {
60+
super(message);
61+
errors = Collections.emptyList();
62+
}
63+
64+
/**
65+
* Create an InputTableValidationException with the given message.
66+
*
67+
* @param message the error message
68+
* @param errors the structured errors for reporting to callers
69+
*/
70+
public InputTableValidationException(final String message, final List<StructuredError> errors) {
71+
super(message);
72+
this.errors = errors;
73+
}
74+
75+
/**
76+
* Create an InputTableValidationException with the given errors.
77+
*
78+
* <p>
79+
* The exception message is generated from the list of errors, attempting to provide a user-actionable summary for
80+
* callers that do not interpret structured errors.
81+
* </p>
82+
*
83+
* @param errors the structured errors for reporting to callers
84+
*/
85+
public InputTableValidationException(final List<StructuredError> errors) {
86+
this(generateMessage(errors), errors);
87+
}
88+
89+
private static String generateMessage(final List<StructuredError> errors) {
90+
if (errors.isEmpty()) {
91+
return "Unknown InputTable validation error.";
92+
}
93+
final StringBuilder message = new StringBuilder();
94+
if (errors.size() == 1) {
95+
final StructuredError firstError = errors.get(0);
96+
message.append("Input Table validation error");
97+
return formatOneMessage(message, firstError).toString();
98+
}
99+
final Map<String, List<StructuredError>> errorMap = errors.stream()
100+
.collect(Collectors.groupingBy(StructuredError::getMessage));
101+
if (errorMap.size() == 1) {
102+
final List<StructuredError> errs = errorMap.values().iterator().next();
103+
return combineSimilarErrors(errors, message, errs).toString();
104+
}
105+
106+
return formatOneMessage(message.append(errors.size()).append(" validation errors occurred. First error "),
107+
errors.get(0)).toString();
108+
}
109+
110+
private static StringBuilder combineSimilarErrors(final List<StructuredError> errors, final StringBuilder message,
111+
final List<StructuredError> errs) {
112+
message.append("Input Table validation error occurred ").append(errors.size()).append(" times at ");
113+
errs.sort(Comparator.comparingLong((final StructuredError e) -> {
114+
if (e.getRow().isPresent()) {
115+
return e.getRow().getAsLong();
116+
} else {
117+
return -1;
118+
}
119+
}).thenComparing((final StructuredError e) -> {
120+
return e.getColumn().orElse("");
121+
}));
122+
final StructuredError firstError = errs.get(0);
123+
final boolean sameRow = errors.stream().allMatch(e -> e.getRow() == firstError.getRow());
124+
final boolean sameColumn = errors.stream().allMatch(e -> e.getColumn().equals(firstError.getColumn()));
125+
if (sameColumn) {
126+
if (sameRow) {
127+
// same for everything
128+
if (firstError.getRow().isEmpty()) {
129+
if (firstError.getColumn().isEmpty()) {
130+
message.append(" unknown location");
131+
} else {
132+
message.append(" column ").append(firstError.getColumn().get());
133+
}
134+
} else {
135+
message.append("row ").append(firstError.getRow().getAsLong());
136+
if (firstError.getColumn().isPresent()) {
137+
message.append(" column ").append(firstError.getColumn().get());
138+
}
139+
}
140+
} else {
141+
// different rows, same column
142+
message.append("rows ").append(errors.stream().filter(e -> e.getRow().isPresent())
143+
.map(e -> Long.toString(e.getRow().getAsLong())).collect(Collectors.joining(", ")));
144+
final long rowUnknown = errors.stream().filter(e -> e.getRow().isEmpty()).count();
145+
if (rowUnknown > 0) {
146+
message.append(" and ").append(rowUnknown).append(" others");
147+
}
148+
if (firstError.getColumn().isPresent()) {
149+
message.append(" column ").append(firstError.getColumn().get());
150+
}
151+
}
152+
} else {
153+
if (sameRow) {
154+
// same row, different columns
155+
if (firstError.getRow().isPresent()) {
156+
message.append("row ").append(firstError.getRow().getAsLong());
157+
}
158+
message.append("columns ").append(errors.stream().filter(e -> e.getColumn().isPresent())
159+
.map(e -> e.getColumn().get()).collect(Collectors.joining(", ")));
160+
final long columnUnknown = errors.stream().filter(e -> e.getColumn().isEmpty()).count();
161+
if (columnUnknown > 0) {
162+
message.append(" and ").append(columnUnknown).append(" others");
163+
}
164+
} else {
165+
// different everything
166+
message.append("rows x columns ")
167+
.append(errors.stream()
168+
.map(e -> (e.getRow().isPresent() ? Long.toString(e.getRow().getAsLong()) : "unknown")
169+
+ " x " + e.getColumn().orElse("unknown"))
170+
.collect(Collectors.joining(", ")));
171+
}
172+
}
173+
return message.append(": ").append(errs.get(0).getMessage());
174+
}
175+
176+
private static @NotNull StringBuilder formatOneMessage(final StringBuilder message,
177+
final StructuredError firstError) {
178+
if (firstError.getRow().isPresent()) {
179+
message.append(" at row ").append(firstError.getRow().getAsLong());
180+
if (firstError.getColumn().isPresent()) {
181+
message.append(" column ").append(firstError.getColumn().get());
182+
}
183+
} else if (firstError.getColumn().isPresent()) {
184+
message.append(" at column ").append(firstError.getColumn().get());
185+
} else {
186+
message.append(" at unknown location");
187+
}
188+
message.append(": ").append(firstError.getMessage());
189+
return message;
190+
}
191+
192+
public Collection<StructuredError> getErrors() {
193+
return errors;
194+
}
195+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// Copyright (c) 2016-2025 Deephaven Data Labs and Patent Pending
3+
//
4+
package io.deephaven.engine.util.input;
5+
6+
import java.util.Optional;
7+
import java.util.OptionalLong;
8+
9+
/**
10+
* An implementation of {@link InputTableValidationException.StructuredError}.
11+
*/
12+
public class StructuredErrorImpl implements InputTableValidationException.StructuredError {
13+
private final String message;
14+
private final String column;
15+
private final long row;
16+
17+
/**
18+
* Create a StructuredError for the provided parameters
19+
*
20+
* @param message the error message
21+
* @param column the column, or null if unknown
22+
* @param row the row position, negative (canonically {@link io.deephaven.engine.rowset.RowSet#NULL_ROW_KEY}) if
23+
* unknown
24+
*/
25+
public StructuredErrorImpl(final String message, final String column, final long row) {
26+
this.message = message;
27+
this.column = column;
28+
this.row = row;
29+
}
30+
31+
@Override
32+
public OptionalLong getRow() {
33+
return row < 0 ? OptionalLong.empty() : OptionalLong.of(row);
34+
}
35+
36+
@Override
37+
public Optional<String> getColumn() {
38+
return Optional.ofNullable(column);
39+
}
40+
41+
@Override
42+
public String getMessage() {
43+
return message;
44+
}
45+
}

extensions/barrage/src/main/java/io/deephaven/extensions/barrage/util/BarrageUtil.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,10 @@ public static Stream<Field> columnDefinitionsToFields(
660660
if (inputTableUpdater.getValueNames().contains(name)) {
661661
putMetadata(metadata, "inputtable.isValue", TRUE_STRING);
662662
}
663+
final String columnRestrictions = inputTableUpdater.getColumnRestrictions(name);
664+
if (columnRestrictions != null) {
665+
putMetadata(metadata, "inputtable.restrictions", columnRestrictions);
666+
}
663667
}
664668

665669
if (field != null) {

0 commit comments

Comments
 (0)