Skip to content

Commit a69d6b7

Browse files
authored
feat: adds condition class and assoc. unit tests (googleapis#2159)
* feat: adds condition class and assoc. unit tests * Updates two test cases for empty string
1 parent 1cabacb commit a69d6b7

File tree

2 files changed

+246
-2
lines changed

2 files changed

+246
-2
lines changed

google/cloud/bigquery/dataset.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import copy
2020

2121
import typing
22+
from typing import Optional, List, Dict, Any, Union
2223

2324
import google.cloud._helpers # type: ignore
2425

@@ -29,8 +30,6 @@
2930
from google.cloud.bigquery.encryption_configuration import EncryptionConfiguration
3031
from google.cloud.bigquery import external_config
3132

32-
from typing import Optional, List, Dict, Any, Union
33-
3433

3534
def _get_table_reference(self, table_id: str) -> TableReference:
3635
"""Constructs a TableReference.
@@ -1074,3 +1073,93 @@ def reference(self):
10741073
model = _get_model_reference
10751074

10761075
routine = _get_routine_reference
1076+
1077+
1078+
class Condition(object):
1079+
"""Represents a textual expression in the Common Expression Language (CEL) syntax.
1080+
1081+
Typically used for filtering or policy rules, such as in IAM Conditions
1082+
or BigQuery row/column access policies.
1083+
1084+
See:
1085+
https://cloud.google.com/iam/docs/reference/rest/Shared.Types/Expr
1086+
https://github.com/google/cel-spec
1087+
1088+
Args:
1089+
expression (str):
1090+
The condition expression string using CEL syntax. This is required.
1091+
Example: ``resource.type == "compute.googleapis.com/Instance"``
1092+
title (Optional[str]):
1093+
An optional title for the condition, providing a short summary.
1094+
Example: ``"Request is for a GCE instance"``
1095+
description (Optional[str]):
1096+
An optional description of the condition, providing a detailed explanation.
1097+
Example: ``"This condition checks whether the resource is a GCE instance."``
1098+
"""
1099+
1100+
def __init__(
1101+
self,
1102+
expression: str,
1103+
title: Optional[str] = None,
1104+
description: Optional[str] = None,
1105+
):
1106+
self._properties: Dict[str, Any] = {}
1107+
# Use setters to initialize properties, which also handle validation
1108+
self.expression = expression
1109+
self.title = title
1110+
self.description = description
1111+
1112+
@property
1113+
def title(self) -> Optional[str]:
1114+
"""Optional[str]: The title for the condition."""
1115+
return self._properties.get("title")
1116+
1117+
@title.setter
1118+
def title(self, value: Optional[str]):
1119+
if value is not None and not isinstance(value, str):
1120+
raise ValueError("Pass a string for title, or None")
1121+
self._properties["title"] = value
1122+
1123+
@property
1124+
def description(self) -> Optional[str]:
1125+
"""Optional[str]: The description for the condition."""
1126+
return self._properties.get("description")
1127+
1128+
@description.setter
1129+
def description(self, value: Optional[str]):
1130+
if value is not None and not isinstance(value, str):
1131+
raise ValueError("Pass a string for description, or None")
1132+
self._properties["description"] = value
1133+
1134+
@property
1135+
def expression(self) -> str:
1136+
"""str: The expression string for the condition."""
1137+
1138+
# Cast assumes expression is always set due to __init__ validation
1139+
return typing.cast(str, self._properties.get("expression"))
1140+
1141+
@expression.setter
1142+
def expression(self, value: str):
1143+
if not isinstance(value, str):
1144+
raise ValueError("Pass a non-empty string for expression")
1145+
if not value:
1146+
raise ValueError("expression cannot be an empty string")
1147+
self._properties["expression"] = value
1148+
1149+
def to_api_repr(self) -> Dict[str, Any]:
1150+
"""Construct the API resource representation of this Condition."""
1151+
return self._properties
1152+
1153+
@classmethod
1154+
def from_api_repr(cls, resource: Dict[str, Any]) -> "Condition":
1155+
"""Factory: construct a Condition instance given its API representation."""
1156+
1157+
# Ensure required fields are present in the resource if necessary
1158+
if "expression" not in resource:
1159+
raise ValueError("API representation missing required 'expression' field.")
1160+
1161+
return cls(
1162+
expression=resource["expression"],
1163+
title=resource.get("title"),
1164+
description=resource.get("description"),
1165+
)

tests/unit/test_dataset.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import pytest
2020
from google.cloud.bigquery.dataset import (
2121
AccessEntry,
22+
Condition,
2223
Dataset,
2324
DatasetReference,
2425
Table,
@@ -1228,3 +1229,157 @@ def test_table(self):
12281229
self.assertEqual(table.table_id, "table_id")
12291230
self.assertEqual(table.dataset_id, dataset_id)
12301231
self.assertEqual(table.project, project)
1232+
1233+
1234+
class TestCondition:
1235+
EXPRESSION = 'resource.name.startsWith("projects/my-project/instances/")'
1236+
TITLE = "Instance Access"
1237+
DESCRIPTION = "Access to instances in my-project"
1238+
1239+
@pytest.fixture
1240+
def condition_instance(self):
1241+
"""Provides a Condition instance for tests."""
1242+
return Condition(
1243+
expression=self.EXPRESSION,
1244+
title=self.TITLE,
1245+
description=self.DESCRIPTION,
1246+
)
1247+
1248+
@pytest.fixture
1249+
def condition_api_repr(self):
1250+
"""Provides the API representation for the test Condition."""
1251+
return {
1252+
"expression": self.EXPRESSION,
1253+
"title": self.TITLE,
1254+
"description": self.DESCRIPTION,
1255+
}
1256+
1257+
# --- Basic Functionality Tests ---
1258+
1259+
def test_constructor_and_getters_full(self, condition_instance):
1260+
"""Test initialization with all arguments and subsequent attribute access."""
1261+
assert condition_instance.expression == self.EXPRESSION
1262+
assert condition_instance.title == self.TITLE
1263+
assert condition_instance.description == self.DESCRIPTION
1264+
1265+
def test_constructor_and_getters_minimal(self):
1266+
"""Test initialization with only the required expression."""
1267+
condition = Condition(expression=self.EXPRESSION)
1268+
assert condition.expression == self.EXPRESSION
1269+
assert condition.title is None
1270+
assert condition.description is None
1271+
1272+
def test_setters(self, condition_instance):
1273+
"""Test setting attributes after initialization."""
1274+
new_title = "New Title"
1275+
new_desc = "New Description"
1276+
new_expr = "request.time < timestamp('2024-01-01T00:00:00Z')"
1277+
1278+
condition_instance.title = new_title
1279+
assert condition_instance.title == new_title
1280+
1281+
condition_instance.description = new_desc
1282+
assert condition_instance.description == new_desc
1283+
1284+
condition_instance.expression = new_expr
1285+
assert condition_instance.expression == new_expr
1286+
1287+
# Test setting title and description to empty strings
1288+
condition_instance.title = ""
1289+
assert condition_instance.title == ""
1290+
1291+
condition_instance.description = ""
1292+
assert condition_instance.description == ""
1293+
1294+
# Test setting optional fields back to None
1295+
condition_instance.title = None
1296+
assert condition_instance.title is None
1297+
condition_instance.description = None
1298+
assert condition_instance.description is None
1299+
1300+
# --- API Representation Tests ---
1301+
1302+
def test_to_api_repr_full(self, condition_instance, condition_api_repr):
1303+
"""Test converting a fully populated Condition to API representation."""
1304+
api_repr = condition_instance.to_api_repr()
1305+
assert api_repr == condition_api_repr
1306+
1307+
def test_to_api_repr_minimal(self):
1308+
"""Test converting a minimally populated Condition to API representation."""
1309+
condition = Condition(expression=self.EXPRESSION)
1310+
expected_api_repr = {
1311+
"expression": self.EXPRESSION,
1312+
"title": None,
1313+
"description": None,
1314+
}
1315+
api_repr = condition.to_api_repr()
1316+
assert api_repr == expected_api_repr
1317+
1318+
def test_from_api_repr_full(self, condition_api_repr):
1319+
"""Test creating a Condition from a full API representation."""
1320+
condition = Condition.from_api_repr(condition_api_repr)
1321+
assert condition.expression == self.EXPRESSION
1322+
assert condition.title == self.TITLE
1323+
assert condition.description == self.DESCRIPTION
1324+
1325+
def test_from_api_repr_minimal(self):
1326+
"""Test creating a Condition from a minimal API representation."""
1327+
minimal_repr = {"expression": self.EXPRESSION}
1328+
condition = Condition.from_api_repr(minimal_repr)
1329+
assert condition.expression == self.EXPRESSION
1330+
assert condition.title is None
1331+
assert condition.description is None
1332+
1333+
def test_from_api_repr_with_extra_fields(self):
1334+
"""Test creating a Condition from an API repr with unexpected fields."""
1335+
api_repr = {
1336+
"expression": self.EXPRESSION,
1337+
"title": self.TITLE,
1338+
"unexpected_field": "some_value",
1339+
}
1340+
condition = Condition.from_api_repr(api_repr)
1341+
assert condition.expression == self.EXPRESSION
1342+
assert condition.title == self.TITLE
1343+
assert condition.description is None
1344+
# Check that the extra field didn't get added to internal properties
1345+
assert "unexpected_field" not in condition._properties
1346+
1347+
# # --- Validation Tests ---
1348+
1349+
@pytest.mark.parametrize(
1350+
"kwargs, error_msg",
1351+
[
1352+
({"expression": None}, "Pass a non-empty string for expression"), # type: ignore
1353+
({"expression": ""}, "expression cannot be an empty string"),
1354+
({"expression": 123}, "Pass a non-empty string for expression"), # type: ignore
1355+
({"expression": EXPRESSION, "title": 123}, "Pass a string for title, or None"), # type: ignore
1356+
({"expression": EXPRESSION, "description": False}, "Pass a string for description, or None"), # type: ignore
1357+
],
1358+
)
1359+
def test_validation_init(self, kwargs, error_msg):
1360+
"""Test validation during __init__."""
1361+
with pytest.raises(ValueError, match=error_msg):
1362+
Condition(**kwargs)
1363+
1364+
@pytest.mark.parametrize(
1365+
"attribute, value, error_msg",
1366+
[
1367+
("expression", None, "Pass a non-empty string for expression"), # type: ignore
1368+
("expression", "", "expression cannot be an empty string"),
1369+
("expression", 123, "Pass a non-empty string for expression"), # type: ignore
1370+
("title", 123, "Pass a string for title, or None"), # type: ignore
1371+
("description", [], "Pass a string for description, or None"), # type: ignore
1372+
],
1373+
)
1374+
def test_validation_setters(self, condition_instance, attribute, value, error_msg):
1375+
"""Test validation via setters."""
1376+
with pytest.raises(ValueError, match=error_msg):
1377+
setattr(condition_instance, attribute, value)
1378+
1379+
def test_validation_expression_required_from_api(self):
1380+
"""Test ValueError is raised if expression is missing in from_api_repr."""
1381+
api_repr = {"title": self.TITLE}
1382+
with pytest.raises(
1383+
ValueError, match="API representation missing required 'expression' field."
1384+
):
1385+
Condition.from_api_repr(api_repr)

0 commit comments

Comments
 (0)