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
18 changes: 16 additions & 2 deletions cdisc_rules_engine/check_operators/dataframe_operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,9 @@ def starts_with(self, other_value):
if self.value.is_series(comparison_data):
# need to convert series to tuple to make startswith operator work correctly
comparison_data: Tuple[str] = tuple(comparison_data)
results = self.value[target].str.startswith(comparison_data)
# na=False keeps the result a clean boolean Series when target values are
# missing, so negating operators (does_not_start_with) stay null-safe.
results = self.value[target].str.startswith(comparison_data, na=False)
return results

@log_operator_execution
Expand All @@ -854,9 +856,21 @@ def ends_with(self, other_value):
if self.value.is_series(comparison_data):
# need to convert series to tuple to make endswith operator work correctly
comparison_data: Tuple[str] = tuple(comparison_data)
results = self.value[target].str.endswith(comparison_data)
# na=False keeps the result a clean boolean Series when target values are
# missing, so negating operators (does_not_end_with) stay null-safe.
results = self.value[target].str.endswith(comparison_data, na=False)
return results

@log_operator_execution
@type_operator(FIELD_DATAFRAME)
def does_not_start_with(self, other_value):
return ~self.starts_with(other_value)

@log_operator_execution
@type_operator(FIELD_DATAFRAME)
def does_not_end_with(self, other_value):
return ~self.ends_with(other_value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Starts_with and ends_with currently returns raw result of pandas string methods. These methods can produce nullable/objects results when target values is missing. This can mix badly with use of ''. Other operators safely use '' when positive has already normalized its output to boolean series. Please make starts_with and ends_with null safe.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in f865a42. Confirmed: with a missing target value, starts_with returned [True, None, False] (object dtype) and does_not_start_with then raised TypeError: bad operand type for unary ~: 'NoneType'. Both starts_with and ends_with now pass na=False to str.startswith/str.endswith, so the result is always a clean boolean Series (missing -> False) that negates safely. Verified on PandasDataset and DaskDataset.

@log_operator_execution
@type_operator(FIELD_DATAFRAME)
def has_equal_length(self, other_value: dict):
Expand Down
20 changes: 20 additions & 0 deletions resources/schema/rule-merged/Operator.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@
"required": ["operator", "value"],
"type": "object"
},
{
"properties": {
"operator": {
"const": "does_not_end_with",
"markdownDescription": "\nComplement of `ends_with`. Returns True when the target does NOT end with the value.\n\n> DOMAIN not ending with 'FOOBAR'\n\n```yaml\n- name: \"DOMAIN\"\n operator: \"does_not_end_with\"\n value: \"FOOBAR\"\n```\n"
}
},
"required": ["operator", "value"],
"type": "object"
},
{
"properties": {
"operator": {
Expand All @@ -151,6 +161,16 @@
"required": ["operator", "ordering", "value", "within"],
"type": "object"
},
{
"properties": {
"operator": {
"const": "does_not_start_with",
"markdownDescription": "\nComplement of `starts_with`. Returns True when the target does NOT start with the value.\n\n> DOMAIN not beginning with 'AP'\n\n```yaml\n- name: \"DOMAIN\"\n operator: \"does_not_start_with\"\n value: \"AP\"\n```\n"
}
},
"required": ["operator", "value"],
"type": "object"
},
{
"properties": {
"operator": {
Expand Down
10 changes: 10 additions & 0 deletions resources/schema/rule/Operator.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@
"required": ["operator", "value"],
"type": "object"
},
{
"properties": { "operator": { "const": "does_not_end_with" } },
"required": ["operator", "value"],
"type": "object"
},
{
"properties": { "operator": { "const": "does_not_equal_string_part" } },
"required": ["operator", "value", "regex"],
Expand All @@ -84,6 +89,11 @@
"required": ["operator", "ordering", "value", "within"],
"type": "object"
},
{
"properties": { "operator": { "const": "does_not_start_with" } },
"required": ["operator", "value"],
"type": "object"
},
{
"properties": { "operator": { "const": "empty" } },
"required": ["operator"],
Expand Down
24 changes: 24 additions & 0 deletions resources/schema/rule/Operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,30 @@ Substring matching
value: "FOOBAR"
```

### does_not_start_with

Complement of `starts_with`. Returns True when the target does NOT start with the value.

> DOMAIN not beginning with 'AP'

```yaml
- name: "DOMAIN"
operator: "does_not_start_with"
value: "AP"
```

### does_not_end_with

Complement of `ends_with`. Returns True when the target does NOT end with the value.

> DOMAIN not ending with 'FOOBAR'

```yaml
- name: "DOMAIN"
operator: "does_not_end_with"
value: "FOOBAR"
```

### prefix_equal_to

True if the `prefix` number of characters beginning a string in `name` match the string in `value`
Expand Down
115 changes: 115 additions & 0 deletions tests/unit/test_check_operators/test_string_comparison.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you please add cases for when 'value_is_literal=True'?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in f865a42: test_starts_with_literal and test_ends_with_literal cover value_is_literal=True for both operators and their negations, plus test_does_not_start_with_is_null_safe / test_does_not_end_with_is_null_safe for the missing-value case — all parametrized across PandasDataset and DaskDataset.

Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,121 @@ def test_ends_with(data, comparator, dataset_type, expected_result):
assert result.equals(df.convert_to_series(expected_result))


@pytest.mark.parametrize(
"data,comparator,dataset_type,expected_result",
[
(
{"target": ["Att", "Btt", "Ctt"], "VAR2": ["A", "B", "D"]},
"VAR2",
PandasDataset,
[False, False, True],
),
(
{"target": ["Att", "Btt", "Ctt"], "VAR2": ["A", "B", "D"]},
"VAR2",
DaskDataset,
[False, False, True],
),
],
)
def test_does_not_start_with(data, comparator, dataset_type, expected_result):
df = dataset_type.from_dict(data)
dataframe_type = DataframeType({"value": df})
result = dataframe_type.does_not_start_with(
{"target": "target", "comparator": comparator}
)
assert result.equals(df.convert_to_series(expected_result))


@pytest.mark.parametrize(
"data,comparator,dataset_type,expected_result",
[
(
{"target": ["Att", "Btt", "Ctt"], "VAR2": ["tt", "B", "D"]},
"VAR2",
PandasDataset,
[False, False, False],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Am I misunderstanding, or shouldn't these be [False, True, True]

),
(
{"target": ["Att", "Btt", "Ctt"], "VAR2": ["tt", "B", "D"]},
"VAR2",
DaskDataset,
[False, False, False],
),
],
)
def test_does_not_end_with(data, comparator, dataset_type, expected_result):
df = dataset_type.from_dict(data)
dataframe_type = DataframeType({"value": df})
result = dataframe_type.does_not_end_with(
{"target": "target", "comparator": comparator}
)
assert result.equals(df.convert_to_series(expected_result))


# value_is_literal=True: comparator is a literal string, not a column name.
@pytest.mark.parametrize(
"data,comparator,dataset_type,expected",
[
({"target": ["Att", "Btt", "Axx"]}, "A", PandasDataset, [True, False, True]),
({"target": ["Att", "Btt", "Axx"]}, "A", DaskDataset, [True, False, True]),
],
)
def test_starts_with_literal(data, comparator, dataset_type, expected):
df = dataset_type.from_dict(data)
dataframe_type = DataframeType({"value": df})
opts = {"target": "target", "comparator": comparator, "value_is_literal": True}
assert dataframe_type.starts_with(opts).equals(df.convert_to_series(expected))
assert dataframe_type.does_not_start_with(opts).equals(
df.convert_to_series([not b for b in expected])
)


@pytest.mark.parametrize(
"data,comparator,dataset_type,expected",
[
({"target": ["Att", "Btt", "Cxx"]}, "tt", PandasDataset, [True, True, False]),
({"target": ["Att", "Btt", "Cxx"]}, "tt", DaskDataset, [True, True, False]),
],
)
def test_ends_with_literal(data, comparator, dataset_type, expected):
df = dataset_type.from_dict(data)
dataframe_type = DataframeType({"value": df})
opts = {"target": "target", "comparator": comparator, "value_is_literal": True}
assert dataframe_type.ends_with(opts).equals(df.convert_to_series(expected))
assert dataframe_type.does_not_end_with(opts).equals(
df.convert_to_series([not b for b in expected])
)


# Missing target values must stay null-safe: starts_with/ends_with return a clean
# boolean Series (missing -> False) so the negating operators don't crash on `~`.
@pytest.mark.parametrize("dataset_type", [PandasDataset, DaskDataset])
def test_does_not_start_with_is_null_safe(dataset_type):
df = dataset_type.from_dict({"target": ["Att", None, "Ctt"]})
dataframe_type = DataframeType({"value": df})
opts = {"target": "target", "comparator": "A", "value_is_literal": True}
assert dataframe_type.starts_with(opts).equals(
df.convert_to_series([True, False, False])
)
assert dataframe_type.does_not_start_with(opts).equals(
df.convert_to_series([False, True, True])
)


@pytest.mark.parametrize("dataset_type", [PandasDataset, DaskDataset])
def test_does_not_end_with_is_null_safe(dataset_type):
df = dataset_type.from_dict({"target": ["Att", None, "Cxx"]})
dataframe_type = DataframeType({"value": df})
opts = {"target": "target", "comparator": "tt", "value_is_literal": True}
assert dataframe_type.ends_with(opts).equals(
df.convert_to_series([True, False, False])
)
assert dataframe_type.does_not_end_with(opts).equals(
df.convert_to_series([False, True, True])
)


@pytest.mark.parametrize(
"data,comparator,dataset_type,expected_result",
[
Expand Down
Loading