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
19 changes: 17 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,9 @@ 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]+)$')");
}

private class PostgreSqlColumnMetaDataReader extends ColumnMetaDataReader
Expand Down Expand Up @@ -1130,6 +1131,8 @@ public SQLFragment formatJdbcFunction(String fn, SQLFragment... arguments)
return formatFunction(call, nativeFn, arguments);
else if (fn.equalsIgnoreCase("timestampdiff"))
return timestampdiff(arguments);
else if (fn.equalsIgnoreCase("week"))
return week(arguments);
else
return super.formatJdbcFunction(fn, arguments);
}
Expand Down Expand Up @@ -1170,6 +1173,18 @@ private SQLFragment timestampdiff(SQLFragment... arguments)
return super.formatJdbcFunction("timestampdiff", arguments);
}

// pgjdbc translates {fn week(x)} to EXTRACT(WEEK FROM x) -- ISO 8601, weeks start Monday -- while the SQL Server
// driver emits DATEPART(week, x) -- US-style, weeks start Sunday. Emit US-style so both databases agree.
private SQLFragment week(SQLFragment... arguments)
{
SQLFragment ret = new SQLFragment("CAST(FLOOR((EXTRACT(doy FROM ");
ret.append(arguments[0]);
ret.append(") + EXTRACT(dow FROM date_trunc('year', ");
ret.append(arguments[0]);
ret.append(")) - 1) / 7) + 1 AS INTEGER)");
return ret;
}

@Override
public boolean supportsBatchGeneratedKeys()
{
Expand Down
47 changes: 44 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,47 @@ public void testRightAndIsnumeric() throws SQLException
assertEquals("isnumeric(NULL) on " + dialect, 0, results.getInt("n4"));
}
}

@Test
public void testWeek() throws SQLException
{
// week() must return SQL Server's US numbering on both platforms: weeks start Sunday, and week 1 is
// whatever week contains Jan 1. pgjdbc expands {fn week} to ISO 8601 -- Monday start, week 1 anchored
// on the year's first Thursday -- so BasePostgreSqlDialect intercepts it rather than deferring.
//
// The two rules diverge independently, and how they combine depends on the day Jan 1 falls on, so a
// single year badly understates the difference. 2026 (Jan 1 = Thursday) agrees with ISO except on
// Sundays; 2027 (Jan 1 = Friday) is off by one every day, and ISO assigns 2027-01-01 to week 53 of
// the prior year. Both are covered below; do not reduce this to a mid-year sample.
String sql =
"SELECT " +
" week(CAST('2026-01-01 00:00:00' AS TIMESTAMP)) AS w1, " + // Thursday -> 1
" week(CAST('2026-01-03 00:00:00' AS TIMESTAMP)) AS w2, " + // Saturday -> 1
" week(CAST('2026-01-04 00:00:00' AS TIMESTAMP)) AS w3, " + // Sunday -> 2 (ISO gives 1)
" week(CAST('2027-01-01 00:00:00' AS TIMESTAMP)) AS w4, " + // Friday -> 1 (ISO gives 53)
" week(CAST('2027-07-15 00:00:00' AS TIMESTAMP)) AS w5 " + // Thursday -> 29 (ISO gives 28)
"FROM core.Containers";

QueryDef qd = new QueryDef();
qd.setSchema("core");
qd.setName("junit" + GUID.makeHash());
qd.setContainer(JunitUtil.getTestContainer().getId());
qd.setSql(sql);
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());
assertEquals("week(2026-01-01), Thursday, on " + dialect, 1, results.getInt("w1"));
assertEquals("week(2026-01-03), Saturday, on " + dialect, 1, results.getInt("w2"));
assertEquals("week(2026-01-04), Sunday, on " + dialect, 2, results.getInt("w3"));
assertEquals("week(2027-01-01), Friday, on " + dialect, 1, results.getInt("w4"));
assertEquals("week(2027-07-15), Fri-Sun-anchored year, on " + dialect, 29, results.getInt("w5"));
}
}
}
}
6 changes: 3 additions & 3 deletions query/src/org/labkey/query/sql/Method.java
Original file line number Diff line number Diff line change
Expand Up @@ -1076,9 +1076,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 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
Loading