diff --git a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java index c2a43f9747a..373d64099e3 100644 --- a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java @@ -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 @@ -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); } @@ -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() { diff --git a/query/src/org/labkey/query/QueryServiceImpl.java b/query/src/org/labkey/query/QueryServiceImpl.java index c83c5b95972..8efc080ab25 100644 --- a/query/src/org/labkey/query/QueryServiceImpl.java +++ b/query/src/org/labkey/query/QueryServiceImpl.java @@ -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, " + diff --git a/query/src/org/labkey/query/sql/Method.java b/query/src/org/labkey/query/sql/Method.java index f4829cc4a11..3ed34d568ed 100644 --- a/query/src/org/labkey/query/sql/Method.java +++ b/query/src/org/labkey/query/sql/Method.java @@ -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() diff --git a/query/src/org/labkey/query/sql/QueryPivot.java b/query/src/org/labkey/query/sql/QueryPivot.java index 42175beebcc..0075b13e0c7 100644 --- a/query/src/org/labkey/query/sql/QueryPivot.java +++ b/query/src/org/labkey/query/sql/QueryPivot.java @@ -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; @@ -538,6 +539,7 @@ public Map getAllColumns() } // Add the pivoted aggregate columns grouped by pivot value + boolean droppedColumn = false; if (!aggs.isEmpty()) { for (String pivotValue : pivotValues.keySet()) @@ -549,10 +551,25 @@ public Map 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; } @@ -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"; }