Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit 4e20423

Browse files
committed
feat: adds condition class and assoc. unit tests
1 parent ae632c5 commit 4e20423

2 files changed

Lines changed: 239 additions & 2 deletions

File tree

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: 148 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,150 @@ 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 optional fields back to None
1288+
condition_instance.title = None
1289+
assert condition_instance.title is None
1290+
condition_instance.description = None
1291+
assert condition_instance.description is None
1292+
1293+
# --- API Representation Tests ---
1294+
1295+
def test_to_api_repr_full(self, condition_instance, condition_api_repr):
1296+
"""Test converting a fully populated Condition to API representation."""
1297+
api_repr = condition_instance.to_api_repr()
1298+
assert api_repr == condition_api_repr
1299+
1300+
def test_to_api_repr_minimal(self):
1301+
"""Test converting a minimally populated Condition to API representation."""
1302+
condition = Condition(expression=self.EXPRESSION)
1303+
expected_api_repr = {
1304+
"expression": self.EXPRESSION,
1305+
"title": None,
1306+
"description": None,
1307+
}
1308+
api_repr = condition.to_api_repr()
1309+
assert api_repr == expected_api_repr
1310+
1311+
def test_from_api_repr_full(self, condition_api_repr):
1312+
"""Test creating a Condition from a full API representation."""
1313+
condition = Condition.from_api_repr(condition_api_repr)
1314+
assert condition.expression == self.EXPRESSION
1315+
assert condition.title == self.TITLE
1316+
assert condition.description == self.DESCRIPTION
1317+
1318+
def test_from_api_repr_minimal(self):
1319+
"""Test creating a Condition from a minimal API representation."""
1320+
minimal_repr = {"expression": self.EXPRESSION}
1321+
condition = Condition.from_api_repr(minimal_repr)
1322+
assert condition.expression == self.EXPRESSION
1323+
assert condition.title is None
1324+
assert condition.description is None
1325+
1326+
def test_from_api_repr_with_extra_fields(self):
1327+
"""Test creating a Condition from an API repr with unexpected fields."""
1328+
api_repr = {
1329+
"expression": self.EXPRESSION,
1330+
"title": self.TITLE,
1331+
"unexpected_field": "some_value",
1332+
}
1333+
condition = Condition.from_api_repr(api_repr)
1334+
assert condition.expression == self.EXPRESSION
1335+
assert condition.title == self.TITLE
1336+
assert condition.description is None
1337+
# Check that the extra field didn't get added to internal properties
1338+
assert "unexpected_field" not in condition._properties
1339+
1340+
# # --- Validation Tests ---
1341+
1342+
@pytest.mark.parametrize(
1343+
"kwargs, error_msg",
1344+
[
1345+
({"expression": None}, "Pass a non-empty string for expression"), # type: ignore
1346+
({"expression": ""}, "expression cannot be an empty string"),
1347+
({"expression": 123}, "Pass a non-empty string for expression"), # type: ignore
1348+
({"expression": EXPRESSION, "title": 123}, "Pass a string for title, or None"), # type: ignore
1349+
({"expression": EXPRESSION, "description": False}, "Pass a string for description, or None"), # type: ignore
1350+
],
1351+
)
1352+
def test_validation_init(self, kwargs, error_msg):
1353+
"""Test validation during __init__."""
1354+
with pytest.raises(ValueError, match=error_msg):
1355+
Condition(**kwargs)
1356+
1357+
@pytest.mark.parametrize(
1358+
"attribute, value, error_msg",
1359+
[
1360+
("expression", None, "Pass a non-empty string for expression"), # type: ignore
1361+
("expression", "", "expression cannot be an empty string"),
1362+
("expression", 123, "Pass a non-empty string for expression"), # type: ignore
1363+
("title", 123, "Pass a string for title, or None"), # type: ignore
1364+
("description", [], "Pass a string for description, or None"), # type: ignore
1365+
],
1366+
)
1367+
def test_validation_setters(self, condition_instance, attribute, value, error_msg):
1368+
"""Test validation via setters."""
1369+
with pytest.raises(ValueError, match=error_msg):
1370+
setattr(condition_instance, attribute, value)
1371+
1372+
def test_validation_expression_required_from_api(self):
1373+
"""Test ValueError is raised if expression is missing in from_api_repr."""
1374+
api_repr = {"title": self.TITLE}
1375+
with pytest.raises(
1376+
ValueError, match="API representation missing required 'expression' field."
1377+
):
1378+
Condition.from_api_repr(api_repr)

0 commit comments

Comments
 (0)