Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,12 @@ public boolean supportsNativeGreatestAndLeast()
return true;
}

@Override
public boolean supportsNativeIsDistinctFrom()
{
return true;
}

@Override
public boolean supportsIsNumeric()
{
Expand Down
11 changes: 11 additions & 0 deletions api/src/org/labkey/api/data/dialect/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,17 @@ public SQLFragment isNumericExpr(SQLFragment expression)
throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement");
}

/**
* Does the dialect natively support the standard "IS [NOT] DISTINCT FROM" predicate? PostgreSQL and Snowflake
* do; SQL Server, MySQL, and Oracle do not (SQL Server added GREATEST/LEAST in recent versions but has never
* added this predicate). Dialects that return false here get a portable CASE-based rewrite instead; see
* Method.IsDistinctFromMethodInfo.
*/
public boolean supportsNativeIsDistinctFrom()
{
return false;
}

public void handleCreateDatabaseException(SQLException e) throws ServletException
{
throw(new ServletException("Can't create database", e));
Expand Down
26 changes: 14 additions & 12 deletions query/src/org/labkey/query/QueryTestCase.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,7 @@ d,seven,twelve,day,month,date,duration,guid
assertNull(rs.getColumn(1).getFormat());
}
},
new SqlTest("SELECT 1 AS name @hidden'")
new SqlTest("SELECT 1 AS name @hidden")
{
@Override
protected void validateResults(Results rs) throws Exception
Expand All @@ -914,18 +914,10 @@ d,seven,twelve,day,month,date,duration,guid
},

// test operators in THEN expression
new SqlTest("SELECT CASE WHEN 1=1 THEN 'a' || 'b' ELSE 'x' || 'y' END")
);

List<SqlTest> postgres = List.of(
// ORDER BY tests
new SqlTest("SELECT R.day, R.month, R.date FROM R ORDER BY R.date", 3, Rsize),
new SqlTest("SELECT R.day, R.month, R.date FROM R UNION SELECT R.day, R.month, R.date FROM R ORDER BY date"),
new SqlTest("SELECT R.guid FROM R WHERE overlaps(CAST('2001-01-01' AS DATE), CAST('2001-01-10' AS DATE), CAST('2001-01-05' AS DATE), CAST('2001-01-15' AS DATE))", 1, Rsize),

// regression test: field reference in sub-select (https://www.labkey.org/home/Developer/issues/issues-details.view?issueId=43580)
new SqlTest("SELECT (SELECT GROUP_CONCAT(b.displayname, ', ') FROM core.UsersAndGroups b WHERE b.email IN (SELECT UNNEST(STRING_TO_ARRAY(a.title, ',')))) AS procedurename, a.parent.rowid FROM core.containers a ", 2, 1),
new SqlTest("SELECT CASE WHEN 1=1 THEN 'a' || 'b' ELSE 'x' || 'y' END"),

// is_distinct_from()/is_not_distinct_from() are portable: native "IS [NOT] DISTINCT FROM" on PostgreSQL and
// Snowflake, a CASE-based rewrite elsewhere (e.g. SQL Server) -- see IsDistinctFromMethodInfo.getSQL()
new MethodSqlTest("SELECT is_distinct_from(NULL,NULL)", JdbcType.BOOLEAN, false),
new MethodSqlTest("SELECT is_not_distinct_from(NULL,NULL)", JdbcType.BOOLEAN, true),
new MethodSqlTest("SELECT is_distinct_from(1,NULL)", JdbcType.BOOLEAN, true),
Expand All @@ -936,6 +928,16 @@ d,seven,twelve,day,month,date,duration,guid
new MethodSqlTest("SELECT is_not_distinct_from(1,2)", JdbcType.BOOLEAN, false)
);

List<SqlTest> postgres = List.of(
// ORDER BY tests
new SqlTest("SELECT R.day, R.month, R.date FROM R ORDER BY R.date", 3, Rsize),
new SqlTest("SELECT R.day, R.month, R.date FROM R UNION SELECT R.day, R.month, R.date FROM R ORDER BY date"),
new SqlTest("SELECT R.guid FROM R WHERE overlaps(CAST('2001-01-01' AS DATE), CAST('2001-01-10' AS DATE), CAST('2001-01-05' AS DATE), CAST('2001-01-15' AS DATE))", 1, Rsize),

// regression test: field reference in sub-select (https://www.labkey.org/home/Developer/issues/issues-details.view?issueId=43580)
new SqlTest("SELECT (SELECT GROUP_CONCAT(b.displayname, ', ') FROM core.UsersAndGroups b WHERE b.email IN (SELECT UNNEST(STRING_TO_ARRAY(a.title, ',')))) AS procedurename, a.parent.rowid FROM core.containers a ", 2, 1)
);

List<SqlTest> postgresOnlyFunctions()
{
int majorVersion = ((BasePostgreSqlDialect) CoreSchema.getInstance().getSqlDialect()).getMajorVersion();
Expand Down
639 changes: 295 additions & 344 deletions query/src/org/labkey/query/controllers/prompts/LabKeySql.md

Large diffs are not rendered by default.

127 changes: 103 additions & 24 deletions query/src/org/labkey/query/sql/Method.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
import org.labkey.api.data.CompareType;
import org.labkey.api.data.Container;
import org.labkey.api.data.CoreSchema;
import org.labkey.api.data.DbScope;
import org.labkey.api.data.JdbcType;
import org.labkey.api.data.MethodInfo;
import org.labkey.api.data.MutableColumnInfo;
import org.labkey.api.data.SQLFragment;
import org.labkey.api.data.SqlSelector;
import org.labkey.api.data.TableInfo;
import org.labkey.api.data.dialect.SqlDialect;
import org.labkey.api.module.Module;
Expand Down Expand Up @@ -167,7 +169,7 @@ public MethodInfo getMethodInfo()
labkeyMethod.put("cos", new JdbcMethod("cos", JdbcType.DOUBLE, 1, 1));
labkeyMethod.put("cot", new JdbcMethod("cot", JdbcType.DOUBLE, 1, 1));
labkeyMethod.put("curdate", new JdbcMethod("curdate", JdbcType.DATE, 0, 0));
labkeyMethod.put("curtime", new JdbcMethod("curtime", JdbcType.DATE, 0, 0));
labkeyMethod.put("curtime", new JdbcMethod("curtime", JdbcType.TIME, 0, 0));
labkeyMethod.put("dayofmonth", new JdbcMethod("dayofmonth", JdbcType.INTEGER, 1, 1));
labkeyMethod.put("dayofweek", new JdbcMethod("dayofweek", JdbcType.INTEGER, 1, 1));
labkeyMethod.put("dayofyear", new JdbcMethod("dayofyear", JdbcType.INTEGER, 1, 1));
Expand Down Expand Up @@ -214,6 +216,22 @@ public JdbcType getJdbcType(JdbcType[] args)
};
}
});
labkeyMethod.put("is_distinct_from", new Method(JdbcType.BOOLEAN, 2, 2)
{
@Override
public MethodInfo getMethodInfo()
{
return new IsDistinctFromMethodInfo(IS);
}
});
labkeyMethod.put("is_not_distinct_from", new Method(JdbcType.BOOLEAN, 2, 2)
{
@Override
public MethodInfo getMethodInfo()
{
return new IsDistinctFromMethodInfo(IS_NOT);
}
});
labkeyMethod.put("isequal", new Method("isequal", JdbcType.BOOLEAN, 2, 2)
{
@Override
Expand Down Expand Up @@ -1843,24 +1861,6 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
postgresMethods.put("jsonb_path_query_tz", new PassthroughMethod("jsonb_path_query_tz", JdbcType.VARCHAR, 2, 4));
postgresMethods.put("jsonb_path_query_array_tz", new PassthroughMethod("jsonb_path_query_array_tz", JdbcType.VARCHAR, 2, 4));
postgresMethods.put("jsonb_path_query_first_tz", new PassthroughMethod("jsonb_path_query_first_tz", JdbcType.VARCHAR, 2, 4));

// "is distinct from" and "is not distinct from" operators in method form
labkeyMethod.put("is_distinct_from", new Method(JdbcType.BOOLEAN, 2, 2)
{
@Override
public MethodInfo getMethodInfo()
{
return new IsDistinctFromMethodInfo(IS);
}
});
labkeyMethod.put("is_not_distinct_from", new Method(JdbcType.BOOLEAN, 2, 2)
{
@Override
public MethodInfo getMethodInfo()
{
return new IsDistinctFromMethodInfo(IS_NOT);
}
});
}

private static class IsDistinctFromMethodInfo extends AbstractMethodInfo
Expand All @@ -1876,13 +1876,37 @@ private static class IsDistinctFromMethodInfo extends AbstractMethodInfo
@Override
public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments)
{
SQLFragment a = arguments[0];
SQLFragment b = arguments[1];
SQLFragment ret = new SQLFragment();
ret.append(" ((").append(arguments[0]).append(")");
if (token == IS)
ret.append(" IS DISTINCT FROM ");

if (dialect.supportsNativeIsDistinctFrom())
{
ret.append(" ((").append(a).append(")");
if (token == IS)
ret.append(" IS DISTINCT FROM ");
else
ret.append(" IS NOT DISTINCT FROM ");
ret.append("(").append(b).append(")) ");
}
else
ret.append(" IS NOT DISTINCT FROM ");
ret.append("(").append(arguments[1]).append(")) ");
{
// "IS [NOT] DISTINCT FROM" isn't standard/portable SQL -- it's native only on PostgreSQL-family and
// Snowflake dialects (and recent SQL Server). Elsewhere rewrite as a CASE expression that always
// evaluates to a real TRUE/FALSE -- never NULL, even when exactly one side is null -- so it behaves
// the same as the native predicate would.

// This is more complicated than the obvious (a=b or a is null and b is null).
// That expression can return NULL, we need to return only TRUE/FALSE.
if (token == IS)
ret.append(" NOT ");
ret.append("(");
ret.append("((").append(a).append(") IS NOT NULL AND (").append(b).append(") IS NOT NULL AND (").append(a).append(")=(").append(b).append("))");
ret.append(" OR ");
ret.append("((").append(a).append(") IS NULL AND (").append(b).append(") IS NULL)");
ret.append(")");
}

return ret;
}
}
Expand Down Expand Up @@ -2013,5 +2037,60 @@ public void testSimpleString()
assertNotSimpleString(new SQLFragment("SELECT 'test'"));
assertNotSimpleString(new SQLFragment("'test''string'"));
}

// Exercises both the native and portable-fallback branches of IsDistinctFromMethodInfo.getSQL() against every
// dialect that's actually connected in this environment, not just whichever dialect the current CI leg happens
// to be running against. A FROM-less SELECT works everywhere except Oracle, which requires FROM DUAL.
//
// Covers both usage contexts: as a selected boolean value (where three-valued logic would otherwise leak NULL
// for the one-null-argument case) and as a WHERE clause filter (where three-valued logic normally treats NULL
// as non-matching, so this instead confirms the CASE-based rewrite still evaluates to a real TRUE/FALSE there).
@Test
public void testIsDistinctFrom()
{
record Case(String a, String b, boolean distinct) {}
List<Case> cases = List.of(
new Case("1", "2", true),
new Case("1", "1", false),
new Case("NULL", "NULL", false),
new Case("1", "NULL", true)
);

for (DbScope scope : DbScope.getDbScopesToTest())
{
SqlDialect d = scope.getSqlDialect();

for (Case c : cases)
{
assertIsDistinctFrom(scope, d, IS, c.a(), c.b(), c.distinct());
assertIsDistinctFrom(scope, d, IS_NOT, c.a(), c.b(), !c.distinct());
assertIsDistinctFromWhere(scope, d, IS, c.a(), c.b(), c.distinct());
assertIsDistinctFromWhere(scope, d, IS_NOT, c.a(), c.b(), !c.distinct());
}
}
}

private void assertIsDistinctFrom(DbScope scope, SqlDialect d, int token, String a, String b, boolean expected)
{
SQLFragment expr = new IsDistinctFromMethodInfo(token).getSQL(d, new SQLFragment[]{new SQLFragment(a), new SQLFragment(b)});
SQLFragment T = new SQLFragment(d.getBooleanTRUE()), F = new SQLFragment(d.getBooleanFALSE());
SQLFragment sql = new SQLFragment("SELECT ").append("CASE WHEN ").append(expr).append(" THEN ").append(T).append(" ELSE ").append(F).append(" END");
if (d.isOracle())
sql.append(" FROM DUAL");
Boolean result = new SqlSelector(scope, sql).getObject(Boolean.class);
assertEquals(d.getClass().getSimpleName() + ": " + sql.toDebugString(), expected, result);
}

private void assertIsDistinctFromWhere(DbScope scope, SqlDialect d, int token, String a, String b, boolean expected)
{
SQLFragment expr = new IsDistinctFromMethodInfo(token).getSQL(d, new SQLFragment[]{new SQLFragment(a), new SQLFragment(b)});
SQLFragment sql = new SQLFragment("SELECT 1");
if (d.isOracle())
sql.append(" FROM DUAL");
sql.append(" WHERE ").append(expr);

boolean matched = new SqlSelector(scope, sql).exists();
assertEquals(d.getClass().getSimpleName() + " (WHERE): " + sql.toDebugString(), expected, matched);
}
}
}
4 changes: 3 additions & 1 deletion query/src/org/labkey/query/sql/QMethodCall.java
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ public QueryParseException fieldCheck(QNode parent, SqlDialect d)
{
if (getMethod(d) == null)
{
return new QueryParseException("Unknown method " + getField().getName(), null, getLine(), getColumn());
String name = getField().getName();
String hint = SqlParser.forUnknownMethod(name, d);
return new QueryParseException("Unknown method " + name + (null == hint ? "" : ". " + hint), null, getLine(), getColumn());
}
return null;
}
Expand Down
21 changes: 11 additions & 10 deletions query/src/org/labkey/query/sql/SqlBase.g
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ tokens
@lexer::header
{
package org.labkey.query.sql.antlr;

import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;
}


Expand Down Expand Up @@ -134,17 +131,14 @@ tokens

@lexer::members
{
Logger _log = LogManager.getLogger(org.labkey.query.sql.SqlParser.class);

protected void setPossibleID(boolean possibleID)
{
}

@Override
public void emitErrorMessage(String msg)
{
_log.debug(msg);
}
// NOTE: lexer errors are reported via reportError(), which SqlParser._SqlLexer overrides to collect
// them as parse errors. Always use _SqlLexer rather than instantiating SqlBaseLexer directly -- the
// default ANTLR emitErrorMessage() prints unmatchable input to System.err and then drops it, letting
// the remaining characters re-lex into a different, valid-looking query.
}


Expand Down Expand Up @@ -180,6 +174,9 @@ REGR_SXY : 'regr_sxy';
REGR_SYY : 'regr_syy';
COUNT : 'count';
CROSS : 'cross';
CURRENT_DATE : 'current_date';
CURRENT_TIME : 'current_time';
CURRENT_TIMESTAMP : 'current_timestamp';
DELETE : 'delete';
DISTINCT : 'distinct';
DOT : '.';
Expand Down Expand Up @@ -761,6 +758,10 @@ starAtom
primaryExpression
: ARRAY exprList ']' -> ^(METHOD_CALL IDENT["ARRAY_CONSTRUCT"] exprList)
| TEXTARRAY exprList ']' -> ^(METHOD_CALL IDENT["TEXTARRAY_CONSTRUCT"] exprList)
// SQL-standard niladic datetime keywords -- no parens allowed; sugar for curdate()/curtime()/now()
| CURRENT_DATE -> ^(METHOD_CALL IDENT["CURDATE"] ^(EXPR_LIST))
| CURRENT_TIME -> ^(METHOD_CALL IDENT["CURTIME"] ^(EXPR_LIST))
| CURRENT_TIMESTAMP -> ^(METHOD_CALL IDENT["NOW"] ^(EXPR_LIST))
| id=identPrimary
| constant
| OPEN! ( expression | subQuery) CLOSE!
Expand Down
Loading