diff --git a/api/src/org/labkey/api/exp/query/SamplesSchema.java b/api/src/org/labkey/api/exp/query/SamplesSchema.java index c1e7412b208..0f4149ce05d 100644 --- a/api/src/org/labkey/api/exp/query/SamplesSchema.java +++ b/api/src/org/labkey/api/exp/query/SamplesSchema.java @@ -260,14 +260,17 @@ public ForeignKey materialIdForeignKey(@Nullable final ExpSampleType st, @Nullab private TableInfo createLookupTableInfo() { + // Hack to support lookup via RowId or Name + if (domainProperty != null && domainProperty.getPropertyType().getJdbcType().isText()) + _columnName = "Name"; + + // GitHub Issue #688 + if (st != null) + return getTable(tableName, getLookupContainerFilter()); + ExpMaterialTable ret = ExperimentService.get().createMaterialTable(SamplesSchema.this, getLookupContainerFilter(), st); ret.populate(); ret.overlayMetadata(ret.getPublicName(), SamplesSchema.this, new ArrayList<>()); - if (domainProperty != null && domainProperty.getPropertyType().getJdbcType().isText()) - { - // Hack to support lookup via RowId or Name - _columnName = "Name"; - } ret.setLocked(true); return ret; } 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; diff --git a/api/src/org/labkey/api/security/Encryption.java b/api/src/org/labkey/api/security/Encryption.java index e15144dfd09..902d2665413 100644 --- a/api/src/org/labkey/api/security/Encryption.java +++ b/api/src/org/labkey/api/security/Encryption.java @@ -41,6 +41,7 @@ import org.labkey.api.util.HelpTopic; import org.labkey.api.util.HtmlStringBuilder; import org.labkey.api.util.JobRunner; +import org.labkey.api.util.QuietCloser; import org.labkey.api.util.StringUtilsLabKey; import org.labkey.api.util.logging.LogHelper; import org.labkey.api.view.ViewContext; @@ -570,7 +571,10 @@ static void registerHandler(EncryptionMigrationHandler handler) */ public static void prepareMigrationFallback() { - if (!isEncryptionPassPhraseSpecified()) + // Skip if no pass phrase is configured (nothing to fall back to) or we're not inserting data such + // as during database migration (checkMigration() is gated the same way, so the fallback would be unused). + // The latter avoids constructing an AES instance, which would lazily create a salt row in the property store. + if (!isEncryptionPassPhraseSpecified() || !ModuleLoader.getInstance().shouldInsertData()) return; String oldPassPhrase = getOldEncryptionPassPhrase(); @@ -677,103 +681,118 @@ else if (!cipher.equals(AESConfig.current.getCipherName())) } - private static final EncryptionMigrationHandler TEST_HANDLER = (oldPassPhrase, keySource, oldConfig) -> {}; + private static final EncryptionMigrationHandler TEST_HANDLER = (_, _, _) -> {}; + + private static QuietCloser createErrorCountResetter() + { + int initial = DECRYPTION_EXCEPTIONS.get(); + return () -> DECRYPTION_EXCEPTIONS.set(initial); + } public static class TestCase extends Assert { @Test public void testEncryptionAlgorithms() throws NoSuchAlgorithmException { - String passPhrase = "Here's my super secret pass phrase"; + try (var _ = createErrorCountResetter()) + { + String passPhrase = "Here's my super secret pass phrase"; - Algorithm aesPassPhrase = new AES(passPhrase, 128, "test pass phrase"); + Algorithm aesPassPhrase = new AES(passPhrase, 128, "test pass phrase"); - test(aesPassPhrase); + test(aesPassPhrase); - if (isEncryptionPassPhraseSpecified()) - { - Algorithm aes = getAES128(TEST_HANDLER); - test(aes); + if (isEncryptionPassPhraseSpecified()) + { + Algorithm aes = getAES128(TEST_HANDLER); + test(aes); - // Test that static factory method matches this configuration - Algorithm aes2 = new AES(getEncryptionPassPhrase(), 128, "test pass phrase"); + // Test that static factory method matches this configuration + Algorithm aes2 = new AES(getEncryptionPassPhrase(), 128, "test pass phrase"); - test(aes, aes2); - test(aes2, aes); - } + test(aes, aes2); + test(aes2, aes); + } - if (Cipher.getMaxAllowedKeyLength("AES") >= 256) - { - test(new AES(passPhrase, 256, "test pass phrase")); + if (Cipher.getMaxAllowedKeyLength("AES") >= 256) + { + test(new AES(passPhrase, 256, "test pass phrase")); + } } } @Test(expected = DecryptionException.class) public void testBadKeyException() { - String textToEncrypt = "this is some text I want to encrypt"; - String passPhrase = "Here's my super secret pass phrase"; - String wrongPassPhrase = passPhrase + " not"; - - // Our AES implementation can usually detect a bad pass phrase (based on padding anomalies), but this is not 100% guaranteed. - // Give the test three tries... by my calculations, this will fail once in every 2.6 million runs, which we can live with. - for (int i = 0; i < 3; i++) + try (var _ = createErrorCountResetter()) { - Algorithm aesPassPhrase = new AES(passPhrase, 128, "test pass phrase"); - byte[] encrypted = aesPassPhrase.encrypt(textToEncrypt); + String textToEncrypt = "this is some text I want to encrypt"; + String passPhrase = "Here's my super secret pass phrase"; + String wrongPassPhrase = passPhrase + " not"; + + // Our AES implementation can usually detect a bad pass phrase (based on padding anomalies), but this is not 100% guaranteed. + // Give the test three tries... by my calculations, this will fail once in every 2.6 million runs, which we can live with. + for (int i = 0; i < 3; i++) + { + Algorithm aesPassPhrase = new AES(passPhrase, 128, "test pass phrase"); + byte[] encrypted = aesPassPhrase.encrypt(textToEncrypt); - Algorithm aesWrongPassPhrase = new AES(wrongPassPhrase, 128, "test pass phrase"); - aesWrongPassPhrase.decrypt(encrypted); + Algorithm aesWrongPassPhrase = new AES(wrongPassPhrase, 128, "test pass phrase"); + aesWrongPassPhrase.decrypt(encrypted); + } } } @Test public void testMigrationFallback() { - String text = "test plaintext"; - AES oldAlgorithm = new AES("old pass phrase", 128, "old algorithm"); - byte[] oldEncrypted = oldAlgorithm.encrypt(text); + try (var _ = createErrorCountResetter()) + { + String text = "test plaintext"; + AES oldAlgorithm = new AES("old pass phrase", 128, "old algorithm"); + byte[] oldEncrypted = oldAlgorithm.encrypt(text); - // Primary (production) instance: different pass phrase, keySource == ENCRYPTION_KEY_CHANGED - AES primary = new AES("primary pass phrase", 128, ENCRYPTION_KEY_CHANGED); + // Primary (production) instance: different pass phrase, keySource == ENCRYPTION_KEY_CHANGED + AES primary = new AES("primary pass phrase", 128, ENCRYPTION_KEY_CHANGED); - // Case 1: no fallback — primary fails and counter increments - int counterBefore = DECRYPTION_EXCEPTIONS.get(); - try - { - primary.decrypt(oldEncrypted); - fail("Expected DecryptionException"); - } - catch (DecryptionException ignored) {} - assertEquals(counterBefore + 1, DECRYPTION_EXCEPTIONS.get()); + // Case 1: no fallback — primary fails and counter increments + int counterBefore = DECRYPTION_EXCEPTIONS.get(); + try + { + primary.decrypt(oldEncrypted); + fail("Expected DecryptionException"); + } + catch (DecryptionException _) {} + assertEquals(counterBefore + 1, DECRYPTION_EXCEPTIONS.get()); - // Case 2: correct fallback — transparent success, counter unchanged - _migrationFallback = oldAlgorithm; - try - { - int counterBeforeFallback = DECRYPTION_EXCEPTIONS.get(); - assertEquals(text, primary.decrypt(oldEncrypted)); - assertEquals("Counter must not increment when fallback succeeds", counterBeforeFallback, DECRYPTION_EXCEPTIONS.get()); - } - finally - { - _migrationFallback = null; - } + // Case 2: correct fallback — transparent success, counter unchanged + _migrationFallback = oldAlgorithm; + try + { + int counterBeforeFallback = DECRYPTION_EXCEPTIONS.get(); + assertEquals(text, primary.decrypt(oldEncrypted)); + assertEquals("Counter must not increment when fallback succeeds", counterBeforeFallback, DECRYPTION_EXCEPTIONS.get()); + } + finally + { + _migrationFallback = null; + } - // Case 3: wrong fallback — both algorithms fail, counter increments - _migrationFallback = new AES("wrong pass phrase", 128, "wrong fallback"); - int counterBeforeWrongFallback = DECRYPTION_EXCEPTIONS.get(); - try - { - primary.decrypt(oldEncrypted); - fail("Expected DecryptionException"); - } - catch (DecryptionException ignored) {} - finally - { - _migrationFallback = null; + // Case 3: wrong fallback — both algorithms fail, counter increments + _migrationFallback = new AES("wrong pass phrase", 128, "wrong fallback"); + int counterBeforeWrongFallback = DECRYPTION_EXCEPTIONS.get(); + try + { + primary.decrypt(oldEncrypted); + fail("Expected DecryptionException"); + } + catch (DecryptionException _) {} + finally + { + _migrationFallback = null; + } + assertEquals(counterBeforeWrongFallback + 1, DECRYPTION_EXCEPTIONS.get()); } - assertEquals(counterBeforeWrongFallback + 1, DECRYPTION_EXCEPTIONS.get()); } private void test(Algorithm algorithm) diff --git a/pipeline/gradle.properties b/pipeline/gradle.properties index bdf43c99a8c..9d671167f44 100644 --- a/pipeline/gradle.properties +++ b/pipeline/gradle.properties @@ -1,4 +1,4 @@ -activemqVersion=5.19.5 +activemqVersion=5.19.6 geronimoJ2eeConnector15SpecVersion=1.0.1 diff --git a/query/src/org/labkey/query/QueryTestCase.jsp b/query/src/org/labkey/query/QueryTestCase.jsp index 1831ba89ada..47b7a37c520 100644 --- a/query/src/org/labkey/query/QueryTestCase.jsp +++ b/query/src/org/labkey/query/QueryTestCase.jsp @@ -695,6 +695,21 @@ d,seven,twelve,day,month,date,duration,guid new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('03 Jan 2004' AS TIMESTAMP), SQL_TSI_YEAR) AS INTEGER)", JdbcType.INTEGER, 1), new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('01 Feb 2004' AS TIMESTAMP), SQL_TSI_MONTH) AS INTEGER)", JdbcType.INTEGER, 12), new MethodSqlTest("SELECT CAST(AGE(CAST('02 Jan 2003' AS TIMESTAMP), CAST('02 Feb 2004' AS TIMESTAMP), SQL_TSI_MONTH) AS INTEGER)", JdbcType.INTEGER, 13), + // age_in_days() and age(..., SQL_TSI_DAY) - counts calendar-day boundaries (SQL Server semantics) + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 395), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, -395), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2004' AS TIMESTAMP), CAST('02 Jan 2004' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1), + new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003' AS TIMESTAMP), CAST('31 Jan 2004' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 395), + new MethodSqlTest("SELECT CAST(AGE(CAST('31 Jan 2004' AS TIMESTAMP), CAST('01 Jan 2003' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, -395), + // age_in_days() with datetime inputs - verifies calendar-boundary counting (not 24-hour elapsed time) + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 0), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), CAST('02 Jan 2003 00:00:01' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 12:00:00' AS TIMESTAMP), CAST('02 Jan 2003 11:00:00' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 1), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('02 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, -1), + new MethodSqlTest("SELECT CAST(AGE_IN_DAYS(CAST('01 Jan 2003 06:00:00' AS TIMESTAMP), CAST('03 Jan 2003 18:00:00' AS TIMESTAMP)) AS INTEGER)", JdbcType.INTEGER, 2), + // age(..., SQL_TSI_DAY) with datetime inputs - same calendar-boundary semantics + new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003 00:00:01' AS TIMESTAMP), CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 0), + new MethodSqlTest("SELECT CAST(AGE(CAST('01 Jan 2003 23:59:59' AS TIMESTAMP), CAST('02 Jan 2003 00:00:01' AS TIMESTAMP), SQL_TSI_DAY) AS INTEGER)", JdbcType.INTEGER, 1), new MethodSqlTest("SELECT CAST('1' AS SQL_INTEGER) ", JdbcType.INTEGER, 1), new MethodSqlTest("SELECT CAST('1' AS INTEGER) ", JdbcType.INTEGER, 1), new MethodSqlTest("SELECT CAST('1.5' AS DOUBLE) ", JdbcType.DOUBLE, 1.5), diff --git a/query/src/org/labkey/query/controllers/GetQueryDetailsAction.java b/query/src/org/labkey/query/controllers/GetQueryDetailsAction.java index a90c5a14d0d..898fe907352 100644 --- a/query/src/org/labkey/query/controllers/GetQueryDetailsAction.java +++ b/query/src/org/labkey/query/controllers/GetQueryDetailsAction.java @@ -18,12 +18,15 @@ import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; import org.json.JSONArray; import org.json.JSONObject; import org.labkey.api.action.Action; import org.labkey.api.action.ActionType; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.BaseViewAction; +import org.labkey.api.action.HasBindParameters; import org.labkey.api.action.ReadOnlyApiAction; import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.collections.CaseInsensitiveHashSet; @@ -63,6 +66,7 @@ import org.labkey.query.CustomViewUtil; import org.labkey.query.QueryDefinitionImpl; import org.labkey.query.persist.QueryDef; +import org.springframework.beans.PropertyValues; import org.springframework.validation.BindException; import java.util.ArrayList; @@ -460,7 +464,7 @@ protected List> getDefViewColProps(QueryView view) return colProps; } - public static class Form + public static class Form implements HasBindParameters { private String _queryName; private String _schemaName; @@ -496,11 +500,6 @@ public String[] getViewName() return _viewName; } - public void setViewName(String[] viewName) - { - _viewName = viewName; - } - public String getFk() { return _fk; @@ -550,5 +549,23 @@ public void setIncludeTriggers(boolean includeTriggers) { _includeTriggers = includeTriggers; } + + @Override + public @NotNull BindException bindParameters(PropertyValues params) + { + // GitHub Issue #936 : manually bind the viewName parameter + var viewName = params.getPropertyValue("viewName"); + if (viewName != null) + { + var value = viewName.getValue(); + if (value instanceof String[] strs) + _viewName = strs; + else if (value instanceof String str) + _viewName = new String[] { str }; + else + LOG.error("Unexpected viewName parameter type: " + value); + } + return BaseViewAction.springBindParameters(this, "form", params); + } } } diff --git a/query/src/org/labkey/query/controllers/LabKeySql.md b/query/src/org/labkey/query/controllers/LabKeySql.md index 5e96934f52d..b02b11d1261 100644 --- a/query/src/org/labkey/query/controllers/LabKeySql.md +++ b/query/src/org/labkey/query/controllers/LabKeySql.md @@ -199,6 +199,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. @@ -230,4 +231,109 @@ Here is a summary of the available functions and methods in LabKey SQL. * `moduleProperty(module name, property name)`: Returns a module property. * `overlaps(START1, END1, START2, END2)`: Tests for overlapping time intervals (PostgreSQL only). * `userid()`, `username()`: Return user information. -* `version()`: Returns the current schema version. \ No newline at end of file +* `version()`: Returns the current schema version. + +----- + +### **9. JSON and JSONB Operators and Functions (PostgreSQL Only)** + +LabKey SQL supports PostgreSQL JSON and JSONB operators and functions for working with JSON data stored in columns. These are **not available on MS SQL Server**. LabKey SQL does not natively understand arrays, but functions that expect them may still work. See the [PostgreSQL docs](https://www.postgresql.org/docs/14/functions-json.html) for detailed usage. + +#### **Operators via `json_op`** + +Native PostgreSQL operator syntax (`->`, `->>`, etc.) cannot be used directly in LabKey SQL. Instead, use the `json_op` pass-through function with three arguments: the left operand, the operator as a string, and the right operand. + +* **Supported operators:** `->`, `->>`, `#>`, `#>>`, `@>`, `<@`, `?`, `?|`, `?&`, `||`, `-`, `#-` +* **Syntax:** `json_op(left_operand, 'operator', right_operand)` +* **Examples:** + * **Extract by key (as JSON):** Get a nested value from a JSONB column: + ```sql + SELECT json_op(metadata, '->', 'name') AS name_json FROM samples + ``` + * **Extract by key (as text):** Get the text value: + ```sql + SELECT json_op(metadata, '->>', 'name') AS name_text FROM samples + ``` + * **Containment check:** Filter rows where JSONB contains a given structure: + ```sql + SELECT * FROM samples WHERE json_op(metadata, '@>', parse_jsonb('{"status":"active"}')) + ``` + * **Key existence check:** + ```sql + SELECT * FROM samples WHERE json_op(metadata, '?', 'name') + ``` + +#### **Conversion / Parsing Functions** + +* `parse_json(text)`, `parse_jsonb(text)`: Cast a text value to JSON or JSONB. Use instead of `::jsonb` or `CAST(... AS JSONB)`. + ```sql + SELECT parse_jsonb('{"a":1, "b":null}') + ``` +* `to_json(value)`, `to_jsonb(value)`: Convert a value to JSON/JSONB. Text values become a single JSON string. +* `array_to_json(array)`: Convert an array to JSON. +* `row_to_json(value)`: Convert a scalar row to JSON. **Note:** Does not support converting an entire table to JSON; use `to_jsonb()` instead. + +#### **Builder Functions** + +* `json_build_array(...)`, `jsonb_build_array(...)`: Build a JSON array from arguments. + ```sql + SELECT jsonb_build_array(1, 'two', 3.0) + ``` +* `json_build_object(...)`, `jsonb_build_object(...)`: Build a JSON object from key/value arguments. + ```sql + SELECT jsonb_build_object('name', sample_name, 'type', sample_type) FROM samples + ``` +* `json_object(text_array)`, `jsonb_object(text_array)`: Build a JSON object from a text array. + +#### **Query and Extraction Functions** + +* `json_array_length(json)`, `jsonb_array_length(jsonb)`: Return the length of the outermost JSON array. +* `json_each(json)`, `jsonb_each(jsonb)`: Expand the outermost JSON object into key/value pairs. **Note:** Only scalar function usage is supported, not the table-returning version. + ```sql + SELECT json_each('{"a":"foo", "b":"bar"}') AS Value + ``` +* `json_each_text(json)`, `jsonb_each_text(jsonb)`: Like `json_each` but values are returned as text. Only scalar usage supported. +* `json_extract_path(json, ...)`, `jsonb_extract_path(jsonb, ...)`: Return the JSON value at the given path. + ```sql + SELECT jsonb_extract_path(metadata, 'address', 'city') FROM samples + ``` +* `json_extract_path_text(json, ...)`, `jsonb_extract_path_text(jsonb, ...)`: Return the value at the given path as text. +* `json_object_keys(json)`, `jsonb_object_keys(jsonb)`: Return the keys of the outermost JSON object. +* `json_array_elements(json)`, `jsonb_array_elements(jsonb)`: Expand a JSON array into a set of values. +* `json_array_elements_text(json)`, `jsonb_array_elements_text(jsonb)`: Expand a JSON array into a set of text values. + +#### **Type Inspection and Cleanup Functions** + +* `json_typeof(json)`, `jsonb_typeof(jsonb)`: Return the type of the outermost JSON value (e.g., `"object"`, `"array"`, `"string"`, `"number"`). +* `json_strip_nulls(json)`, `jsonb_strip_nulls(jsonb)`: Remove all null-valued keys from a JSON object. + +#### **Modification Functions** + +* `jsonb_insert(jsonb, path, new_value)`: Insert a value at a given path within a JSONB object. +* `jsonb_pretty(jsonb)`: Format a JSONB value as indented text. +* `jsonb_set(jsonb, path, new_value)`: Set the value at a given path. Strict: returns NULL on NULL input. +* `jsonb_set_lax(jsonb, path, new_value, null_behavior)`: Like `jsonb_set` but not strict. The `null_behavior` argument must be one of: `'raise_exception'`, `'use_json_null'`, `'delete_key'`, or `'return_target'`. + +#### **Path Query Functions** + +* `jsonb_path_exists(jsonb, path)`, `jsonb_path_exists_tz(...)`: Check whether the JSON path returns any item. The `_tz` variant is timezone-aware. +* `jsonb_path_match(jsonb, path)`, `jsonb_path_match_tz(...)`: Return the result of a JSON path predicate check. +* `jsonb_path_query(jsonb, path)`, `jsonb_path_query_tz(...)`: Return all items matched by the JSON path. +* `jsonb_path_query_array(jsonb, path)`, `jsonb_path_query_array_tz(...)`: Return matched items as an array. +* `jsonb_path_query_first(jsonb, path)`, `jsonb_path_query_first_tz(...)`: Return the first matched item. + +#### **Not Supported** + +The following functions are **not supported** in LabKey SQL: +`json_populate_record`, `jsonb_populate_record`, `json_populate_recordset`, `jsonb_populate_recordset`, `json_to_record`, `jsonb_to_record`, `json_to_recordset`, `jsonb_to_recordset`. + +#### **Quick Reference for Writing LabKey SQL with JSON** + +When writing LabKey SQL queries that work with JSON columns: + +1. **Always use `json_op()` for operators** — never use raw PostgreSQL operator syntax like `->` or `->>`. Wrap them: `json_op(col, '->>', 'key')`. +2. **Use `parse_jsonb()` to create JSONB literals** — there is no `::jsonb` cast in LabKey SQL. Write `parse_jsonb('{"key":"value"}')`. +3. **Use `jsonb_extract_path_text()` for nested field access** — this is often the clearest way to extract a deeply nested text value: `jsonb_extract_path_text(col, 'level1', 'level2', 'field')`. +4. **Use `jsonb_build_object()` to construct JSON** — for building JSON from column values: `jsonb_build_object('id', rowid, 'name', label)`. +5. **Check database type first** — these functions only work on PostgreSQL. If the target server may use MS SQL Server, do not use them. +6. **The `validateSQL` MCP tool can verify syntax** — use it to check JSON function calls before the user saves a query. \ No newline at end of file diff --git a/query/src/org/labkey/query/sql/Method.java b/query/src/org/labkey/query/sql/Method.java index b6377660675..8789835eb94 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)); @@ -889,10 +898,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"); } } @@ -972,6 +983,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() 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); diff --git a/study/test/src/org/labkey/test/tests/study/AssayTest.java b/study/test/src/org/labkey/test/tests/study/AssayTest.java index 316e41680af..89a8f2bf686 100644 --- a/study/test/src/org/labkey/test/tests/study/AssayTest.java +++ b/study/test/src/org/labkey/test/tests/study/AssayTest.java @@ -207,7 +207,6 @@ public void testSampleFieldUpdate() clickAndWait(Locator.linkWithText("view results")); assertElementPresent("Sample lookup failed for: OS_1", new Locator.LinkLocator("OS_1"), 1); - log("Edit assay design and change Sample field to point to created Sample Type"); goToManageAssays(); clickAndWait(Locator.LinkLocator.linkWithText(SAMPLE_FIELD_TEST_ASSAY)); @@ -222,10 +221,21 @@ public void testSampleFieldUpdate() importAssayData(SAMPLE_FIELD_TEST_ASSAY, TEST_RUN2, "SampleField\nS_1"); goToManageAssays().clickAndWait(Locator.linkWithText(SAMPLE_FIELD_TEST_ASSAY)); clickAndWait(Locator.linkWithText("view results")); -// assertElementPresent("Sample lookup failed for: OS_1", new Locator.LinkLocator("OS_1"), 1); //TODO this becomes the RowId "<123>" after change. Issue #40047 - + DataRegionTable table = new DataRegionTable("Data", getDriver()); + List sampleFieldValues = table.getColumnDataAsText("SampleField"); + assertTrue("First sample should not resolve to sample type", sampleFieldValues.get(0).startsWith("<")); + assertEquals("Second sample should resolve to sample type", "S_1", sampleFieldValues.get(1)); assertElementPresent("Sample lookup failed for: S_1", new Locator.LinkLocator("S_1"), 1); + log("GitHub Issue #688: verify sample lookup to createdBy"); + _customizeViewsHelper.openCustomizeViewPanel(); + _customizeViewsHelper.addColumn("SampleField/CreatedBy"); + _customizeViewsHelper.applyCustomView(); + table = new DataRegionTable("Data", getDriver()); + List createdByValues = table.getColumnDataAsText("SampleField/CreatedBy"); + assertEquals("First sample should not have a createdBy since it doesn't resolve", " ", createdByValues.get(0)); + assertEquals("Second sample should have a createdBy since it resolves to a sample type", getCurrentUserName(), createdByValues.get(1)); + log("Edit assay design and change Sample field to point back to 'All Samples'"); goToManageAssays(); clickAndWait(Locator.LinkLocator.linkWithText(SAMPLE_FIELD_TEST_ASSAY)); @@ -247,6 +257,10 @@ public void testSampleFieldUpdate() assertElementPresent("Sample lookup failed for: S_2", new Locator.LinkLocator("S_2"), 1); assertElementPresent("Sample lookup failed for: OS_2", new Locator.LinkLocator("OS_2"), 1); + log("GitHub Issue #688: verify sample lookup to createdBy"); + table = new DataRegionTable("Data", getDriver()); + for (int i = 0; i < table.getDataRowCount(); i++) + assertEquals("Row " + i + " should have current user as createdBy since they all resolve to samples", getCurrentUserName(), table.getDataAsText(i, "SampleField/CreatedBy")); } private void importAssayData(String assayName, String runName, String runDataStr)