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..47b7a37c520 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -695,6 +695,21 @@ d,seven,twelve,day,month,date,duration,guid new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('03 Jan 2004' AS TIMESTAMP), SQL_TSI_YEAR) AS INTEGER)", JdbcType.INTEGER, 1), new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('01 Feb 2004' AS TIMESTAMP), SQL_TSI_MONTH) AS INTEGER)", JdbcType.INTEGER, 12), new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('02 Feb 2004' AS TIMESTAMP), SQL_TSI_MONTH) AS INTEGER)", JdbcType.INTEGER, 13), + // age_in_days() and age(..., SQL_TSI_DAY) - counts calendar-day boundaries (SQL Server semantics) + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 395), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, -395), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2004' AS TIMESTAMP), CAST('02 Jan 2004' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1), + new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 395), + new MethodSqlTest("SELECT CAST(AGE(CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, -395), + // age_in_days() with datetime inputs - verifies calendar-boundary counting (not 24-hour elapsed time) + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 0), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), CAST('02 Jan 2003 00:00:01' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 12:00:00' AS TIMESTAMP), CAST('02 Jan 2003 11:00:00' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('02 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, -1), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 06:00:00' AS TIMESTAMP), CAST('03 Jan 2003 18:00:00' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 2), + // age(..., SQL_TSI_DAY) with datetime inputs - same calendar-boundary semantics + new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 0), + new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), CAST('02 Jan 2003 00:00:01' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 1), new MethodSqlTest("SELECT CAST('1' AS SQL_INTEGER) ", JdbcType.INTEGER, 1), new MethodSqlTest("SELECT CAST('1' AS INTEGER) ", JdbcType.INTEGER, 1), new MethodSqlTest("SELECT CAST('1.5' AS DOUBLE) ", JdbcType.DOUBLE, 1.5), 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..8789835eb94 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)); @@ -889,10 +898,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 +983,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);