From ad4adb8a4977066fa6f553d66e77071423075999 Mon Sep 17 00:00:00 2001 From: "Niemes, Adam" Date: Fri, 7 Aug 2026 16:24:55 -0400 Subject: [PATCH 1/5] started working through the changes to add this operator --- cdisc_rules_engine/models/operation_params.py | 3 +- .../operations/operations_factory.py | 2 + cdisc_rules_engine/operations/value_equals.py | 60 ++++++++++++++ .../utilities/rule_processor.py | 1 + resources/schema/rule/Operations.json | 12 +++ resources/schema/rule/Operations.md | 4 + .../unit/test_operations/test_value_equals.py | 79 +++++++++++++++++++ 7 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 cdisc_rules_engine/operations/value_equals.py create mode 100644 tests/unit/test_operations/test_value_equals.py diff --git a/cdisc_rules_engine/models/operation_params.py b/cdisc_rules_engine/models/operation_params.py index 5d953ead4..823a1ab2f 100644 --- a/cdisc_rules_engine/models/operation_params.py +++ b/cdisc_rules_engine/models/operation_params.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Any from cdisc_rules_engine.models.external_dictionaries_container import ( ExternalDictionariesContainer, ) @@ -57,6 +57,7 @@ class OperationParams: regex: str = None returntype: str = None source: str = None + value: Any = None target: str = None subtract: str = None order_insensitive: bool = True diff --git a/cdisc_rules_engine/operations/operations_factory.py b/cdisc_rules_engine/operations/operations_factory.py index 63db1dfb7..c52f44441 100644 --- a/cdisc_rules_engine/operations/operations_factory.py +++ b/cdisc_rules_engine/operations/operations_factory.py @@ -55,6 +55,7 @@ from cdisc_rules_engine.operations.valid_external_dictionary_code_term_pair import ( ValidExternalDictionaryCodeTermPair, ) +from cdisc_rules_engine.operations.value_equals import ValueEquals from cdisc_rules_engine.operations.variable_exists import VariableExists from cdisc_rules_engine.operations.variable_names import VariableNames from cdisc_rules_engine.operations.variable_value_count import VariableValueCount @@ -144,6 +145,7 @@ class OperationsFactory(FactoryInterface): "valid_external_dictionary_code": ValidExternalDictionaryCode, "valid_external_dictionary_code_term_pair": ValidExternalDictionaryCodeTermPair, "valid_define_external_dictionary_version": DefineDictionaryVersionValidator, + "value_equals": ValueEquals, "get_dataset_filtered_variables": GetDatasetFilteredVariables, "get_xhtml_errors": GetXhtmlErrors, } diff --git a/cdisc_rules_engine/operations/value_equals.py b/cdisc_rules_engine/operations/value_equals.py new file mode 100644 index 000000000..bb9d50b33 --- /dev/null +++ b/cdisc_rules_engine/operations/value_equals.py @@ -0,0 +1,60 @@ +import pandas as pd +from cdisc_rules_engine.models.dataset.dask_dataset import DaskDataset +from cdisc_rules_engine.operations.base_operation import BaseOperation + + +class ValueEquals(BaseOperation): + def _execute_operation(self): + dataframe = self.params.dataframe + target = self.params.target + expected = self.params.value + + if target not in dataframe.columns: + raise ValueError(f"Target column '{target}' not found in dataset") + + target_series = dataframe[target] + + if self._is_variable_list_column(target_series): + # Avoid dask row-wise apply tokenization issues by using pandas for this path. + if isinstance(dataframe, DaskDataset): + pandas_df = dataframe.data.compute() + return pandas_df.apply( + self._get_matching_variable_names_from_row, + axis=1, + args=(target, expected), + ) + return dataframe.apply( + self._get_matching_variable_names_from_row, + axis=1, + args=(target, expected), + ) + + return target_series.apply( + lambda value: [target] if self._values_equal(value, expected) else [] + ) + + def _is_variable_list_column(self, series) -> bool: + non_null_values = series[series.notna()] + return len(non_null_values) > 0 and all( + isinstance(value, (list, tuple, set)) for value in non_null_values + ) + + def _get_matching_variable_names_from_row( + self, row, list_column_name: str, expected + ): + variable_names = row[list_column_name] + if not isinstance(variable_names, (list, tuple, set)): + return [] + + matches = [] + for variable_name in variable_names: + if variable_name in row.index and self._values_equal( + row[variable_name], expected + ): + matches.append(variable_name) + return matches + + def _values_equal(self, actual, expected) -> bool: + if expected is None: + return pd.isna(actual) + return actual == expected \ No newline at end of file diff --git a/cdisc_rules_engine/utilities/rule_processor.py b/cdisc_rules_engine/utilities/rule_processor.py index 1f93f0885..c9c655d3a 100644 --- a/cdisc_rules_engine/utilities/rule_processor.py +++ b/cdisc_rules_engine/utilities/rule_processor.py @@ -423,6 +423,7 @@ def perform_rule_operations( regex=operation.get("regex"), returntype=operation.get("returntype"), source=operation.get("source"), + value=operation.get("value"), standard=standard, standard_substandard=standard_substandard, standard_version=standard_version, diff --git a/resources/schema/rule/Operations.json b/resources/schema/rule/Operations.json index b3908c33b..8bdd2faf3 100644 --- a/resources/schema/rule/Operations.json +++ b/resources/schema/rule/Operations.json @@ -477,6 +477,15 @@ }, "required": ["id", "operator", "name", "namespace"], "type": "object" + }, + { + "properties": { + "operator": { + "const": "value_equals" + } + }, + "required": ["id", "operator", "name", "value"], + "type": "object" } ], "properties": { @@ -644,6 +653,9 @@ "term_pref_term": { "type": "string" }, + "value": { + "type": ["string", "number", "boolean", "null", "array"] + }, "value_is_reference": { "type": "boolean" }, diff --git a/resources/schema/rule/Operations.md b/resources/schema/rule/Operations.md index f5eb48cbd..1691a91d5 100644 --- a/resources/schema/rule/Operations.md +++ b/resources/schema/rule/Operations.md @@ -1167,6 +1167,10 @@ Operations: id: $num_sponsor_ids operator: record_count ``` +### value_equals + + + ### variable_count diff --git a/tests/unit/test_operations/test_value_equals.py b/tests/unit/test_operations/test_value_equals.py new file mode 100644 index 000000000..ff36b5570 --- /dev/null +++ b/tests/unit/test_operations/test_value_equals.py @@ -0,0 +1,79 @@ +import pytest +from cdisc_rules_engine.config.config import ConfigService +from cdisc_rules_engine.models.dataset.dask_dataset import DaskDataset +from cdisc_rules_engine.models.dataset.pandas_dataset import PandasDataset +from cdisc_rules_engine.models.operation_params import OperationParams +from cdisc_rules_engine.operations.value_equals import ValueEquals +from cdisc_rules_engine.services.cache.cache_service_factory import CacheServiceFactory + + +@pytest.mark.parametrize("dataset_type", [PandasDataset, DaskDataset]) +def test_value_equals_single_target_returns_matching_variable_names( + dataset_type, mock_data_service, operation_params: OperationParams +): + data = dataset_type.from_dict({"PPSTRESU": ["3", "4", "3"]}) + config = ConfigService() + cache = CacheServiceFactory(config).get_cache_service() + + operation_params.dataframe = data + operation_params.target = "PPSTRESU" + operation_params.value = "3" + + result = ValueEquals(operation_params, data, cache, mock_data_service).execute() + + assert operation_params.operation_id in result + assert result[operation_params.operation_id].tolist() == [ + ["PPSTRESU"], + [], + ["PPSTRESU"], + ] + + +@pytest.mark.parametrize("dataset_type", [PandasDataset, DaskDataset]) +def test_value_equals_null_matches_only_true_null( + dataset_type, mock_data_service, operation_params: OperationParams +): + data = dataset_type.from_dict({"PPSTRESU": [None, "", "3"]}) + config = ConfigService() + cache = CacheServiceFactory(config).get_cache_service() + + operation_params.dataframe = data + operation_params.target = "PPSTRESU" + operation_params.value = None + + result = ValueEquals(operation_params, data, cache, mock_data_service).execute() + + assert operation_params.operation_id in result + assert result[operation_params.operation_id].tolist() == [ + ["PPSTRESU"], # true null -> match + [], # empty string -> no match + [], # non-null value -> no match + ] + + +@pytest.mark.parametrize("dataset_type", [PandasDataset, DaskDataset]) +def test_value_equals_variable_list_target_returns_only_matching_variables( + dataset_type, mock_data_service, operation_params: OperationParams +): + data = dataset_type.from_dict( + { + "$var_list": [["A", "B"], ["A", "B"], ["A", "B"]], + "A": [1, 3, 3], + "B": [3, 2, 3], + } + ) + config = ConfigService() + cache = CacheServiceFactory(config).get_cache_service() + + operation_params.dataframe = data + operation_params.target = "$var_list" + operation_params.value = 3 + + result = ValueEquals(operation_params, data, cache, mock_data_service).execute() + + assert operation_params.operation_id in result + assert result[operation_params.operation_id].tolist() == [ + ["B"], # row 1: A=1, B=3 + ["A"], # row 2: A=3, B=2 + ["A", "B"], # row 3: A=3, B=3 + ] \ No newline at end of file From adf0c23b49af5573f3b064e725d812a6edf9c1c6 Mon Sep 17 00:00:00 2001 From: "Niemes, Adam" Date: Fri, 14 Aug 2026 13:49:17 -0400 Subject: [PATCH 2/5] updated operations --- resources/schema/rule/Operations.md | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/resources/schema/rule/Operations.md b/resources/schema/rule/Operations.md index 1691a91d5..3e32b4591 100644 --- a/resources/schema/rule/Operations.md +++ b/resources/schema/rule/Operations.md @@ -1169,8 +1169,81 @@ Operations: ``` ### value_equals +Compares a target column value against a specified value, returning matching variable names. When the target is a single variable column, returns the variable name if the value matches, or an empty list if it does not. When the target is a variable-list column (containing lists of variable names per row), evaluates each listed variable against the specified value and returns only those variable names where the value matched. +**Required Parameters:** + +- `name`: Target column name to check +- `value`: Value to compare against (can be scalar, list, or null) + +**Null Semantics:** + +When `value` is null, only true null values (as determined by `pd.isna()`) are matched. Empty strings, zero, or other "falsy" values are not considered null. + +**Single Column Example:** + +Given a dataset: + +```yaml +PPSTRESU +3 +5 +3 +``` + +and the following operation: + +```yaml +Operations: + - id: $ppstresu_equals_3 + operator: value_equals + name: PPSTRESU + value: 3 +``` + +This will result in: +```yaml +PPSTRESU $ppstresu_equals_3 +3 [PPSTRESU] +5 [] +3 [PPSTRESU] +``` + +Variable-List Column Example: + +```markdown +Given a dataset where one column contains lists of variable names to check: + +$var_list A B C +[A, B] 1 null 5 +[B, C] 2 3 null +``` + +Operations: + - id: $vars_equal_null + operator: value_equals + name: $var_list + value: null + +This will result in: +```yaml +$var_list A B C $vars_equal_null +[A, B] 1 null 5 [B] +[B, C] 2 3 null [C] +``` + +Null Value Example: + +To detect rows where a specific variable is null: +```yaml +Operations: + - id: $status_is_null + operator: value_equals + name: STATUS + value: null +``` +will return [STATUS] for rows where STATUS is null (via pd.isna()), and [] for rows where STATUS has any other value (including empty string). ### variable_count From 2aea7a7de7794fbf82c95a13832963f902652779 Mon Sep 17 00:00:00 2001 From: "Niemes, Adam" Date: Tue, 18 Aug 2026 09:45:38 -0400 Subject: [PATCH 3/5] updated documentation --- resources/schema/rule/Operations.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/resources/schema/rule/Operations.md b/resources/schema/rule/Operations.md index 3e32b4591..dbaeb2c23 100644 --- a/resources/schema/rule/Operations.md +++ b/resources/schema/rule/Operations.md @@ -1202,39 +1202,46 @@ Operations: ``` This will result in: -```yaml + +``` PPSTRESU $ppstresu_equals_3 3 [PPSTRESU] 5 [] 3 [PPSTRESU] ``` -Variable-List Column Example: +**Variable-List Column Example:** -```markdown Given a dataset where one column contains lists of variable names to check: +``` $var_list A B C [A, B] 1 null 5 [B, C] 2 3 null ``` +and the following operation: + +```yaml Operations: - id: $vars_equal_null operator: value_equals name: $var_list value: null +``` This will result in: -```yaml + +``` $var_list A B C $vars_equal_null [A, B] 1 null 5 [B] [B, C] 2 3 null [C] ``` -Null Value Example: +**Null Value Example:** To detect rows where a specific variable is null: + ```yaml Operations: - id: $status_is_null @@ -1243,7 +1250,7 @@ Operations: value: null ``` -will return [STATUS] for rows where STATUS is null (via pd.isna()), and [] for rows where STATUS has any other value (including empty string). +will return `[STATUS]` for rows where STATUS is null (via `pd.isna()`), and `[]` for rows where STATUS has any other value (including empty string). ### variable_count From 4565d8be313e9ac17c97aef8529d7dfb846ad201 Mon Sep 17 00:00:00 2001 From: "Niemes, Adam" Date: Tue, 18 Aug 2026 10:42:13 -0400 Subject: [PATCH 4/5] Adjusted based on CI scripts --- cdisc_rules_engine/operations/operations_factory.py | 2 +- cdisc_rules_engine/operations/value_equals.py | 2 +- tests/unit/test_operations/test_value_equals.py | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cdisc_rules_engine/operations/operations_factory.py b/cdisc_rules_engine/operations/operations_factory.py index c52f44441..f94cdce85 100644 --- a/cdisc_rules_engine/operations/operations_factory.py +++ b/cdisc_rules_engine/operations/operations_factory.py @@ -145,7 +145,7 @@ class OperationsFactory(FactoryInterface): "valid_external_dictionary_code": ValidExternalDictionaryCode, "valid_external_dictionary_code_term_pair": ValidExternalDictionaryCodeTermPair, "valid_define_external_dictionary_version": DefineDictionaryVersionValidator, - "value_equals": ValueEquals, + "value_equals": ValueEquals, "get_dataset_filtered_variables": GetDatasetFilteredVariables, "get_xhtml_errors": GetXhtmlErrors, } diff --git a/cdisc_rules_engine/operations/value_equals.py b/cdisc_rules_engine/operations/value_equals.py index bb9d50b33..70decab22 100644 --- a/cdisc_rules_engine/operations/value_equals.py +++ b/cdisc_rules_engine/operations/value_equals.py @@ -57,4 +57,4 @@ def _get_matching_variable_names_from_row( def _values_equal(self, actual, expected) -> bool: if expected is None: return pd.isna(actual) - return actual == expected \ No newline at end of file + return actual == expected diff --git a/tests/unit/test_operations/test_value_equals.py b/tests/unit/test_operations/test_value_equals.py index ff36b5570..f44368ae8 100644 --- a/tests/unit/test_operations/test_value_equals.py +++ b/tests/unit/test_operations/test_value_equals.py @@ -46,8 +46,8 @@ def test_value_equals_null_matches_only_true_null( assert operation_params.operation_id in result assert result[operation_params.operation_id].tolist() == [ ["PPSTRESU"], # true null -> match - [], # empty string -> no match - [], # non-null value -> no match + [], # empty string -> no match + [], # non-null value -> no match ] @@ -73,7 +73,7 @@ def test_value_equals_variable_list_target_returns_only_matching_variables( assert operation_params.operation_id in result assert result[operation_params.operation_id].tolist() == [ - ["B"], # row 1: A=1, B=3 - ["A"], # row 2: A=3, B=2 + ["B"], # row 1: A=1, B=3 + ["A"], # row 2: A=3, B=2 ["A", "B"], # row 3: A=3, B=3 - ] \ No newline at end of file + ] From c4d217ab3ad33169eb1f18508a7205d6ee4a1562 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 18 Aug 2026 14:59:04 +0000 Subject: [PATCH 5/5] Update merged schema files with markdown descriptions --- resources/schema/rule-merged/Operations.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/resources/schema/rule-merged/Operations.json b/resources/schema/rule-merged/Operations.json index 8c03f1c5a..fd81b7436 100644 --- a/resources/schema/rule-merged/Operations.json +++ b/resources/schema/rule-merged/Operations.json @@ -297,7 +297,7 @@ "properties": { "operator": { "const": "record_count", - "markdownDescription": "\nIf no filter or group is provided, returns the number of records in the dataset. If filter is provided, returns the number of records in the dataset that contain the value(s) in the corresponding column(s) provided in the filter. Filter can have a wildcard `&` that when added to the end of the filter value will look for all instances of that prefix (see 4th example below). If group is provided, returns the number of rows matching each unique set of the grouping variables. These can be static column name(s) or can be derived from other operations like get_dataset_filtered_variables.\n\nIf both filter and group are provided, returns the number of records in the dataset that contain the value(s) in the corresponding column(s) provided in the filter that also match each unique set of the grouping variables.\n\n**Wildcard Filtering:** Filter values ending with % will match any records where the column value starts with the specified prefix. For example, RACE% will match RACE1, RACE2, RACE3, etc. This is useful for matching related variables with numeric or alphabetic suffixes.\n\n**Regex Transformation:** If regex is provided along with group, the regex pattern will be applied to transform grouping column values before grouping. The regex is only applied to columns where the pattern matches the data type. For example, using regex `^\\d{4}-\\d{2}-\\d{2}` on a column containing `2022-01-14T08:00` will extract `2022-01-14` for grouping purposes.\n\nIf group is provided, group_aliases may also be provided to assign new grouping variable names so that results grouped by the values in one set of grouping variables can be merged onto a dataset according to the same grouping value(s) stored in different set of grouping variables. When both group and group_aliases are provided, columns are renamed according to corresponding list position (i.e., the 1st column in group is renamed to the 1st column in group_aliases, etc.). If there are more columns listed in group than in group_aliases, only the group columns with corresponding group_aliases columns will be renamed. If there are more columns listed in group_aliases than in group, the extra column names in group_aliases will be ignored.\n\nExample: return the number of records in a dataset.\n\n```yaml\n- operator: record_count\n id: $records_in_dataset\n```\n\nExample: return the number of records where STUDYID = \"CDISC01\" and FLAGVAR = \"Y\".\n\n```yaml\n- operator: record_count\n id: $flagged_cdisc01_records_in_dataset\n filter:\n STUDYID: \"CDISC01\"\n FLAGVAR: \"Y\"\n```\n\nExample: return the number of records grouped by USUBJID and timing variables, extracting only the date portion from datetime values.\n\n```yaml\n- operator: record_count\n id: $records_per_usubjid_date\n group:\n - USUBJID\n - --TESTCD\n - $TIMING_VARIABLES\n regex: \"^\\d{4}-\\d{2}-\\d{2}\"\n```\n\nExample: return the number of records where QNAM starts with \"RACE\" (matches RACE1, RACE2, RACE3, etc.) per USUBJID.\n\n```yaml\n- operator: record_count\n id: $race_records_in_dataset\n filter:\n QNAM: \"RACE&\"\n group:\n - \"USUBJID\"\n```\n\nExample: return the number of records grouped by USUBJID.\n\n```yaml\n- operator: record_count\n id: $records_per_usubjid\n group:\n - USUBJID\n```\n\nExample: return the number of records grouped by USUBJID where FLAGVAR = \"Y\".\n\n```yaml\n- operator: record_count\n id: $flagged_records_per_usubjid\n group:\n - USUBJID\n filter:\n FLAGVAR: \"Y\"\n```\n\nExample: return the number of records grouped by USUBJID and IDVARVAL where QNAM = \"TEST1\" and IDVAR = \"GROUPID\", renaming the IDVARVAL column to GROUPID for subsequent merging.\n\n```yaml\n- operator: record_count\n id: $test1_records_per_usubjid_groupid\n group:\n - USUBJID\n - IDVARVAL\n filter:\n QNAM: \"TEST1\"\n IDVAR: \"GROUPID\"\n group_aliases:\n - USUBJID\n - GROUPID\n```\n\nExample: Group the StudyIdentifier dataset by parent_id and merge the result back to the context dataset StudyVersion using StudyVersion.id == StudyIdentifier.parent_id\n\n```yaml\nScope:\n Entities:\n Include:\n - StudyVersion\nOperations:\n - domain: StudyIdentifier\n filter:\n parent_entity: \"StudyVersion\"\n parent_rel: \"studyIdentifiers\"\n rel_type: \"definition\"\n studyIdentifierScope.organizationType.code: \"C70793\"\n studyIdentifierScope.organizationType.codeSystem: \"http://www.cdisc.org\"\n group:\n - parent_id\n group_aliases:\n - id\n id: $num_sponsor_ids\n operator: record_count\n```\n" + "markdownDescription": "\nIf no filter or group is provided, returns the number of records in the dataset. If filter is provided, returns the number of records in the dataset that contain the value(s) in the corresponding column(s) provided in the filter. Filter can have a wildcard `&` that when added to the end of the filter value will look for all instances of that prefix (see 4th example below). If group is provided, returns the number of rows matching each unique set of the grouping variables. These can be static column name(s) or can be derived from other operations like get_dataset_filtered_variables.\n\nIf both filter and group are provided, returns the number of records in the dataset that contain the value(s) in the corresponding column(s) provided in the filter that also match each unique set of the grouping variables.\n\n**Wildcard Filtering:** Filter values ending with % will match any records where the column value starts with the specified prefix. For example, RACE% will match RACE1, RACE2, RACE3, etc. This is useful for matching related variables with numeric or alphabetic suffixes.\n\n**Regex Transformation:** If regex is provided along with group, the regex pattern will be applied to transform grouping column values before grouping. The regex is only applied to columns where the pattern matches the data type. For example, using regex `^\\d{4}-\\d{2}-\\d{2}` on a column containing `2022-01-14T08:00` will extract `2022-01-14` for grouping purposes.\n\nIf group is provided, group_aliases may also be provided to assign new grouping variable names so that results grouped by the values in one set of grouping variables can be merged onto a dataset according to the same grouping value(s) stored in different set of grouping variables. When both group and group_aliases are provided, columns are renamed according to corresponding list position (i.e., the 1st column in group is renamed to the 1st column in group_aliases, etc.). If there are more columns listed in group than in group_aliases, only the group columns with corresponding group_aliases columns will be renamed. If there are more columns listed in group_aliases than in group, the extra column names in group_aliases will be ignored.\n\nExample: return the number of records in a dataset.\n\n```yaml\n- operator: record_count\n id: $records_in_dataset\n```\n\nExample: return the number of records where STUDYID = \"CDISC01\" and FLAGVAR = \"Y\".\n\n```yaml\n- operator: record_count\n id: $flagged_cdisc01_records_in_dataset\n filter:\n STUDYID: \"CDISC01\"\n FLAGVAR: \"Y\"\n```\n\nExample: return the number of records grouped by USUBJID and timing variables, extracting only the date portion from datetime values.\n\n```yaml\n- operator: record_count\n id: $records_per_usubjid_date\n group:\n - USUBJID\n - --TESTCD\n - $TIMING_VARIABLES\n regex: \"^\\d{4}-\\d{2}-\\d{2}\"\n```\n\nExample: return the number of records where QNAM starts with \"RACE\" (matches RACE1, RACE2, RACE3, etc.) per USUBJID.\n\n```yaml\n- operator: record_count\n id: $race_records_in_dataset\n filter:\n QNAM: \"RACE&\"\n group:\n - \"USUBJID\"\n```\n\nExample: return the number of records grouped by USUBJID.\n\n```yaml\n- operator: record_count\n id: $records_per_usubjid\n group:\n - USUBJID\n```\n\nExample: return the number of records grouped by USUBJID where FLAGVAR = \"Y\".\n\n```yaml\n- operator: record_count\n id: $flagged_records_per_usubjid\n group:\n - USUBJID\n filter:\n FLAGVAR: \"Y\"\n```\n\nExample: return the number of records grouped by USUBJID and IDVARVAL where QNAM = \"TEST1\" and IDVAR = \"GROUPID\", renaming the IDVARVAL column to GROUPID for subsequent merging.\n\n```yaml\n- operator: record_count\n id: $test1_records_per_usubjid_groupid\n group:\n - USUBJID\n - IDVARVAL\n filter:\n QNAM: \"TEST1\"\n IDVAR: \"GROUPID\"\n group_aliases:\n - USUBJID\n - GROUPID\n```\n\nExample: Group the StudyIdentifier dataset by parent_id and merge the result back to the context dataset StudyVersion using StudyVersion.id == StudyIdentifier.parent_id\n\n```yaml\nScope:\n Entities:\n Include:\n - StudyVersion\nOperations:\n - domain: StudyIdentifier\n filter:\n parent_entity: \"StudyVersion\"\n parent_rel: \"studyIdentifiers\"\n rel_type: \"definition\"\n studyIdentifierScope.organizationType.code: \"C70793\"\n studyIdentifierScope.organizationType.codeSystem: \"http://www.cdisc.org\"\n group:\n - parent_id\n group_aliases:\n - id\n id: $num_sponsor_ids\n operator: record_count\n```" } }, "required": ["id", "operator"], @@ -530,6 +530,16 @@ }, "required": ["id", "operator", "name", "namespace"], "type": "object" + }, + { + "properties": { + "operator": { + "const": "value_equals", + "markdownDescription": "\nCompares a target column value against a specified value, returning matching variable names. When the target is a single variable column, returns the variable name if the value matches, or an empty list if it does not. When the target is a variable-list column (containing lists of variable names per row), evaluates each listed variable against the specified value and returns only those variable names where the value matched.\n\n**Required Parameters:**\n\n- `name`: Target column name to check\n- `value`: Value to compare against (can be scalar, list, or null)\n\n**Null Semantics:**\n\nWhen `value` is null, only true null values (as determined by `pd.isna()`) are matched. Empty strings, zero, or other \"falsy\" values are not considered null.\n\n**Single Column Example:**\n\nGiven a dataset:\n\n```yaml\nPPSTRESU\n3\n5\n3\n```\n\nand the following operation:\n\n```yaml\nOperations:\n - id: $ppstresu_equals_3\n operator: value_equals\n name: PPSTRESU\n value: 3\n```\n\nThis will result in:\n\n```\nPPSTRESU $ppstresu_equals_3\n3 [PPSTRESU]\n5 []\n3 [PPSTRESU]\n```\n\n**Variable-List Column Example:**\n\nGiven a dataset where one column contains lists of variable names to check:\n\n```\n$var_list A B C\n[A, B] 1 null 5\n[B, C] 2 3 null\n```\n\nand the following operation:\n\n```yaml\nOperations:\n - id: $vars_equal_null\n operator: value_equals\n name: $var_list\n value: null\n```\n\nThis will result in:\n\n```\n$var_list A B C $vars_equal_null\n[A, B] 1 null 5 [B]\n[B, C] 2 3 null [C]\n```\n\n**Null Value Example:**\n\nTo detect rows where a specific variable is null:\n\n```yaml\nOperations:\n - id: $status_is_null\n operator: value_equals\n name: STATUS\n value: null\n```\n\nwill return `[STATUS]` for rows where STATUS is null (via `pd.isna()`), and `[]` for rows where STATUS has any other value (including empty string).\n" + } + }, + "required": ["id", "operator", "name", "value"], + "type": "object" } ], "properties": { @@ -697,6 +707,9 @@ "term_pref_term": { "type": "string" }, + "value": { + "type": ["string", "number", "boolean", "null", "array"] + }, "value_is_reference": { "type": "boolean" },