From 09e27159dd7f0fe8e2a9cf07f885e734c0459e25 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sun, 1 Mar 2026 09:16:50 -0800 Subject: [PATCH 01/10] Don't do the case statement when casting boolean value. --- query/src/org/labkey/query/sql/QuerySelect.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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); From 42a9e645212526ef7834ddc2405d34fbb1aeac6b Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 9 Mar 2026 07:05:45 -0700 Subject: [PATCH 02/10] timestampdiff2 --- .../data/dialect/BasePostgreSqlDialect.java | 35 +++++++++++++++++++ .../labkey/api/data/dialect/SqlDialect.java | 2 ++ query/src/org/labkey/query/QueryTestCase.jsp | 14 ++++++++ query/src/org/labkey/query/sql/Method.java | 8 +++++ query/src/org/labkey/query/sql/SqlParser.java | 3 +- 5 files changed, 61 insertions(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java index 0ecd40466c3..23a0dd97e28 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,39 @@ 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. + */ + private SQLFragment timestampdiff2(SQLFragment... arguments) + { + String interval = arguments[0].getSQL(); + SQLFragment start = arguments[1]; + SQLFragment end = arguments[2]; + + return switch (interval) + { + case "SQL_TSI_YEAR" -> + new SQLFragment("(EXTRACT(YEAR FROM (").append(end).append(")) - EXTRACT(YEAR FROM (").append(start).append(")))::INT"); + case "SQL_TSI_QUARTER" -> + new SQLFragment("((EXTRACT(YEAR FROM (").append(end).append(")) - EXTRACT(YEAR FROM (").append(start).append("))) * 4 + EXTRACT(QUARTER FROM (").append(end).append(")) - EXTRACT(QUARTER FROM (").append(start).append(")))::INT"); + case "SQL_TSI_MONTH" -> + new SQLFragment("((EXTRACT(YEAR FROM (").append(end).append(")) - EXTRACT(YEAR FROM (").append(start).append("))) * 12 + EXTRACT(MONTH FROM (").append(end).append(")) - EXTRACT(MONTH FROM (").append(start).append(")))::INT"); + 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)::INT"); + default -> throw new IllegalArgumentException("Unsupported interval for timestampdiff2: " + interval); + }; + } + @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/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index 1831ba89ada..e715011b5e1 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -777,6 +777,20 @@ 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_MONTH, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Apr 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_YEAR, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Jan 2006' AS TIMESTAMP))", JdbcType.INTEGER, 3), + 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.INTEGER, 1000), + 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/sql/Method.java b/query/src/org/labkey/query/sql/Method.java index b6377660675..ba044ace372 100644 --- a/query/src/org/labkey/query/sql/Method.java +++ b/query/src/org/labkey/query/sql/Method.java @@ -428,6 +428,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)); 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)", From d054412e6ec902b94660b9daa821c3133f8314ac Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 9 Mar 2026 08:10:30 -0700 Subject: [PATCH 03/10] Better calculation of year, month, quarter --- .../data/dialect/BasePostgreSqlDialect.java | 37 +++++++++++++++++-- query/src/org/labkey/query/QueryTestCase.jsp | 9 +++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java index 23a0dd97e28..5aacb8bdd57 100644 --- a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java @@ -1167,15 +1167,18 @@ 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("(EXTRACT(YEAR FROM (").append(end).append(")) - EXTRACT(YEAR FROM (").append(start).append(")))::INT"); + new SQLFragment("TRUNC((").append(wholeMonths).append(")::NUMERIC / 12)::INT"); case "SQL_TSI_QUARTER" -> - new SQLFragment("((EXTRACT(YEAR FROM (").append(end).append(")) - EXTRACT(YEAR FROM (").append(start).append("))) * 4 + EXTRACT(QUARTER FROM (").append(end).append(")) - EXTRACT(QUARTER FROM (").append(start).append(")))::INT"); + new SQLFragment("TRUNC((").append(wholeMonths).append(")::NUMERIC / 3)::INT"); case "SQL_TSI_MONTH" -> - new SQLFragment("((EXTRACT(YEAR FROM (").append(end).append(")) - EXTRACT(YEAR FROM (").append(start).append("))) * 12 + EXTRACT(MONTH FROM (").append(end).append(")) - EXTRACT(MONTH FROM (").append(start).append(")))::INT"); + wholeMonths; case "SQL_TSI_WEEK" -> new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) / 604800)::INT"); case "SQL_TSI_DAY" -> @@ -1192,6 +1195,34 @@ private SQLFragment timestampdiff2(SQLFragment... arguments) }; } + private SQLFragment getWholeElapsedMonths(SQLFragment start, SQLFragment end) + { + SQLFragment baseMonths = new SQLFragment("((EXTRACT(YEAR FROM (").append(end).append(")) - EXTRACT(YEAR FROM (") + .append(start).append("))) * 12 + EXTRACT(MONTH FROM (").append(end).append(")) - EXTRACT(MONTH FROM (") + .append(start).append(")))::INT"); + SQLFragment endBeforeStartInMonth = isSubMonthPartBefore(end, start); + SQLFragment endAfterStartInMonth = isSubMonthPartBefore(start, end); + + // baseMonths counts calendar month boundaries. Adjust away any trailing partial month so the result + // reflects only whole elapsed months, while still truncating toward zero for negative differences. + return new SQLFragment("(CASE WHEN (").append(baseMonths).append(") > 0 AND ").append(endBeforeStartInMonth) + .append(" THEN (").append(baseMonths).append(" - 1) WHEN (").append(baseMonths).append(") < 0 AND ") + .append(endAfterStartInMonth).append(" THEN (").append(baseMonths).append(" + 1) ELSE ") + .append(baseMonths).append(" END)"); + } + + private SQLFragment isSubMonthPartBefore(SQLFragment left, SQLFragment right) + { + SQLFragment leftTimeOfDay = new SQLFragment("EXTRACT(EPOCH FROM ((").append(left).append(") - DATE_TRUNC('day', (") + .append(left).append("))))"); + SQLFragment rightTimeOfDay = new SQLFragment("EXTRACT(EPOCH FROM ((").append(right).append(") - DATE_TRUNC('day', (") + .append(right).append("))))"); + + return new SQLFragment("((EXTRACT(DAY FROM (").append(left).append(")) < EXTRACT(DAY FROM (").append(right) + .append("))) OR (EXTRACT(DAY FROM (").append(left).append(")) = EXTRACT(DAY FROM (").append(right) + .append(")) AND (").append(leftTimeOfDay).append(") < (").append(rightTimeOfDay).append(")))"); + } + @Override public boolean supportsBatchGeneratedKeys() { diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index e715011b5e1..e70aa5e9c48 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -787,8 +787,17 @@ d,seven,twelve,day,month,date,duration,guid 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_MONTH, CAST('01 Jan 2003' AS TIMESTAMP), CAST('01 Apr 2003' AS TIMESTAMP))", JdbcType.INTEGER, 3), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_MONTH, CAST('31 Jan 2003 23:59' AS TIMESTAMP), CAST('01 Feb 2003 00:00' AS TIMESTAMP))", JdbcType.INTEGER, 0), + 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_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_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('15 Jan 2006' AS TIMESTAMP), CAST('14 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.INTEGER, 1000), new MethodSqlTest("SELECT UCASE('Fred')", JdbcType.VARCHAR, "FRED"), From 25aa55b27eee641b6798084392c99c8e0300dec4 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 9 Mar 2026 08:30:11 -0700 Subject: [PATCH 04/10] Handle type overflow --- api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java | 2 +- query/src/org/labkey/query/QueryTestCase.jsp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java index 5aacb8bdd57..697d6a86f35 100644 --- a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java @@ -1190,7 +1190,7 @@ private SQLFragment timestampdiff2(SQLFragment... arguments) 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)::INT"); + new SQLFragment("TRUNC(EXTRACT(EPOCH FROM (").append(end).append(") - (").append(start).append(")) * 1000)::BIGINT"); default -> throw new IllegalArgumentException("Unsupported interval for timestampdiff2: " + interval); }; } diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index e70aa5e9c48..cb38f8f1c9f 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -799,6 +799,7 @@ d,seven,twelve,day,month,date,duration,guid 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('15 Jan 2006' AS TIMESTAMP), CAST('14 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.INTEGER, 1000), + new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_FRAC_SECOND, CAST('01 Jan 2004' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 2592000000L), new MethodSqlTest("SELECT UCASE('Fred')", JdbcType.VARCHAR, "FRED"), new MethodSqlTest("SELECT UPPER('fred')", JdbcType.VARCHAR, "FRED"), From 3b2b28be47baac52e0753ec4d7b1f6fa6189e683 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 9 Mar 2026 09:46:57 -0700 Subject: [PATCH 05/10] Simplify with AGE. Fix test case. --- .../data/dialect/BasePostgreSqlDialect.java | 28 +++---------------- query/src/org/labkey/query/QueryTestCase.jsp | 2 +- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java index 697d6a86f35..ab886db944a 100644 --- a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java @@ -1197,30 +1197,10 @@ private SQLFragment timestampdiff2(SQLFragment... arguments) private SQLFragment getWholeElapsedMonths(SQLFragment start, SQLFragment end) { - SQLFragment baseMonths = new SQLFragment("((EXTRACT(YEAR FROM (").append(end).append(")) - EXTRACT(YEAR FROM (") - .append(start).append("))) * 12 + EXTRACT(MONTH FROM (").append(end).append(")) - EXTRACT(MONTH FROM (") - .append(start).append(")))::INT"); - SQLFragment endBeforeStartInMonth = isSubMonthPartBefore(end, start); - SQLFragment endAfterStartInMonth = isSubMonthPartBefore(start, end); - - // baseMonths counts calendar month boundaries. Adjust away any trailing partial month so the result - // reflects only whole elapsed months, while still truncating toward zero for negative differences. - return new SQLFragment("(CASE WHEN (").append(baseMonths).append(") > 0 AND ").append(endBeforeStartInMonth) - .append(" THEN (").append(baseMonths).append(" - 1) WHEN (").append(baseMonths).append(") < 0 AND ") - .append(endAfterStartInMonth).append(" THEN (").append(baseMonths).append(" + 1) ELSE ") - .append(baseMonths).append(" END)"); - } - - private SQLFragment isSubMonthPartBefore(SQLFragment left, SQLFragment right) - { - SQLFragment leftTimeOfDay = new SQLFragment("EXTRACT(EPOCH FROM ((").append(left).append(") - DATE_TRUNC('day', (") - .append(left).append("))))"); - SQLFragment rightTimeOfDay = new SQLFragment("EXTRACT(EPOCH FROM ((").append(right).append(") - DATE_TRUNC('day', (") - .append(right).append("))))"); - - return new SQLFragment("((EXTRACT(DAY FROM (").append(left).append(")) < EXTRACT(DAY FROM (").append(right) - .append("))) OR (EXTRACT(DAY FROM (").append(left).append(")) = EXTRACT(DAY FROM (").append(right) - .append(")) AND (").append(leftTimeOfDay).append(") < (").append(rightTimeOfDay).append(")))"); + // 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 diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index cb38f8f1c9f..9745b08c451 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -797,7 +797,7 @@ d,seven,twelve,day,month,date,duration,guid 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('15 Jan 2006' AS TIMESTAMP), CAST('14 Jan 2003' AS TIMESTAMP))", JdbcType.INTEGER, -2), + 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.INTEGER, 1000), new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_FRAC_SECOND, CAST('01 Jan 2004' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 2592000000L), From cb2aaee88232647feddadf55fdc269fe1b7da3ae Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 9 Mar 2026 09:53:12 -0700 Subject: [PATCH 06/10] More negative value tests --- query/src/org/labkey/query/QueryTestCase.jsp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index 9745b08c451..3e837c76e4b 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -786,14 +786,17 @@ d,seven,twelve,day,month,date,duration,guid 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('31 Jan 2003 23:59' AS TIMESTAMP), CAST('01 Feb 2003 00:00' AS TIMESTAMP))", JdbcType.INTEGER, 0), 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), From e7ee897400bad7a1962063eab44b36574aff55d4 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 10 Mar 2026 17:30:07 -0700 Subject: [PATCH 07/10] Test doesn't work on sql server --- query/src/org/labkey/query/QueryTestCase.jsp | 1 - 1 file changed, 1 deletion(-) diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index 3e837c76e4b..140a92c809e 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -788,7 +788,6 @@ d,seven,twelve,day,month,date,duration,guid 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('31 Jan 2003 23:59' AS TIMESTAMP), CAST('01 Feb 2003 00:00' AS TIMESTAMP))", JdbcType.INTEGER, 0), 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), From dc4bd6116c759b854946f0c3d7f0cc74cb9033e2 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 31 Mar 2026 12:59:05 -0700 Subject: [PATCH 08/10] age_in_days --- .../org/labkey/query/controllers/LabKeySql.md | 1 + query/src/org/labkey/query/sql/Method.java | 40 +++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) 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 ba044ace372..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)); @@ -897,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"); } } @@ -980,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() From c070adcf2de3078416bd4a1d6056f45093f06f27 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 31 Mar 2026 12:59:22 -0700 Subject: [PATCH 09/10] Migration service update --- .../org/labkey/api/migration/DatabaseMigrationService.java | 6 ++++++ 1 file changed, 6 insertions(+) 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; From dc9a8e75b7fef46ee219da5f91aa93875e15f868 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 3 Apr 2026 10:24:37 -0700 Subject: [PATCH 10/10] Update test type --- query/src/org/labkey/query/QueryTestCase.jsp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index 140a92c809e..1fb73e95560 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -800,8 +800,8 @@ d,seven,twelve,day,month,date,duration,guid 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.INTEGER, 1000), - new MethodSqlTest("SELECT TIMESTAMPDIFF2(SQL_TSI_FRAC_SECOND, CAST('01 Jan 2004' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP))", JdbcType.INTEGER, 2592000000L), + 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"),