Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -937,8 +937,24 @@ public boolean supportsIsNumeric()
@Override
public SQLFragment isNumericExpr(SQLFragment expression)
{
return new SQLFragment("(CASE WHEN CAST((").append(expression)
.append(") AS TEXT) ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$' THEN 1 ELSE 0 END)");
// A boolean predicate, not 1/0, to match SQL Server's contract; in SELECT position JDBC's getInt() converts true/false to 1/0.
return new SQLFragment("(CAST((").append(expression)
.append(") AS TEXT) ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$')");
}

@Override
public SQLFragment weekIsoExpr(SQLFragment expression)
{
return new SQLFragment("CAST(EXTRACT(week FROM (").append(expression).append(")) AS INTEGER)");
}

@Override
public SQLFragment weekUsExpr(SQLFragment expression)
{
// Week 1 is whatever week contains Jan 1, and weeks start Sunday: (day of year + Sunday-based weekday of Jan 1 - 1) / 7, rounded down, plus one.
return new SQLFragment("CAST(FLOOR((EXTRACT(doy FROM (").append(expression)
.append(")) + EXTRACT(dow FROM date_trunc('year', (").append(expression)
.append("))) - 1) / 7) + 1 AS INTEGER)");
}

private class PostgreSqlColumnMetaDataReader extends ColumnMetaDataReader
Expand Down
18 changes: 18 additions & 0 deletions api/src/org/labkey/api/data/dialect/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,24 @@ public SQLFragment isNumericExpr(SQLFragment expression)
throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement");
}

/**
* ISO 8601 week number, 1 to 53: weeks start Monday and week 1 is the week holding the year's first Thursday.
* A week belongs to whichever year owns its Thursday, so Jan 1-3 can number as week 52 or 53 of the prior year (2027-01-01 is week 53) and Dec 29-31 as week 1 of the next.
*/
public SQLFragment weekIsoExpr(SQLFragment expression)
{
throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement");
}

/**
* US week number, 1 to 54: weeks start Sunday and week 1 is whatever week holds Jan 1, so the first and last weeks of a year are both partial.
* Every date numbers within its own calendar year, so Jan 1 is always week 1. Matches SQL Server's DATEPART(week, x) under the default DATEFIRST 7.
*/
public SQLFragment weekUsExpr(SQLFragment expression)
{
throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement");
}

public void handleCreateDatabaseException(SQLException e) throws ServletException
{
throw(new ServletException("Can't create database", e));
Expand Down
56 changes: 53 additions & 3 deletions query/src/org/labkey/query/QueryServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -3786,9 +3786,8 @@ public void testWhereClauseWithUnion()
@Test
public void testRightAndIsnumeric() throws SQLException
{
// Portable LabKey-SQL functions: right() dispatches via the JDBC {fn right} escape;
// isnumeric() emits ISNUMERIC(x) on SQL Server and a regex-based CASE on PostgreSQL.
// This test exercises both against whichever dialect the test container is using.
// Portable LabKey-SQL functions: right() dispatches via the JDBC {fn right} escape; isnumeric() is a
// boolean predicate on both -- (ISNUMERIC(x) = 1) on SQL Server, a regex match on PostgreSQL.
String sql =
"SELECT " +
" right('hello', 2) AS r1, " +
Expand Down Expand Up @@ -3821,5 +3820,56 @@ public void testRightAndIsnumeric() throws SQLException
assertEquals("isnumeric(NULL) on " + dialect, 0, results.getInt("n4"));
}
}

// 2026 (Jan 1 = Thursday) makes the two rules agree except on Sundays; 2027 (Jan 1 = Friday) puts them one
// apart every day and starts in the prior ISO year. A mid-year sample in a Mon-Thu year passes either way.
private static final String[] WEEK_DATES = {
"2026-01-01", // Thursday
"2026-01-03", // Saturday
"2026-01-04", // Sunday
"2027-01-01", // Friday
"2027-07-15" // Thursday
};

@Test
public void testWeekUs() throws SQLException
{
// Weeks start Sunday and week 1 holds Jan 1, matching SQL Server's DATEPART(week, x) under DATEFIRST 7.
assertWeeks("weekus", 1, 1, 2, 1, 29);
}

@Test
public void testWeekIso() throws SQLException
{
// ISO 8601: weeks start Monday and week 1 holds the year's first Thursday, so 2027-01-01 lands in 2026's week 53.
assertWeeks("weekiso", 1, 1, 1, 53, 28);
}

private void assertWeeks(String method, int... expected) throws SQLException
{
StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < WEEK_DATES.length; i++)
sql.append(i > 0 ? ", " : "").append(method)
.append("(CAST('").append(WEEK_DATES[i]).append(" 00:00:00' AS TIMESTAMP)) AS w").append(i + 1);
sql.append(" FROM core.Containers");

QueryDef qd = new QueryDef();
qd.setSchema("core");
qd.setName("junit" + GUID.makeHash());
qd.setContainer(JunitUtil.getTestContainer().getId());
qd.setSql(sql.toString());
QueryDefinition qdef = new CustomQueryDefinitionImpl(TestContext.get().getUser(), JunitUtil.getTestContainer(), qd);
List<QueryException> errors = new ArrayList<>();
TableInfo t = qdef.getTable(errors, false);
String dialect = t == null ? "?" : t.getSqlDialect().getProductName();
assertTrue("Query parse errors on " + dialect + ": " + errors, errors.isEmpty());

try (Results results = new TableSelector(t).getResults())
{
assertTrue("Expected at least one row from core.Containers", results.next());
for (int i = 0; i < expected.length; i++)
assertEquals(method + "(" + WEEK_DATES[i] + ") on " + dialect, expected[i], results.getInt("w" + (i + 1)));
}
}
}
}
53 changes: 50 additions & 3 deletions query/src/org/labkey/query/sql/Method.java
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,25 @@ public MethodInfo getMethodInfo()
// Put new methods below this line and move above after they're documented, i.e.,
// added to https://www.labkey.org/Documentation/wiki-page.view?name=labkeySql

// week() is a passthrough, so its numbering is whatever the database does. These two are defined by LabKey and return the same number on either dialect.
// weekiso(x) -- ISO 8601, 1 to 53. Weeks start Monday and week 1 holds the year's first Thursday, so early January can number as the prior year's week 52 or 53.
// weekus(x) -- US, 1 to 54. Weeks start Sunday and week 1 holds Jan 1, so every date numbers within its own year.
labkeyMethod.put("weekiso", new Method("weekiso", JdbcType.INTEGER, 1, 1)
{
@Override
public MethodInfo getMethodInfo()
{
return new WeekIsoInfo();
}
});
labkeyMethod.put("weekus", new Method("weekus", JdbcType.INTEGER, 1, 1)
{
@Override
public MethodInfo getMethodInfo()
{
return new WeekUsInfo();
}
});

// ========== Don't document these ==========
labkeyMethod.put("__cte_two__", new Method(JdbcType.INTEGER, 0, 0)
Expand Down Expand Up @@ -1076,9 +1095,9 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
}
}

// Portable isnumeric() emits ISNUMERIC(x) on SQL Server and a regex-based CASE on PostgreSQL.
// Returns 1 for digit strings with an optional sign/decimal point, 0 otherwise.
// This is stricter than SQL Server's ISNUMERIC(), which also accepts formats like scientific notation.
// Portable isnumeric() is a boolean predicate on both databases -- (ISNUMERIC(x) = 1) on SQL Server, a regex
// match on PostgreSQL -- so it is valid in CASE WHEN and WHERE, not just a SELECT list. The PostgreSQL regex
// accepts only digits with an optional sign/decimal point, stricter than SQL Server's ISNUMERIC().
static class IsNumericInfo extends AbstractMethodInfo
{
IsNumericInfo()
Expand All @@ -1097,6 +1116,34 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
}
}

static class WeekIsoInfo extends AbstractMethodInfo
{
WeekIsoInfo()
{
super(JdbcType.INTEGER);
}

@Override
public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
{
return dialect.weekIsoExpr(arguments[0]);
}
}

static class WeekUsInfo extends AbstractMethodInfo
{
WeekUsInfo()
{
super(JdbcType.INTEGER);
}

@Override
public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
{
return dialect.weekUsExpr(arguments[0]);
}
}

static class VersionMethodInfo extends AbstractMethodInfo
{
VersionMethodInfo()
Expand Down
44 changes: 42 additions & 2 deletions query/src/org/labkey/query/sql/QueryPivot.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package org.labkey.query.sql;

import org.apache.commons.beanutils.ConversionException;
import org.apache.commons.beanutils.ConvertUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand Down Expand Up @@ -538,6 +539,7 @@ public Map<String, RelationColumn> getAllColumns()
}

// Add the pivoted aggregate columns grouped by pivot value
boolean droppedColumn = false;
if (!aggs.isEmpty())
{
for (String pivotValue : pivotValues.keySet())
Expand All @@ -549,10 +551,25 @@ public Map<String, RelationColumn> getAllColumns()

String pivotName = makePivotAggName(name, pivotValue);
RelationColumn pvt = _makePivotedAggColumn(s, new FieldKey(null, pivotName), pivotValue);
_columns.put(pivotName, pvt);
// _makePivotedAggColumn() returns null when parse errors are present
if (null != pvt)
_columns.put(pivotName, pvt);
else
droppedColumn = true;
}
}
}

// A silently short column list is harder to diagnose than the parse error behind it, so throw the way
// getSql() and getColMembers() do. Discard the cached _columns first, or the partial map gets handed
// out unguarded on the next call.
if (droppedColumn && !getParseErrors().isEmpty())
{
_columns = null;
QueryException qe = getParseErrors().get(0);
_query.decorateException(qe);
throw qe;
}
}
return _columns;
}
Expand Down Expand Up @@ -822,9 +839,32 @@ public SQLFragment getSql()
String alias = makePivotColumnAlias(col.getAlias(), pivotValue.getKey());
sql.append(comma).append("MAX(CASE WHEN (").append(_pivotColumn.getValueSql());
if (value instanceof QNull)
{
sql.append(" IS NULL");
}
else
sql.append("=").append(value.getSourceText());
{
// Bind rather than embed the source text: a value containing ';' or a quote trips SQLFragment's guardrail.
// Postgres needs an explicit parameter type, and wrapConstant() types date/timestamp pivot values as
// QString, so prefer the pivot column's type and fall back to the constant's if it won't convert.
Object bindValue = ((IConstant) value).getValue();
JdbcType bindType = ((QExpr) value).getJdbcType();
JdbcType columnType = _pivotColumn.getJdbcType();
if (null != columnType && JdbcType.OTHER != columnType && columnType != bindType)
{
try
{
bindValue = columnType.convert(bindValue);
bindType = columnType;
}
catch (ConversionException ignored)
{
// keep the constant's own type and value
}
}
sql.append("=?");
sql.add(bindValue, bindType);
}
sql.append(") THEN (").append(col.getValueSql()).append(") ELSE NULL END) AS ").appendIdentifier(alias);
comma = ",\n";
}
Expand Down