From 1bd7543b2aaf5e5209e1350016af01c30e0f23a5 Mon Sep 17 00:00:00 2001 From: Binal Patel Date: Wed, 6 May 2026 22:43:41 -0700 Subject: [PATCH 1/5] Add isNumeric() to LabKey SQL - emits ISNUMERIC(x) on SQL Server and a regex-based CASE on PostgreSQL --- query/src/org/labkey/query/sql/Method.java | 37 +++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/query/src/org/labkey/query/sql/Method.java b/query/src/org/labkey/query/sql/Method.java index 8789835eb94..5ce71dc322e 100644 --- a/query/src/org/labkey/query/sql/Method.java +++ b/query/src/org/labkey/query/sql/Method.java @@ -230,6 +230,14 @@ public MethodInfo getMethodInfo() return new IsMemberInfo(); } }); + labkeyMethod.put("isnumeric", new Method("isnumeric", JdbcType.BOOLEAN, 1, 1) + { + @Override + public MethodInfo getMethodInfo() + { + return new IsNumericInfo(); + } + }); labkeyMethod.put("javaconstant", new Method("javaconstant", JdbcType.VARBINARY, 1, 1) { @Override @@ -1067,6 +1075,33 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments) } } + // Portable isnumeric() — emits ISNUMERIC(x) on SQL Server and a regex-based CASE on PostgreSQL. + // Returns 1 when the input is numeric (digits with optional sign / decimal point), 0 otherwise. + // NULL inputs return 0 to match SQL Server's ISNUMERIC behavior. + static class IsNumericInfo extends AbstractMethodInfo + { + IsNumericInfo() + { + super(JdbcType.BOOLEAN); + } + + @Override + public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments) + { + SQLFragment arg = arguments[0]; + if (dialect.isSqlServer()) + { + return new SQLFragment("ISNUMERIC(").append(arg).append(")"); + } + if (dialect.isPostgreSQL()) + { + return new SQLFragment("(CASE WHEN (").append(arg) + .append(") ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$' THEN 1 ELSE 0 END)"); + } + throw new IllegalStateException("isnumeric() is not supported for this database dialect: " + dialect.getProductName()); + } + } + static class VersionMethodInfo extends AbstractMethodInfo { VersionMethodInfo() @@ -1874,7 +1909,7 @@ private static void addJsonPassthroughMethod(String name, JdbcType type, int min mssqlMethods.put("charindex", new PassthroughMethod("charindex", JdbcType.INTEGER, 2, 3)); mssqlMethods.put("concat_ws", new PassthroughMethod("concat_ws", JdbcType.VARCHAR, 1, Integer.MAX_VALUE)); mssqlMethods.put("difference", new PassthroughMethod("difference", JdbcType.INTEGER, 2, 2)); - mssqlMethods.put("isnumeric", new PassthroughMethod("isnumeric", JdbcType.BOOLEAN, 1, 1)); + // isnumeric is registered in labkeyMethod (portable across PostgreSQL and SQL Server) mssqlMethods.put("len", new PassthroughMethod("len", JdbcType.INTEGER, 1, 1)); mssqlMethods.put("patindex", new PassthroughMethod("patindex", JdbcType.INTEGER, 2, 2)); mssqlMethods.put("quotename", new PassthroughMethod("quotename", JdbcType.VARCHAR, 1, 2)); From aacef1acf24b01569d2880a7539b439d09d83812 Mon Sep 17 00:00:00 2001 From: Binal Patel Date: Tue, 12 May 2026 14:09:59 -0700 Subject: [PATCH 2/5] add 'right' to labkey sql. --- query/src/org/labkey/query/sql/Method.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/query/src/org/labkey/query/sql/Method.java b/query/src/org/labkey/query/sql/Method.java index 5ce71dc322e..2e5af532188 100644 --- a/query/src/org/labkey/query/sql/Method.java +++ b/query/src/org/labkey/query/sql/Method.java @@ -383,6 +383,7 @@ public MethodInfo getMethodInfo() labkeyMethod.put("radians", new JdbcMethod("radians", JdbcType.DOUBLE, 1, 1)); labkeyMethod.put("rand", new JdbcMethod("rand", JdbcType.DOUBLE, 0, 1)); labkeyMethod.put("repeat", new JdbcMethod("repeat", JdbcType.VARCHAR, 2, 2)); + labkeyMethod.put("right", new JdbcMethod("right", JdbcType.VARCHAR, 2, 2)); labkeyMethod.put("round", new Method("round", JdbcType.DOUBLE, 1, 2) { @Override @@ -1916,7 +1917,6 @@ private static void addJsonPassthroughMethod(String name, JdbcType type, int min mssqlMethods.put("replace", new PassthroughMethod("replace", JdbcType.VARCHAR, 3, 3)); mssqlMethods.put("replicate", new PassthroughMethod("replicate", JdbcType.VARCHAR, 2, 2)); mssqlMethods.put("reverse", new PassthroughMethod("reverse", JdbcType.VARCHAR, 1, 1)); - mssqlMethods.put("right", new PassthroughMethod("right", JdbcType.VARCHAR, 2, 2)); mssqlMethods.put("soundex", new PassthroughMethod("soundex", JdbcType.VARCHAR, 1, 1)); mssqlMethods.put("space", new PassthroughMethod("space", JdbcType.VARCHAR, 1, 1)); mssqlMethods.put("str", new PassthroughMethod("str", JdbcType.VARCHAR, 1, 3)); From 5c2544f571fb6112357f780ae0c6dbe436ac8cbb Mon Sep 17 00:00:00 2001 From: Binal Patel Date: Sun, 28 Jun 2026 22:05:01 -0600 Subject: [PATCH 3/5] Address claude review changes --- query/src/org/labkey/query/sql/Method.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/query/src/org/labkey/query/sql/Method.java b/query/src/org/labkey/query/sql/Method.java index 2e5af532188..64989a5ddae 100644 --- a/query/src/org/labkey/query/sql/Method.java +++ b/query/src/org/labkey/query/sql/Method.java @@ -1076,9 +1076,9 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments) } } - // Portable isnumeric() — emits ISNUMERIC(x) on SQL Server and a regex-based CASE on PostgreSQL. - // Returns 1 when the input is numeric (digits with optional sign / decimal point), 0 otherwise. - // NULL inputs return 0 to match SQL Server's ISNUMERIC behavior. + // Portable isnumeric() emits ISNUMERIC(x) on SQL Server and a regex-based CASE on PostgreSQL. + // Returns 1 for digit strings with an optional sign/decimal point, 0 otherwise. + // This is stricter than SQL Server's ISNUMERIC(), which also accepts formats like scientific notation. static class IsNumericInfo extends AbstractMethodInfo { IsNumericInfo() @@ -1096,8 +1096,8 @@ public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments) } if (dialect.isPostgreSQL()) { - return new SQLFragment("(CASE WHEN (").append(arg) - .append(") ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$' THEN 1 ELSE 0 END)"); + return new SQLFragment("(CASE WHEN CAST((").append(arg) + .append(") AS TEXT) ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$' THEN 1 ELSE 0 END)"); } throw new IllegalStateException("isnumeric() is not supported for this database dialect: " + dialect.getProductName()); } From 30c7f448d4773c4b9d07a711d93ab7c18a1d58f5 Mon Sep 17 00:00:00 2001 From: Binal Patel Date: Mon, 29 Jun 2026 12:12:25 -0600 Subject: [PATCH 4/5] Add unit test --- .../org/labkey/query/QueryServiceImpl.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/query/src/org/labkey/query/QueryServiceImpl.java b/query/src/org/labkey/query/QueryServiceImpl.java index 749bbb656d8..c83c5b95972 100644 --- a/query/src/org/labkey/query/QueryServiceImpl.java +++ b/query/src/org/labkey/query/QueryServiceImpl.java @@ -3782,5 +3782,44 @@ public void testWhereClauseWithUnion() assertTrue(e.getMessage().contains("Syntax error near 'UNION'")); } } + + @Test + public void testRightAndIsnumeric() throws SQLException + { + // Portable LabKey-SQL functions: right() dispatches via the JDBC {fn right} escape; + // isnumeric() emits ISNUMERIC(x) on SQL Server and a regex-based CASE on PostgreSQL. + // This test exercises both against whichever dialect the test container is using. + String sql = + "SELECT " + + " right('hello', 2) AS r1, " + + " right('xy', 5) AS r2, " + + " isnumeric('5') AS n1, " + + " isnumeric('-3.14') AS n2, " + + " isnumeric('abc') AS n3, " + + " isnumeric(NULL) AS n4 " + + "FROM core.Containers"; + + QueryDef qd = new QueryDef(); + qd.setSchema("core"); + qd.setName("junit" + GUID.makeHash()); + qd.setContainer(JunitUtil.getTestContainer().getId()); + qd.setSql(sql); + QueryDefinition qdef = new CustomQueryDefinitionImpl(TestContext.get().getUser(), JunitUtil.getTestContainer(), qd); + List errors = new ArrayList<>(); + TableInfo t = qdef.getTable(errors, false); + String dialect = t == null ? "?" : t.getSqlDialect().getProductName(); + assertTrue("Query parse errors on " + dialect + ": " + errors, errors.isEmpty()); + + try (Results results = new TableSelector(t).getResults()) + { + assertTrue("Expected at least one row from core.Containers", results.next()); + assertEquals("right('hello', 2) on " + dialect, "lo", results.getString("r1")); + assertEquals("right('xy', 5) on " + dialect, "xy", results.getString("r2")); + assertEquals("isnumeric('5') on " + dialect, 1, results.getInt("n1")); + assertEquals("isnumeric('-3.14') on " + dialect, 1, results.getInt("n2")); + assertEquals("isnumeric('abc') on " + dialect, 0, results.getInt("n3")); + assertEquals("isnumeric(NULL) on " + dialect, 0, results.getInt("n4")); + } + } } } From 2372f5b3f111e54605dadd2cbebf91550adae7ef Mon Sep 17 00:00:00 2001 From: Binal Patel Date: Mon, 29 Jun 2026 12:50:18 -0600 Subject: [PATCH 5/5] Code review change - Move LabKey SQL isnumeric() generation behind dialect capability methods --- .../data/dialect/BasePostgreSqlDialect.java | 13 +++++++++ .../labkey/api/data/dialect/SqlDialect.java | 28 +++++++++++++------ query/src/org/labkey/query/sql/Method.java | 12 ++------ 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java index 0ecd40466c3..c2a43f9747a 100644 --- a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java @@ -928,6 +928,19 @@ public boolean supportsNativeGreatestAndLeast() return true; } + @Override + public boolean supportsIsNumeric() + { + return true; + } + + @Override + public SQLFragment isNumericExpr(SQLFragment expression) + { + return new SQLFragment("(CASE WHEN CAST((").append(expression) + .append(") AS TEXT) ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$' THEN 1 ELSE 0 END)"); + } + private class PostgreSqlColumnMetaDataReader extends ColumnMetaDataReader { private final TableInfo _table; diff --git a/api/src/org/labkey/api/data/dialect/SqlDialect.java b/api/src/org/labkey/api/data/dialect/SqlDialect.java index 868702be8e2..a7dc67defef 100644 --- a/api/src/org/labkey/api/data/dialect/SqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/SqlDialect.java @@ -832,15 +832,25 @@ public SQLFragment getNumericCast(SQLFragment expression) * @param arguments Arguments passed from the LK SQL * @return the dialect equivalent SQLFragrment */ - public SQLFragment getGreatestAndLeastSQL(String method, SQLFragment... arguments) - { - throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); - } - - public void handleCreateDatabaseException(SQLException e) throws ServletException - { - throw(new ServletException("Can't create database", e)); - } + public SQLFragment getGreatestAndLeastSQL(String method, SQLFragment... arguments) + { + throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); + } + + public boolean supportsIsNumeric() + { + return false; + } + + public SQLFragment isNumericExpr(SQLFragment expression) + { + throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); + } + + public void handleCreateDatabaseException(SQLException e) throws ServletException + { + throw(new ServletException("Can't create database", e)); + } /** * Wrap one or more INSERT statements to allow explicit specification diff --git a/query/src/org/labkey/query/sql/Method.java b/query/src/org/labkey/query/sql/Method.java index 64989a5ddae..f4829cc4a11 100644 --- a/query/src/org/labkey/query/sql/Method.java +++ b/query/src/org/labkey/query/sql/Method.java @@ -1090,15 +1090,9 @@ static class IsNumericInfo extends AbstractMethodInfo public SQLFragment getSQL(SqlDialect dialect, SQLFragment[] arguments) { SQLFragment arg = arguments[0]; - if (dialect.isSqlServer()) - { - return new SQLFragment("ISNUMERIC(").append(arg).append(")"); - } - if (dialect.isPostgreSQL()) - { - return new SQLFragment("(CASE WHEN CAST((").append(arg) - .append(") AS TEXT) ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$' THEN 1 ELSE 0 END)"); - } + if (dialect.supportsIsNumeric()) + return dialect.isNumericExpr(arg); + throw new IllegalStateException("isnumeric() is not supported for this database dialect: " + dialect.getProductName()); } }