Skip to content

Commit 620ed78

Browse files
committed
timestampdiff2 and age_in_days
1 parent 4dd5cac commit 620ed78

8 files changed

Lines changed: 131 additions & 5 deletions

File tree

api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,8 @@ public SQLFragment formatJdbcFunction(String fn, SQLFragment... arguments)
11171117
return formatFunction(call, nativeFn, arguments);
11181118
else if (fn.equalsIgnoreCase("timestampdiff"))
11191119
return timestampdiff(arguments);
1120+
else if (fn.equalsIgnoreCase("timestampdiff2"))
1121+
return timestampdiff2(arguments);
11201122
else
11211123
return super.formatJdbcFunction(fn, arguments);
11221124
}
@@ -1157,6 +1159,50 @@ private SQLFragment timestampdiff(SQLFragment... arguments)
11571159
return super.formatJdbcFunction("timestampdiff", arguments);
11581160
}
11591161

1162+
/* Native PostgreSQL implementation for all 9 SQL_TSI intervals.
1163+
* This returns INTEGER for all intervals and never falls back to the JDBC escape.
1164+
*/
1165+
private SQLFragment timestampdiff2(SQLFragment... arguments)
1166+
{
1167+
String interval = arguments[0].getSQL();
1168+
SQLFragment start = arguments[1];
1169+
SQLFragment end = arguments[2];
1170+
// Compute whole elapsed months first, then derive quarter/year from that value so all larger
1171+
// intervals use the same truncation-toward-zero semantics as the epoch-based branches below.
1172+
SQLFragment wholeMonths = getWholeElapsedMonths(start, end);
1173+
1174+
return switch (interval)
1175+
{
1176+
case "SQL_TSI_YEAR" ->
1177+
new SQLFragment("TRUNC((").append(wholeMonths).append(")::NUMERIC / 12)::INT");
1178+
case "SQL_TSI_QUARTER" ->
1179+
new SQLFragment("TRUNC((").append(wholeMonths).append(")::NUMERIC / 3)::INT");
1180+
case "SQL_TSI_MONTH" ->
1181+
wholeMonths;
1182+
case "SQL_TSI_WEEK" ->
1183+
new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 604800)::INT");
1184+
case "SQL_TSI_DAY" ->
1185+
new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 86400)::INT");
1186+
case "SQL_TSI_HOUR" ->
1187+
new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 3600)::INT");
1188+
case "SQL_TSI_MINUTE" ->
1189+
new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 60)::INT");
1190+
case "SQL_TSI_SECOND" ->
1191+
new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")))::INT");
1192+
case "SQL_TSI_FRAC_SECOND" ->
1193+
new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) * 1000)::BIGINT");
1194+
default -> throw new IllegalArgumentException("Unsupported interval for timestampdiff2: " + interval);
1195+
};
1196+
}
1197+
1198+
private SQLFragment getWholeElapsedMonths(SQLFragment start, SQLFragment end)
1199+
{
1200+
// AGE() normalizes the symbolic year/month/day components for both positive and negative spans.
1201+
SQLFragment age = new SQLFragment("AGE((").append(end).append("), (").append(start).append("))");
1202+
return new SQLFragment("((EXTRACT(YEAR FROM ").append(age).append(") * 12) + EXTRACT(MONTH FROM ").append(age)
1203+
.append("))::INT");
1204+
}
1205+
11601206
@Override
11611207
public boolean supportsBatchGeneratedKeys()
11621208
{

api/src/org/labkey/api/data/dialect/SqlDialect.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2107,6 +2107,8 @@ public SQLFragment formatFunction(SQLFragment target, String fn, SQLFragment...
21072107

21082108
public SQLFragment formatJdbcFunction(String fn, SQLFragment... arguments)
21092109
{
2110+
if (fn.equalsIgnoreCase("timestampdiff2"))
2111+
fn = "timestampdiff";
21102112
SQLFragment ret = new SQLFragment();
21112113
ret.append("{fn ");
21122114
formatFunction(ret, fn, arguments);

api/src/org/labkey/api/migration/DatabaseMigrationService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.jetbrains.annotations.Nullable;
66
import org.labkey.api.data.CompareType;
77
import org.labkey.api.data.DbSchemaType;
8+
import org.labkey.api.data.DbSchema;
89
import org.labkey.api.data.SimpleFilter.FilterClause;
910
import org.labkey.api.data.TableInfo;
1011
import org.labkey.api.query.FieldKey;
@@ -17,6 +18,7 @@
1718
import java.util.HashSet;
1819
import java.util.List;
1920
import java.util.Set;
21+
import java.util.function.Consumer;
2022

2123
public interface DatabaseMigrationService
2224
{
@@ -51,6 +53,10 @@ default void registerSchemaHandler(MigrationSchemaHandler schemaHandler) {}
5153
default void registerTableHandler(MigrationTableHandler tableHandler) {}
5254
default void registerMigrationFilter(MigrationFilter filter) {}
5355

56+
// Register a contributor that runs during migration before a schema's tables are processed.
57+
// Useful for modules that need to register table handlers for a schema owned by another module.
58+
default void registerSchemaContributor(String schemaName, Consumer<DbSchema> contributor) {}
59+
5460
default @Nullable MigrationFilter getMigrationFilter(String propertyName)
5561
{
5662
return null;

query/src/org/labkey/query/QueryTestCase.jsp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,32 @@ d,seven,twelve,day,month,date,duration,guid
777777
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),
778778
// NOTE: SQL_TSI_WEEK, SQL_TSI_MONTH, SQL_TSI_QUARTER, and SQL_TSI_YEAR are NYI in PostsgreSQL TIMESTAMPDIFF
779779
780+
// timestampdiff2 - native PostgreSQL implementation for all intervals, returns INTEGER
781+
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),
782+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MINUTE, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 525600),
783+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MINUTE, CAST('01 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2005' AS TIMESTAMP))", JdbcType.INTEGER, 527040),
784+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_HOUR, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 8760),
785+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_HOUR, CAST('01 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2005' AS TIMESTAMP))", JdbcType.INTEGER, 8784),
786+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_DAY, CAST('01 Jan 2003' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 395),
787+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_DAY, CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -395),
788+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_WEEK, CAST('01 Jan 2003' AS TIMESTAMP), CAST('22 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3),
789+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_WEEK, CAST('22 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -3),
790+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Apr 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3),
791+
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),
792+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('15 Jan 2003' AS TIMESTAMP), CAST('14 Apr 2003' AS TIMESTAMP))", JdbcType.INTEGER, 2),
793+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('15 Jan 2003' AS TIMESTAMP), CAST('15 Apr 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3),
794+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('15 Apr 2003' AS TIMESTAMP), CAST('14 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -3),
795+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_QUARTER, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Oct 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3),
796+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_QUARTER, CAST('15 Jan 2003' AS TIMESTAMP), CAST('14 Oct 2003' AS TIMESTAMP))", JdbcType.INTEGER, 2),
797+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_QUARTER, CAST('15 Jan 2003' AS TIMESTAMP), CAST('15 Oct 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3),
798+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_QUARTER, CAST('15 Oct 2003' AS TIMESTAMP), CAST('14 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -3),
799+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_YEAR, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2006' AS TIMESTAMP))", JdbcType.INTEGER, 3),
800+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_YEAR, CAST('15 Jan 2003' AS TIMESTAMP), CAST('14 Jan 2006' AS TIMESTAMP))", JdbcType.INTEGER, 2),
801+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_YEAR, CAST('15 Jan 2003' AS TIMESTAMP), CAST('15 Jan 2006' AS TIMESTAMP))", JdbcType.INTEGER, 3),
802+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_YEAR, CAST('14 Jan 2006' AS TIMESTAMP), CAST('15 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -2),
803+
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),
804+
new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_FRAC_SECOND, CAST('01 Jan 2004' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP))", JdbcType.BIGINT, 2592000000L),
805+
780806
new MethodSqlTest("SELECT UCASE('Fred')", JdbcType.VARCHAR, "FRED"),
781807
new MethodSqlTest("SELECT UPPER('fred')", JdbcType.VARCHAR, "FRED"),
782808
new MethodSqlTest("SELECT USERID()", JdbcType.INTEGER, () -> TestContext.get().getUser().getUserId()),

query/src/org/labkey/query/controllers/LabKeySql.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ Here is a summary of the available functions and methods in LabKey SQL.
198198
#### **Date and Time Functions**
199199
200200
* `age(date1, date2, [interval])`: Supplies the difference in age.
201+
* `age_in_days(date1, date2)`: Returns age in days.
201202
* `age_in_months(date1, date2)`: Returns age in months.
202203
* `age_in_years(date1, date2)`: Returns age in years.
203204
* `curdate()`, `curtime()`: Returns the current date/time.

query/src/org/labkey/query/sql/Method.java

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import org.labkey.query.QueryServiceImpl;
5252
import org.labkey.query.sql.antlr.SqlBaseLexer;
5353

54+
import java.util.Calendar;
5455
import java.lang.reflect.Field;
5556
import java.lang.reflect.Modifier;
5657
import java.text.DecimalFormat;
@@ -94,9 +95,9 @@ public void validate(CommonTree fn, List<QNode> args, List<Exception> parseError
9495
if (text.length() >= 2 && text.startsWith("'") && text.endsWith("'"))
9596
text = text.substring(1, text.length() - 1);
9697
TimestampDiffInterval i = TimestampDiffInterval.parse(text);
97-
if (!(i == TimestampDiffInterval.SQL_TSI_MONTH || i == TimestampDiffInterval.SQL_TSI_YEAR))
98+
if (!(i == TimestampDiffInterval.SQL_TSI_DAY || i == TimestampDiffInterval.SQL_TSI_MONTH || i == TimestampDiffInterval.SQL_TSI_YEAR))
9899
{
99-
parseErrors.add(new QueryParseException("AGE function supports SQL_TSI_YEAR or SQL_TSI_MONTH", null,
100+
parseErrors.add(new QueryParseException("AGE function supports SQL_TSI_DAY, SQL_TSI_MONTH, or SQL_TSI_YEAR", null,
100101
nodeInterval.getLine(), nodeInterval.getColumn()));
101102
}
102103
}
@@ -118,6 +119,14 @@ public MethodInfo getMethodInfo()
118119
return new AgeInYearsMethodInfo();
119120
}
120121
});
122+
labkeyMethod.put("age_in_days", new Method(JdbcType.INTEGER, 2, 2)
123+
{
124+
@Override
125+
public MethodInfo getMethodInfo()
126+
{
127+
return new AgeInDaysMethodInfo();
128+
}
129+
});
121130
labkeyMethod.put("asin", new JdbcMethod("asin", JdbcType.DOUBLE, 1, 1));
122131
labkeyMethod.put("atan", new JdbcMethod("atan", JdbcType.DOUBLE, 1, 1));
123132
labkeyMethod.put("atan2", new JdbcMethod("atan2", JdbcType.DOUBLE, 2, 2));
@@ -428,6 +437,14 @@ public MethodInfo getMethodInfo()
428437
return new TimestampInfo(this);
429438
}
430439
});
440+
labkeyMethod.put("timestampdiff2", new Method("timestampdiff2", JdbcType.INTEGER, 3, 3)
441+
{
442+
@Override
443+
public MethodInfo getMethodInfo()
444+
{
445+
return new TimestampInfo(this);
446+
}
447+
});
431448
labkeyMethod.put("truncate", new JdbcMethod("truncate", JdbcType.DOUBLE, 2, 2));
432449
labkeyMethod.put("ucase", new JdbcMethod("ucase", JdbcType.VARCHAR, 1, 1));
433450
labkeyMethod.put("upper", new JdbcMethod("ucase", JdbcType.VARCHAR, 1, 1));
@@ -889,10 +906,12 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
889906
return new AgeInYearsMethodInfo().getSQL(dialect, arguments);
890907
if (i == TimestampDiffInterval.SQL_TSI_MONTH)
891908
return new AgeInMonthsMethodInfo().getSQL(dialect, arguments);
909+
if (i == TimestampDiffInterval.SQL_TSI_DAY)
910+
return new AgeInDaysMethodInfo().getSQL(dialect, arguments);
892911
if (null == i)
893912
throw new IllegalArgumentException("AGE(" + arguments[2].getSQL() + ")");
894913
else
895-
throw new IllegalArgumentException("AGE only supports YEAR and MONTH");
914+
throw new IllegalArgumentException("AGE only supports DAY, MONTH, and YEAR");
896915
}
897916
}
898917

@@ -972,6 +991,29 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
972991
}
973992

974993

994+
static class AgeInDaysMethodInfo extends AbstractMethodInfo
995+
{
996+
AgeInDaysMethodInfo()
997+
{
998+
super(JdbcType.INTEGER);
999+
}
1000+
1001+
@Override
1002+
public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
1003+
{
1004+
MethodInfo convert = labkeyMethod.get("convert").getMethodInfo();
1005+
SQLFragment dateType = new SQLFragment("DATE");
1006+
SQLFragment startDate = convert.getSQL(dialect, new SQLFragment[]{arguments[0], dateType});
1007+
SQLFragment endDate = convert.getSQL(dialect, new SQLFragment[]{arguments[1], dateType});
1008+
1009+
if (dialect.isPostgreSQL())
1010+
return new SQLFragment("(").append(endDate).append(" - ").append(startDate).append(")");
1011+
1012+
return dialect.getDateDiff(Calendar.DATE, endDate, startDate);
1013+
}
1014+
}
1015+
1016+
9751017
static class StartsWithInfo extends AbstractMethodInfo
9761018
{
9771019
StartsWithInfo()

query/src/org/labkey/query/sql/QuerySelect.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2264,7 +2264,9 @@ SQLFragment getInternalSql()
22642264
QExpr expr = getResolvedField();
22652265

22662266
// NOTE SqlServer does not like predicates (A=B) in select list, try to help out
2267-
if (expr instanceof QMethodCall && expr.getJdbcType() == JdbcType.BOOLEAN && b.getDialect().isSqlServer())
2267+
// Exclude CAST/CONVERT expressions — they produce BIT values, not boolean predicates
2268+
if (expr instanceof QMethodCall mc && mc.getJdbcType() == JdbcType.BOOLEAN && b.getDialect().isSqlServer()
2269+
&& !(mc.getMethod(b.getDialect()) instanceof Method.ConvertInfo))
22682270
{
22692271
b.append("CASE WHEN (");
22702272
expr.appendSql(b, _query);

query/src/org/labkey/query/sql/SqlParser.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -995,7 +995,7 @@ else if (divisorType==NUM_DOUBLE || divisorType==NUM_FLOAT || divisorType==NUM_I
995995
}
996996
exprList._replaceChildren(new LinkedList<>(List.of(valueExpression, type)));
997997
}
998-
else if (name.equals("timestampadd") || name.equals("timestampdiff"))
998+
else if (name.equals("timestampadd") || name.equals("timestampdiff") || name.equals("timestampdiff2"))
999999
{
10001000
if (!(exprList instanceof QExprList) || exprList.childList().size() != 3)
10011001
{
@@ -1945,6 +1945,7 @@ class delete elements fetch indices insert into limit new set update versioned b
19451945
"SELECT TIMESTAMPDIFF(SQL_TSI_SECOND,a,b), TIMESTAMPDIFF(SECOND,a,b), TIMESTAMPDIFF('SQL_TSI_DAY',a,b), TIMESTAMPDIFF('DAY',a,b) FROM R",
19461946
"SELECT TIMESTAMPDIFF('SQL_TSI_Second',a,b), TIMESTAMPDIFF('Second',a,b), TIMESTAMPDIFF('SQL_TSI_Day',a,b), TIMESTAMPDIFF('Day',a,b) FROM R",
19471947
"SELECT TIMESTAMPADD(SQL_TSI_SECOND,1,b), TIMESTAMPADD(SECOND,1,b), TIMESTAMPADD('SQL_TSI_DAY',1,b), TIMESTAMPADD('DAY',1,b) FROM R",
1948+
"SELECT TIMESTAMPDIFF2(SQL_TSI_SECOND,a,b), TIMESTAMPDIFF2('SQL_TSI_DAY',a,b), TIMESTAMPDIFF2('MONTH',a,b), TIMESTAMPDIFF2('YEAR',a,b) FROM R",
19481949

19491950
"SELECT (SELECT value FROM S WHERE S.x=R.x) AS V FROM R",
19501951
"SELECT R.value AS V FROM R WHERE R.y > (SELECT MAX(S.y) FROM S WHERE S.x=R.x)",

0 commit comments

Comments
 (0)