Skip to content

Commit abc7f37

Browse files
committed
Review feedback
1 parent 1858001 commit abc7f37

9 files changed

Lines changed: 231 additions & 117 deletions

File tree

‎api/src/org/labkey/api/query/suggestions/SuggestionColumn.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public enum Type { BOOLEAN, DATE, FLOAT, INT, STRING }
4141
private boolean _uniqueId;
4242
private boolean _rangeUnknown;
4343
private boolean _multiValued;
44+
private boolean _partialMatch;
4445
private final List<String> _nameExpressions = new ArrayList<>();
4546
private @Nullable List<String> _values;
4647
private @Nullable List<String> _shapes;
@@ -142,6 +143,17 @@ public void setMultiValued(boolean multiValued)
142143
_multiValued = multiValued;
143144
}
144145

146+
/** Users search this column by any portion of a value, so every term gets a Contains suggestion for it. */
147+
public boolean isPartialMatch()
148+
{
149+
return _partialMatch;
150+
}
151+
152+
public void setPartialMatch(boolean partialMatch)
153+
{
154+
_partialMatch = partialMatch;
155+
}
156+
145157
/** Name expressions whose generated names this column holds, e.g. a sample type's name and aliquot expressions. */
146158
public @NotNull List<String> getNameExpressions()
147159
{

‎api/src/org/labkey/api/util/DateUtil.java‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,16 @@
4545
import java.text.ParseException;
4646
import java.text.ParsePosition;
4747
import java.text.SimpleDateFormat;
48+
import java.time.DateTimeException;
4849
import java.time.LocalDate;
4950
import java.time.LocalDateTime;
5051
import java.time.LocalTime;
52+
import java.time.Year;
53+
import java.time.YearMonth;
5154
import java.time.ZoneId;
5255
import java.time.ZonedDateTime;
5356
import java.time.format.DateTimeFormatter;
57+
import java.time.temporal.Temporal;
5458
import java.util.Arrays;
5559
import java.util.Calendar;
5660
import java.util.Date;
@@ -62,6 +66,7 @@
6266
import java.util.TimeZone;
6367
import java.util.concurrent.ConcurrentHashMap;
6468
import java.util.concurrent.TimeUnit;
69+
import java.util.regex.Matcher;
6570
import java.util.regex.Pattern;
6671

6772
import static java.util.Calendar.AM_PM;
@@ -96,6 +101,9 @@ private DateUtil()
96101

97102
// SimpleDataFormat does not support microseconds, it can only support up to milliseconds
98103
private static final Pattern NON_SIMPLE_PRECISION_TIME_PATTERN = Pattern.compile(".*([0-5][0-9]):([0-5][0-9])\\.(\\d{4,6}).*");
104+
private static final Pattern YEAR_PATTERN = Pattern.compile("\\d{4}");
105+
// The scanner reads a trailing number as the day, so "2024-02" would be February 2nd of an unknown month
106+
private static final Pattern YEAR_MONTH_PATTERN = Pattern.compile("(\\d{4})[-/](\\d{1,2})");
99107
private static final String[] SIMPLE_TIME_FORMATS_WITH_AMPM = {"hh:mm:ss.SSS a", "hh:mm:ss a", "hh:mm a"};
100108
private static final String[] SIMPLE_TIME_FORMATS_NO_AMPM = {
101109
ISO_LONG_TIME_FORMAT_STRING,
@@ -809,6 +817,46 @@ private static long parseDate(String s, MonthDayOption md, @Nullable String extr
809817
}
810818
}
811819

820+
/** Parses a date that may stop at the month or year, such as "2024", "2024-02" or "Feb 2024", using the site's month/day setting. */
821+
public static @Nullable Temporal parsePartialDate(@NotNull String s)
822+
{
823+
MonthDayOption monthDayOption = LookAndFeelProperties.getInstance(ContainerManager.getRoot()).getDateParsingMode().getDayMonth();
824+
825+
return parsePartialDate(s, monthDayOption);
826+
}
827+
828+
/**
829+
* Returns a LocalDate, YearMonth or Year, or null when s isn't one. Fields are taken as written, so any time is
830+
* ignored and an explicit zone can't shift the date into the server's zone.
831+
*/
832+
public static @Nullable Temporal parsePartialDate(@NotNull String s, @NotNull MonthDayOption md)
833+
{
834+
s = s.trim();
835+
if (YEAR_PATTERN.matcher(s).matches())
836+
return Year.of(Integer.parseInt(s));
837+
838+
try
839+
{
840+
Matcher m = YEAR_MONTH_PATTERN.matcher(s);
841+
if (m.matches())
842+
return YearMonth.of(Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)));
843+
844+
CalendarParts parts = new CalendarParts();
845+
ParseDateTimeEN.getInstance(_localeDefault, _timezoneDefault, DateTimeOption.DateTime, md, false).scan(s, new ParsePosition(0), parts);
846+
if (!parts.isSet(YEAR, MONTH))
847+
return null;
848+
if (parts.isSet(DAY_OF_MONTH))
849+
return LocalDate.of(parts.getYear(), parts.getMonth() + 1, parts.getDayOfMonth());
850+
if (parts.isHourSet() || parts.anySet(MINUTE, SECOND))
851+
return null;
852+
return YearMonth.of(parts.getYear(), parts.getMonth() + 1);
853+
}
854+
catch (ConversionException | DateTimeException e)
855+
{
856+
return null;
857+
}
858+
}
859+
812860
public static @Nullable Date parseSimpleTime(@NotNull String s)
813861
{
814862
Date duration = null;
@@ -1826,6 +1874,23 @@ public void testDateTimeCH()
18261874
assertEquals(dateExpected, DateUtil.parseDateTime("3.2.2001", MonthDayOption.DAY_MONTH));
18271875
}
18281876

1877+
@Test
1878+
public void testParsePartialDate()
1879+
{
1880+
assertEquals(Year.of(2024), parsePartialDate("2024", MonthDayOption.MONTH_DAY));
1881+
YearMonth feb = YearMonth.of(2024, 2);
1882+
for (String s : List.of("2024-02", "2024/2", "Feb 2024", "Feb. 2024", "february 2024"))
1883+
assertEquals(s, feb, parsePartialDate(s, MonthDayOption.MONTH_DAY));
1884+
1885+
assertEquals(LocalDate.of(2024, 2, 3), parsePartialDate("2/3/2024", MonthDayOption.MONTH_DAY));
1886+
assertEquals(LocalDate.of(2024, 3, 2), parsePartialDate("2/3/2024", MonthDayOption.DAY_MONTH));
1887+
assertEquals(LocalDate.of(2024, 2, 3), parsePartialDate("Feb 3, 2024 11:38 pm", MonthDayOption.MONTH_DAY));
1888+
assertEquals(LocalDate.of(2024, 2, 3), parsePartialDate("2024-02-03T23:30:00Z", MonthDayOption.MONTH_DAY));
1889+
1890+
for (String s : List.of("2024-13", "2023-02-29", "31/12/2023", "Feb", "86", "Feb 2024 11:38", "11:38", "Sodium"))
1891+
assertNull(s, parsePartialDate(s, MonthDayOption.MONTH_DAY));
1892+
}
1893+
18291894
private void assertXmlDateMatches(long millis)
18301895
{
18311896
Calendar parsedXml = new GregorianCalendar();

‎experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1392,7 +1392,7 @@ void recordPendingUpdate(@NotNull Timestamp changedSince)
13921392

13931393
String getDataChangeToken()
13941394
{
1395-
return instance + "-" + update.get() + "-" + insert.get() + "-" + delete.get() + "-" + rollup.get() + "-" + rebuild.get();
1395+
return instance + "-" + update.get() + "-" + insert.get() + "-" + delete.get() + "-" + rollup.get() + "-" + rebuild.get() + "-" + UnloggedTableGeneration.current();
13961396
}
13971397
}
13981398

‎experiment/src/org/labkey/experiment/api/SampleTypeFilterSuggestionProvider.java‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.labkey.api.data.Container;
2626
import org.labkey.api.data.ContainerFilter;
2727
import org.labkey.api.data.TableInfo;
28+
import org.labkey.api.data.UnloggedTableGeneration;
2829
import org.labkey.api.exp.api.ExpSampleType;
2930
import org.labkey.api.exp.api.SampleTypeService;
3031
import org.labkey.api.exp.query.ExpMaterialTable;
@@ -47,7 +48,7 @@
4748
import java.util.Objects;
4849
import java.util.Set;
4950

50-
/** Adds a sample type's name expressions to its Name column and keys its cached facts to its sample data changes. */
51+
/** Marks a sample type's Name column as searched by any portion, adds its name expressions, and keys its cached facts to its sample data changes. */
5152
public class SampleTypeFilterSuggestionProvider implements FilterSuggestionProvider
5253
{
5354
@Override
@@ -66,6 +67,7 @@ public void describe(@NotNull FilterSuggestionContext context, @NotNull List<Sug
6667
columns.stream()
6768
.filter(column -> ExpMaterialTable.Column.Name.name().equalsIgnoreCase(column.getFieldKey()))
6869
.forEach(column -> {
70+
column.setPartialMatch(true);
6971
column.addNameExpression(sampleType.getNameExpression());
7072
// Null when the default applies; default aliquot names extend the parent's name, which the name expression already matches
7173
column.addNameExpression(sampleType.getAliquotNameExpression());
@@ -138,6 +140,9 @@ public void testDataChangeToken() throws Exception
138140
ExpMaterialTableImpl._materializedQueries.remove(st.getLSID());
139141
ExpMaterialTableImpl._invalidationCounters.remove(st.getLSID());
140142
assertNewToken(seen, st, "reset");
143+
144+
UnloggedTableGeneration.simulateResetForTest();
145+
assertNewToken(seen, st, "unlogged reset");
141146
}
142147

143148
@Test

‎query/src/org/labkey/query/suggestions/ColumnFactsCache.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,17 @@ public void testFactsAndCacheHit()
835835
assertEquals(List.of("AAAA-999999"), byFieldKey(again).get("subject").getShapes());
836836
}
837837

838+
@Test
839+
public void testShapesSampleNewestRows()
840+
{
841+
// The oldest rows, S-0 through S-99, have the only one- and two-digit names, and sorting by value would reach them first
842+
assertEquals("The sample must skip exactly the oldest 100 rows", 100, ROWS - ColumnFactsLoader.SHAPE_SAMPLE_SIZE);
843+
TableInfo table = getTable(SAMPLE_TYPE);
844+
ColumnInfo name = Objects.requireNonNull(table.getColumn("Name"));
845+
Map<FieldKey, ColumnFact.Shapes> shapes = new ColumnFactsLoader().loadShapes(table, List.of(name), Map.of());
846+
assertEquals(List.of("A-999"), shapes.get(name.getFieldKey()).shapes());
847+
}
848+
838849
@Test
839850
public void testSharingFollowsToken()
840851
{

‎query/src/org/labkey/query/suggestions/ColumnFactsLoader.java‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.labkey.api.data.QueryLogging;
2323
import org.labkey.api.data.Results;
2424
import org.labkey.api.data.SQLFragment;
25+
import org.labkey.api.data.Sort;
2526
import org.labkey.api.data.SqlSelector;
2627
import org.labkey.api.data.TableInfo;
2728
import org.labkey.api.data.TableSelector;
@@ -130,7 +131,7 @@ record Aggregates(@Nullable Aggregate min, @Nullable Aggregate max, @Nullable Ag
130131
Map<FieldKey, Set<String>> shapes = new LinkedHashMap<>();
131132
columns.forEach(column -> shapes.put(column.getFieldKey(), new LinkedHashSet<>()));
132133

133-
TableSelector selector = new TableSelector(table, columns, null, null)
134+
TableSelector selector = new TableSelector(table, columns, null, getNewestFirstSort(table))
134135
.setMaxRows(SHAPE_SAMPLE_SIZE)
135136
.setNamedParameters(parameters)
136137
.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
@@ -157,6 +158,17 @@ record Aggregates(@Nullable Aggregate min, @Nullable Aggregate max, @Nullable Ag
157158
return result;
158159
}
159160

161+
/** Keys are usually auto-increment, so a descending key sort samples the newest rows. Null leaves the default sort, which orders by the sampled columns. */
162+
private static @Nullable Sort getNewestFirstSort(TableInfo table)
163+
{
164+
List<ColumnInfo> pkColumns = table.getPkColumns();
165+
if (pkColumns.isEmpty() || !pkColumns.stream().allMatch(ColumnInfo::isSortable))
166+
return null;
167+
Sort sort = new Sort();
168+
pkColumns.forEach(pk -> sort.appendSortColumn(pk.getFieldKey(), Sort.SortDirection.DESC, false));
169+
return sort;
170+
}
171+
160172
private static @Nullable Object getValue(List<Aggregate.Result> results, @Nullable Aggregate aggregate)
161173
{
162174
if (aggregate == null)

‎query/src/org/labkey/query/suggestions/FilterSuggestionRanker.java‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,15 @@ boolean matches(String value)
192192
}
193193
}
194194

195+
// Ranked below every specific match, but given a reserved slot below so those can't crowd it out
196+
List<FilterSuggestion> partialMatches = new ArrayList<>();
197+
if (raw.length() >= MIN_PARTIAL_MATCH_LENGTH)
198+
{
199+
columns.stream()
200+
.filter(column -> column.isPartialMatch() && column.getType() == SuggestionColumn.Type.STRING)
201+
.forEach(column -> addFilter(partialMatches, seen, column, "contains", raw, "partialMatch", raw));
202+
}
203+
195204
List<FilterSuggestion> compose = new ArrayList<>();
196205

197206
if (!stringSignals.isEmpty())
@@ -240,7 +249,12 @@ boolean matches(String value)
240249
}
241250

242251
int directLimit = Math.max(0, maxSuggestions - compose.size() - 1);
243-
List<FilterSuggestion> suggestions = new ArrayList<>(direct.subList(0, Math.min(directLimit, direct.size())));
252+
// Never reserve the only direct slot, which belongs to the strongest specific match
253+
int otherLimit = directLimit;
254+
if (directLimit > partialMatches.size())
255+
otherLimit -= partialMatches.size();
256+
List<FilterSuggestion> suggestions = new ArrayList<>(direct.subList(0, Math.min(otherLimit, direct.size())));
257+
suggestions.addAll(partialMatches.subList(0, Math.min(partialMatches.size(), directLimit - suggestions.size())));
244258
suggestions.addAll(compose);
245259
suggestions.add(FilterSuggestion.search(raw));
246260
return suggestions;
@@ -423,6 +437,7 @@ private static List<SuggestionColumn> toColumns(JsonNode columnSet)
423437
column.setUniqueId(node.path("isUniqueId").asBoolean(false));
424438
column.setRangeUnknown(node.path("rangeUnknown").asBoolean(false));
425439
column.setMultiValued(node.path("multiValued").asBoolean(false));
440+
column.setPartialMatch(node.path("partialMatch").asBoolean(false));
426441
column.addNameExpression(node.path("nameExpression").asText(null));
427442
column.setValues(toStrings(node.get("values")));
428443
column.setShapes(toStrings(node.get("shapes")));

0 commit comments

Comments
 (0)