Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude is complaining about this BIGINT vs. Method defining timestampdiff2 as returning JdbcType.INTEGER.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In limited local testing, I didn't see any problem with very large values, though.

default -> throw new IllegalArgumentException("Unsupported interval for timestampdiff2: " + interval);
};
}

private SQLFragment getWholeElapsedMonths(SQLFragment start, SQLFragment end)
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method assumes that start and end don't include any parameters. Current callers don't, but best to add a comment and an invariant check for that, i.e., throw if either has parameters.

// 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()
{
Expand Down
2 changes: 2 additions & 0 deletions api/src/org/labkey/api/data/dialect/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;

public interface DatabaseMigrationService
{
Expand Down Expand Up @@ -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<DbSchema> contributor) {}

default @Nullable MigrationFilter getMigrationFilter(String propertyName)
{
return null;
Expand Down
26 changes: 26 additions & 0 deletions query/src/org/labkey/query/QueryTestCase.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add test for age_in_days()?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And new age() option?


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()),
Expand Down
1 change: 1 addition & 0 deletions query/src/org/labkey/query/controllers/LabKeySql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 45 additions & 3 deletions query/src/org/labkey/query/sql/Method.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,9 +95,9 @@ public void validate(CommonTree fn, List<QNode> args, List<Exception> 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()));
}
}
Expand All @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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");
}
}

Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion query/src/org/labkey/query/sql/QuerySelect.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion query/src/org/labkey/query/sql/SqlParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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)",
Expand Down
Loading