diff --git a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java index 0ecd40466c3..97c2313e0dd 100644 --- a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java @@ -1117,6 +1117,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("timestampdiff2")) + return timestampdiff2(arguments); else return super.formatJdbcFunction(fn, arguments); } @@ -1157,6 +1159,53 @@ private SQLFragment timestampdiff(SQLFragment... arguments) return super.formatJdbcFunction("timestampdiff", arguments); } + /* Native PostgreSQL implementation for all 9 SQL_TSI intervals. + * This returns INTEGER for all intervals and never falls back to the JDBC escape. + * + * This was specifically created as a work around for PG JDBC parameter swapping issue. This does not use the JDBC + * timestampdiff function. + */ + private SQLFragment timestampdiff2(SQLFragment... arguments) + { + String interval = arguments[0].getSQL(); + SQLFragment start = arguments[1]; + SQLFragment end = arguments[2]; + // Compute whole elapsed months first, then derive quarter/year from that value so all larger + // intervals use the same truncation-toward-zero semantics as the epoch-based branches below. + SQLFragment wholeMonths = getWholeElapsedMonths(start, end); + + return switch (interval) + { + case "SQL_TSI_YEAR" -> + new SQLFragment("TRUNC((").append(wholeMonths).append(")::NUMERIC / 12)::INT"); + case "SQL_TSI_QUARTER" -> + new SQLFragment("TRUNC((").append(wholeMonths).append(")::NUMERIC / 3)::INT"); + case "SQL_TSI_MONTH" -> + wholeMonths; + case "SQL_TSI_WEEK" -> + new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 604800)::INT"); + case "SQL_TSI_DAY" -> + new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 86400)::INT"); + case "SQL_TSI_HOUR" -> + new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 3600)::INT"); + case "SQL_TSI_MINUTE" -> + new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 60)::INT"); + case "SQL_TSI_SECOND" -> + new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")))::INT"); + case "SQL_TSI_FRAC_SECOND" -> + new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) * 1000)::BIGINT"); + default -> throw new IllegalArgumentException("Unsupported interval for timestampdiff2: " + interval); + }; + } + + private SQLFragment getWholeElapsedMonths(SQLFragment start, SQLFragment end) + { + // AGE() normalizes the symbolic year/month/day components for both positive and negative spans. + SQLFragment age = new SQLFragment("AGE((").append(end).append("), (").append(start).append("))"); + return new SQLFragment("((EXTRACT(YEAR FROM ").append(age).append(") * 12) + EXTRACT(MONTH FROM ").append(age) + .append("))::INT"); + } + @Override public boolean supportsBatchGeneratedKeys() { diff --git a/api/src/org/labkey/api/data/dialect/SqlDialect.java b/api/src/org/labkey/api/data/dialect/SqlDialect.java index 868702be8e2..2c36f9ca156 100644 --- a/api/src/org/labkey/api/data/dialect/SqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/SqlDialect.java @@ -2107,6 +2107,8 @@ public SQLFragment formatFunction(SQLFragment target, String fn, SQLFragment... public SQLFragment formatJdbcFunction(String fn, SQLFragment... arguments) { + if (fn.equalsIgnoreCase("timestampdiff2")) + fn = "timestampdiff"; SQLFragment ret = new SQLFragment(); ret.append("{fn "); formatFunction(ret, fn, arguments); diff --git a/api/src/org/labkey/api/migration/DatabaseMigrationService.java b/api/src/org/labkey/api/migration/DatabaseMigrationService.java index 59bbd9d997e..558b6548941 100644 --- a/api/src/org/labkey/api/migration/DatabaseMigrationService.java +++ b/api/src/org/labkey/api/migration/DatabaseMigrationService.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.Nullable; import org.labkey.api.data.CompareType; import org.labkey.api.data.DbSchemaType; +import org.labkey.api.data.DbSchema; import org.labkey.api.data.SimpleFilter.FilterClause; import org.labkey.api.data.TableInfo; import org.labkey.api.query.FieldKey; @@ -17,6 +18,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.Consumer; public interface DatabaseMigrationService { @@ -51,6 +53,10 @@ default void registerSchemaHandler(MigrationSchemaHandler schemaHandler) {} default void registerTableHandler(MigrationTableHandler tableHandler) {} default void registerMigrationFilter(MigrationFilter filter) {} + // Register a contributor that runs during migration before a schema's tables are processed. + // Useful for modules that need to register table handlers for a schema owned by another module. + default void registerSchemaContributor(String schemaName, Consumer contributor) {} + default @Nullable MigrationFilter getMigrationFilter(String propertyName) { return null; diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index 1831ba89ada..1fb73e95560 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -777,6 +777,32 @@ d,seven,twelve,day,month,date,duration,guid new MethodSqlTest("SELECT CAST(TIMESTAMPDIFF(SQL_TSI_DAY, CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, -395), // NOTE: SQL_TSI_WEEK, SQL_TSI_MONTH, SQL_TSI_QUARTER, and SQL_TSI_YEAR are NYI in PostsgreSQL TIMESTAMPDIFF + // timestampdiff2 - native PostgreSQL implementation for all intervals, returns INTEGER + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_SECOND, CAST('01 Jan 2004 5:00' AS TIMESTAMP), CAST('01 Jan 2004 6:00' AS TIMESTAMP))", JdbcType.INTEGER, 3600), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MINUTE, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 525600), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MINUTE, CAST('01 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2005' AS TIMESTAMP))", JdbcType.INTEGER, 527040), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_HOUR, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 8760), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_HOUR, CAST('01 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2005' AS TIMESTAMP))", JdbcType.INTEGER, 8784), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_DAY, CAST('01 Jan 2003' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 395), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_DAY, CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -395), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_WEEK, CAST('01 Jan 2003' AS TIMESTAMP), CAST('22 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_WEEK, CAST('22 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Apr 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('01 Feb 2003 00:00' AS TIMESTAMP), CAST('31 Jan 2003 23:59' AS TIMESTAMP))", JdbcType.INTEGER, 0), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('15 Jan 2003' AS TIMESTAMP), CAST('14 Apr 2003' AS TIMESTAMP))", JdbcType.INTEGER, 2), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('15 Jan 2003' AS TIMESTAMP), CAST('15 Apr 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('15 Apr 2003' AS TIMESTAMP), CAST('14 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_QUARTER, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Oct 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_QUARTER, CAST('15 Jan 2003' AS TIMESTAMP), CAST('14 Oct 2003' AS TIMESTAMP))", JdbcType.INTEGER, 2), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_QUARTER, CAST('15 Jan 2003' AS TIMESTAMP), CAST('15 Oct 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_QUARTER, CAST('15 Oct 2003' AS TIMESTAMP), CAST('14 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_YEAR, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2006' AS TIMESTAMP))", JdbcType.INTEGER, 3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_YEAR, CAST('15 Jan 2003' AS TIMESTAMP), CAST('14 Jan 2006' AS TIMESTAMP))", JdbcType.INTEGER, 2), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_YEAR, CAST('15 Jan 2003' AS TIMESTAMP), CAST('15 Jan 2006' AS TIMESTAMP))", JdbcType.INTEGER, 3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_YEAR, CAST('14 Jan 2006' AS TIMESTAMP), CAST('15 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -2), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_FRAC_SECOND, CAST('01 Jan 2004 5:00:00' AS TIMESTAMP), CAST('01 Jan 2004 5:00:01' AS TIMESTAMP))", JdbcType.BIGINT, 1000), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_FRAC_SECOND, CAST('01 Jan 2004' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP))", JdbcType.BIGINT, 2592000000L), + new MethodSqlTest("SELECT UCASE('Fred')", JdbcType.VARCHAR, "FRED"), new MethodSqlTest("SELECT UPPER('fred')", JdbcType.VARCHAR, "FRED"), new MethodSqlTest("SELECT USERID()", JdbcType.INTEGER, () -> TestContext.get().getUser().getUserId()), diff --git a/query/src/org/labkey/query/controllers/LabKeySql.md b/query/src/org/labkey/query/controllers/LabKeySql.md index e39099d2f76..960de5f7553 100644 --- a/query/src/org/labkey/query/controllers/LabKeySql.md +++ b/query/src/org/labkey/query/controllers/LabKeySql.md @@ -198,6 +198,7 @@ Here is a summary of the available functions and methods in LabKey SQL. #### **Date and Time Functions** * `age(date1, date2, [interval])`: Supplies the difference in age. +* `age_in_days(date1, date2)`: Returns age in days. * `age_in_months(date1, date2)`: Returns age in months. * `age_in_years(date1, date2)`: Returns age in years. * `curdate()`, `curtime()`: Returns the current date/time. diff --git a/query/src/org/labkey/query/sql/Method.java b/query/src/org/labkey/query/sql/Method.java index b6377660675..42b9b09b399 100644 --- a/query/src/org/labkey/query/sql/Method.java +++ b/query/src/org/labkey/query/sql/Method.java @@ -51,6 +51,7 @@ import org.labkey.query.QueryServiceImpl; import org.labkey.query.sql.antlr.SqlBaseLexer; +import java.util.Calendar; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.text.DecimalFormat; @@ -94,9 +95,9 @@ public void validate(CommonTree fn, List args, List parseError if (text.length() >= 2 && text.startsWith("'") && text.endsWith("'")) text = text.substring(1, text.length() - 1); TimestampDiffInterval i = TimestampDiffInterval.parse(text); - if (!(i == TimestampDiffInterval.SQL_TSI_MONTH || i == TimestampDiffInterval.SQL_TSI_YEAR)) + if (!(i == TimestampDiffInterval.SQL_TSI_DAY || i == TimestampDiffInterval.SQL_TSI_MONTH || i == TimestampDiffInterval.SQL_TSI_YEAR)) { - parseErrors.add(new QueryParseException("AGE function supports SQL_TSI_YEAR or SQL_TSI_MONTH", null, + parseErrors.add(new QueryParseException("AGE function supports SQL_TSI_DAY, SQL_TSI_MONTH, or SQL_TSI_YEAR", null, nodeInterval.getLine(), nodeInterval.getColumn())); } } @@ -118,6 +119,14 @@ public MethodInfo getMethodInfo() return new AgeInYearsMethodInfo(); } }); + labkeyMethod.put("age_in_days", new Method(JdbcType.INTEGER, 2, 2) + { + @Override + public MethodInfo getMethodInfo() + { + return new AgeInDaysMethodInfo(); + } + }); labkeyMethod.put("asin", new JdbcMethod("asin", JdbcType.DOUBLE, 1, 1)); labkeyMethod.put("atan", new JdbcMethod("atan", JdbcType.DOUBLE, 1, 1)); labkeyMethod.put("atan2", new JdbcMethod("atan2", JdbcType.DOUBLE, 2, 2)); @@ -428,6 +437,14 @@ public MethodInfo getMethodInfo() return new TimestampInfo(this); } }); + labkeyMethod.put("timestampdiff2", new Method("timestampdiff2", JdbcType.INTEGER, 3, 3) + { + @Override + public MethodInfo getMethodInfo() + { + return new TimestampInfo(this); + } + }); labkeyMethod.put("truncate", new JdbcMethod("truncate", JdbcType.DOUBLE, 2, 2)); labkeyMethod.put("ucase", new JdbcMethod("ucase", JdbcType.VARCHAR, 1, 1)); labkeyMethod.put("upper", new JdbcMethod("ucase", JdbcType.VARCHAR, 1, 1)); @@ -889,10 +906,12 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments) return new AgeInYearsMethodInfo().getSQL(dialect, arguments); if (i == TimestampDiffInterval.SQL_TSI_MONTH) return new AgeInMonthsMethodInfo().getSQL(dialect, arguments); + if (i == TimestampDiffInterval.SQL_TSI_DAY) + return new AgeInDaysMethodInfo().getSQL(dialect, arguments); if (null == i) throw new IllegalArgumentException("AGE(" + arguments[2].getSQL() + ")"); else - throw new IllegalArgumentException("AGE only supports YEAR and MONTH"); + throw new IllegalArgumentException("AGE only supports DAY, MONTH, and YEAR"); } } @@ -972,6 +991,29 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments) } + static class AgeInDaysMethodInfo extends AbstractMethodInfo + { + AgeInDaysMethodInfo() + { + super(JdbcType.INTEGER); + } + + @Override + public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments) + { + MethodInfo convert = labkeyMethod.get("convert").getMethodInfo(); + SQLFragment dateType = new SQLFragment("DATE"); + SQLFragment startDate = convert.getSQL(dialect, new SQLFragment[]{arguments[0], dateType}); + SQLFragment endDate = convert.getSQL(dialect, new SQLFragment[]{arguments[1], dateType}); + + if (dialect.isPostgreSQL()) + return new SQLFragment("(").append(endDate).append(" - ").append(startDate).append(")"); + + return dialect.getDateDiff(Calendar.DATE, endDate, startDate); + } + } + + static class StartsWithInfo extends AbstractMethodInfo { StartsWithInfo() diff --git a/query/src/org/labkey/query/sql/QuerySelect.java b/query/src/org/labkey/query/sql/QuerySelect.java index c863c3ad90d..98adee324e7 100644 --- a/query/src/org/labkey/query/sql/QuerySelect.java +++ b/query/src/org/labkey/query/sql/QuerySelect.java @@ -2264,7 +2264,9 @@ SQLFragment getInternalSql() QExpr expr = getResolvedField(); // NOTE SqlServer does not like predicates (A=B) in select list, try to help out - if (expr instanceof QMethodCall && expr.getJdbcType() == JdbcType.BOOLEAN && b.getDialect().isSqlServer()) + // Exclude CAST/CONVERT expressions — they produce BIT values, not boolean predicates + if (expr instanceof QMethodCall mc && mc.getJdbcType() == JdbcType.BOOLEAN && b.getDialect().isSqlServer() + && !(mc.getMethod(b.getDialect()) instanceof Method.ConvertInfo)) { b.append("CASE WHEN ("); expr.appendSql(b, _query); diff --git a/query/src/org/labkey/query/sql/SqlParser.java b/query/src/org/labkey/query/sql/SqlParser.java index cca976db057..d6691ea7b95 100644 --- a/query/src/org/labkey/query/sql/SqlParser.java +++ b/query/src/org/labkey/query/sql/SqlParser.java @@ -995,7 +995,7 @@ else if (divisorType==NUM_DOUBLE || divisorType==NUM_FLOAT || divisorType==NUM_I } exprList._replaceChildren(new LinkedList<>(List.of(valueExpression, type))); } - else if (name.equals("timestampadd") || name.equals("timestampdiff")) + else if (name.equals("timestampadd") || name.equals("timestampdiff") || name.equals("timestampdiff2")) { if (!(exprList instanceof QExprList) || exprList.childList().size() != 3) { @@ -1945,6 +1945,7 @@ class delete elements fetch indices insert into limit new set update versioned b "SELECT TIMESTAMPDIFF(SQL_TSI_SECOND,a,b), TIMESTAMPDIFF(SECOND,a,b), TIMESTAMPDIFF('SQL_TSI_DAY',a,b), TIMESTAMPDIFF('DAY',a,b) FROM R", "SELECT TIMESTAMPDIFF('SQL_TSI_Second',a,b), TIMESTAMPDIFF('Second',a,b), TIMESTAMPDIFF('SQL_TSI_Day',a,b), TIMESTAMPDIFF('Day',a,b) FROM R", "SELECT TIMESTAMPADD(SQL_TSI_SECOND,1,b), TIMESTAMPADD(SECOND,1,b), TIMESTAMPADD('SQL_TSI_DAY',1,b), TIMESTAMPADD('DAY',1,b) FROM R", + "SELECT TIMESTAMPDIFF2(SQL_TSI_SECOND,a,b), TIMESTAMPDIFF2('SQL_TSI_DAY',a,b), TIMESTAMPDIFF2('MONTH',a,b), TIMESTAMPDIFF2('YEAR',a,b) FROM R", "SELECT (SELECT value FROM S WHERE S.x=R.x) AS V FROM R", "SELECT R.value AS V FROM R WHERE R.y > (SELECT MAX(S.y) FROM S WHERE S.x=R.x)",