crosscontract 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crosscontract/__init__.py +14 -0
- crosscontract/contracts/__init__.py +9 -0
- crosscontract/contracts/contracts/__init__.py +4 -0
- crosscontract/contracts/contracts/base_contract.py +103 -0
- crosscontract/contracts/contracts/cross_contract.py +64 -0
- crosscontract/contracts/schema/__init__.py +29 -0
- crosscontract/contracts/schema/adapters/__init__.py +18 -0
- crosscontract/contracts/schema/adapters/abstract_adapter.py +30 -0
- crosscontract/contracts/schema/adapters/pandera_adapter.py +557 -0
- crosscontract/contracts/schema/adapters/pydantic_adapter.py +288 -0
- crosscontract/contracts/schema/adapters/sqlalchemy_adapter.py +168 -0
- crosscontract/contracts/schema/adapters/utils.py +30 -0
- crosscontract/contracts/schema/exceptions/__init__.py +1 -0
- crosscontract/contracts/schema/exceptions/validation_error.py +222 -0
- crosscontract/contracts/schema/field_descriptors/__init__.py +20 -0
- crosscontract/contracts/schema/field_descriptors/descriptors.py +66 -0
- crosscontract/contracts/schema/field_descriptors/field_descriptors.py +89 -0
- crosscontract/contracts/schema/fields/__init__.py +18 -0
- crosscontract/contracts/schema/fields/base.py +61 -0
- crosscontract/contracts/schema/fields/datetime_field.py +37 -0
- crosscontract/contracts/schema/fields/list_field.py +55 -0
- crosscontract/contracts/schema/fields/numeric_field.py +44 -0
- crosscontract/contracts/schema/fields/string_field.py +48 -0
- crosscontract/contracts/schema/reference/__init__.py +4 -0
- crosscontract/contracts/schema/reference/foreign_key.py +150 -0
- crosscontract/contracts/schema/reference/primary_key.py +52 -0
- crosscontract/contracts/schema/schema.py +253 -0
- crosscontract/contracts/schema/validation/__init__.py +3 -0
- crosscontract/contracts/schema/validation/validate_dataframe.py +81 -0
- crosscontract/contracts/utils.py +35 -0
- crosscontract/contracts/valid_items.py +14 -0
- crosscontract/crossclient/__init__.py +3 -0
- crosscontract/crossclient/crossclient.py +146 -0
- crosscontract/crossclient/exceptions/__init__.py +24 -0
- crosscontract/crossclient/exceptions/exception_factory.py +104 -0
- crosscontract/crossclient/exceptions/exceptions.py +104 -0
- crosscontract/crossclient/logger.py +4 -0
- crosscontract/crossclient/services/__init__.py +4 -0
- crosscontract/crossclient/services/contract_resource.py +303 -0
- crosscontract/crossclient/services/contract_service.py +267 -0
- crosscontract/py.typed +0 -0
- crosscontract-0.2.2.dist-info/METADATA +23 -0
- crosscontract-0.2.2.dist-info/RECORD +45 -0
- crosscontract-0.2.2.dist-info/WHEEL +4 -0
- crosscontract-0.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
from datetime import UTC
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
7
|
+
from crosscontract.contracts.schema import TableSchema
|
|
8
|
+
|
|
9
|
+
import pandera.pandas as pa
|
|
10
|
+
from pandera import DataType
|
|
11
|
+
from pandera.engines import pandas_engine
|
|
12
|
+
|
|
13
|
+
from crosscontract.contracts.schema.fields import (
|
|
14
|
+
DateTimeField,
|
|
15
|
+
IntegerField,
|
|
16
|
+
ListField,
|
|
17
|
+
NumberField,
|
|
18
|
+
StringField,
|
|
19
|
+
)
|
|
20
|
+
from crosscontract.contracts.schema.fields.base import BaseField
|
|
21
|
+
from crosscontract.contracts.schema.reference.foreign_key import ForeignKey
|
|
22
|
+
|
|
23
|
+
from .abstract_adapter import AbstractAdapter
|
|
24
|
+
from .utils import parse_datetime
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def convert_schema_to_pandera(
|
|
28
|
+
schema: "TableSchema",
|
|
29
|
+
name: str = "ConvertedSchema",
|
|
30
|
+
) -> pa.DataFrameSchema:
|
|
31
|
+
"""Convert the DataContract to a Pandera DataFrameSchema.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
schema (TableSchema): The Schema instance to convert.
|
|
35
|
+
name (str): The name of the resulting DataFrameSchema.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
pa.DataFrameSchema: A Pandera DataFrameSchema representing the schema of the
|
|
39
|
+
data described by the Schema.
|
|
40
|
+
"""
|
|
41
|
+
return PanderaPandasAdapter.convert_schema(schema, name=name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PanderaPandasAdapter(AbstractAdapter):
|
|
45
|
+
"""Adapter that converts a schema into a corresponding pandera schema that
|
|
46
|
+
allows to validate a Pandas DataFrame against the schema.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def convert(
|
|
50
|
+
self,
|
|
51
|
+
name: str = "ConvertedSchema",
|
|
52
|
+
primary_key_values: list[tuple[Any, ...]] | None = None,
|
|
53
|
+
foreign_key_values: dict[tuple[str, ...], list[tuple[Any, ...]]] | None = None,
|
|
54
|
+
skip_primary_key_validation: bool = False,
|
|
55
|
+
skip_foreign_key_validation: bool = False,
|
|
56
|
+
) -> pa.DataFrameSchema:
|
|
57
|
+
"""Convert the given TableSchema into a Pandera DataFrameSchema.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
name (str): The name of the resulting DataFrameSchema.
|
|
61
|
+
primary_key_values (list[tuple[Any, ...]] | None): Existing primary key
|
|
62
|
+
values to check for uniqueness.
|
|
63
|
+
Note: The uniqueness of the primary key is validated is checked against
|
|
64
|
+
the union of the provided values and the values in the DataFrame.
|
|
65
|
+
foreign_key_values (dict[tuple[str, ...], list[tuple[Any, ...]]] | None):
|
|
66
|
+
Existing foreign key values to check against. This is provided as a
|
|
67
|
+
dictionary where the keys are the tuples of fields that refer to the
|
|
68
|
+
referenced values, and the values are lists of tuples representing the
|
|
69
|
+
existing referenced values.
|
|
70
|
+
Note: In the case of self-referencing foreign keys, the values in the
|
|
71
|
+
DataFrame are considered automatically, i.e., the referring fields
|
|
72
|
+
are validated against the union of the provided values and the
|
|
73
|
+
values in the DataFrame.
|
|
74
|
+
skip_primary_key_validation (bool): Whether to skip the validation of
|
|
75
|
+
primary key uniqueness.
|
|
76
|
+
skip_foreign_key_validation (bool): Whether to skip the validation of
|
|
77
|
+
foreign key integrity.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
pa.DataFrameSchema: A Pandera DataFrameSchema representing the schema
|
|
81
|
+
of the data described by the TableSchema.
|
|
82
|
+
"""
|
|
83
|
+
columns: dict[str, pa.Column] = {}
|
|
84
|
+
for field in self.schema.field_iterator():
|
|
85
|
+
match field:
|
|
86
|
+
case IntegerField() | NumberField():
|
|
87
|
+
columns[field.name] = self._convert_numeric_field(field)
|
|
88
|
+
case StringField():
|
|
89
|
+
columns[field.name] = self._convert_string_field(field)
|
|
90
|
+
case DateTimeField():
|
|
91
|
+
columns[field.name] = self._convert_datetime_field(field)
|
|
92
|
+
case ListField():
|
|
93
|
+
columns[field.name] = self._convert_list_field(field)
|
|
94
|
+
case _: # pragma: no cover
|
|
95
|
+
# this should never happen because the schema validation should
|
|
96
|
+
# catch unsupported field types, but we add this case for type
|
|
97
|
+
# safety
|
|
98
|
+
raise NotImplementedError(
|
|
99
|
+
f"Field type '{field.type}' not yet supported"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# create the pandera schema with the columns and the name
|
|
103
|
+
pandera_schema = pa.DataFrameSchema(
|
|
104
|
+
columns=columns,
|
|
105
|
+
index=None, # Currently we do not support index columns
|
|
106
|
+
name=name,
|
|
107
|
+
coerce=True, # Useful for CSVs (str -> int)
|
|
108
|
+
strict=True, # Fails if DataFrame contains columns not in Schema
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Handle primary key constraints by adding a custom check to the schema
|
|
112
|
+
additional_checks: list[pa.Check] = []
|
|
113
|
+
if self.schema.primaryKey and not skip_primary_key_validation:
|
|
114
|
+
self._check_reference_inputs(primary_key_values) # Validate input format
|
|
115
|
+
additional_checks.append(
|
|
116
|
+
self._get_primary_key_check(
|
|
117
|
+
pk_fields=self.schema.primaryKey.root,
|
|
118
|
+
primary_key_values=primary_key_values,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Handle foreign key constraints by adding custom checks to the schema
|
|
123
|
+
if self.schema.foreignKeys and not skip_foreign_key_validation:
|
|
124
|
+
for fk in self.schema.foreignKeys:
|
|
125
|
+
valid_values = (
|
|
126
|
+
foreign_key_values.get(tuple(fk.fields))
|
|
127
|
+
if foreign_key_values
|
|
128
|
+
else None
|
|
129
|
+
)
|
|
130
|
+
self._check_reference_inputs(valid_values) # Validate input format
|
|
131
|
+
additional_checks.append(
|
|
132
|
+
self._get_foreign_key_check(fk=fk, foreign_key_values=valid_values)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# add the additional checks to the pandera schema checks, ensuring we
|
|
136
|
+
# don't overwrite any existing checks
|
|
137
|
+
pandera_schema.checks = (pandera_schema.checks or []) + additional_checks
|
|
138
|
+
|
|
139
|
+
return pandera_schema
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def convert_schema(
|
|
143
|
+
cls,
|
|
144
|
+
schema: "TableSchema",
|
|
145
|
+
name: str = "ConvertedSchema",
|
|
146
|
+
primary_key_values: list[tuple[Any, ...]] | None = None,
|
|
147
|
+
foreign_key_values: dict[tuple[str, ...], list[tuple[Any, ...]]] | None = None,
|
|
148
|
+
skip_primary_key_validation: bool = False,
|
|
149
|
+
skip_foreign_key_validation: bool = False,
|
|
150
|
+
) -> pa.DataFrameSchema:
|
|
151
|
+
"""Class method to convert a TableSchema into a Pandera DataFrameSchema without
|
|
152
|
+
needing to instantiate the adapter.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
schema (TableSchema): The TableSchema to convert.
|
|
156
|
+
name (str): The name of the resulting DataFrameSchema.
|
|
157
|
+
primary_key_values (list[tuple[Any, ...]] | None): Existing primary key
|
|
158
|
+
values to check for uniqueness.
|
|
159
|
+
Note: The uniqueness of the primary key is validated is checked against
|
|
160
|
+
the union of the provided values and the values in the DataFrame.
|
|
161
|
+
foreign_key_values (dict[tuple[str, ...], list[tuple[Any, ...]]] | None):
|
|
162
|
+
Existing foreign key values to check against. This is provided as a
|
|
163
|
+
dictionary where the keys are the tuples of fields that refer to the
|
|
164
|
+
referenced values, and the values are lists of tuples representing the
|
|
165
|
+
existing referenced values.
|
|
166
|
+
Note: In the case of self-referencing foreign keys, the values in the
|
|
167
|
+
DataFrame are considered automatically, i.e., the referring fields
|
|
168
|
+
are validated against the union of the provided values and the
|
|
169
|
+
values in the DataFrame.
|
|
170
|
+
skip_primary_key_validation (bool): Whether to skip the validation of
|
|
171
|
+
primary key uniqueness.
|
|
172
|
+
skip_foreign_key_validation (bool): Whether to skip the validation of
|
|
173
|
+
foreign key integrity.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
pa.DataFrameSchema: A Pandera DataFrameSchema representing the schema of the
|
|
177
|
+
data described by the TableSchema.
|
|
178
|
+
"""
|
|
179
|
+
return super().convert_schema(
|
|
180
|
+
schema,
|
|
181
|
+
name=name,
|
|
182
|
+
primary_key_values=primary_key_values,
|
|
183
|
+
skip_primary_key_validation=skip_primary_key_validation,
|
|
184
|
+
foreign_key_values=foreign_key_values,
|
|
185
|
+
skip_foreign_key_validation=skip_foreign_key_validation,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _init_pandera_kwargs(
|
|
189
|
+
self, field: BaseField, pandera_type: type | str | DataType
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
"""Initialize the keyword arguments for creating a pandera Column based on
|
|
192
|
+
the given field.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
field (BaseField): The field for which to initialize the pandera kwargs.
|
|
196
|
+
pandera_type (type | str): The pandera type for the field.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
dict[str, Any]: The initialized keyword arguments for creating a pandera
|
|
200
|
+
Column.
|
|
201
|
+
"""
|
|
202
|
+
kwargs: dict[str, Any] = {
|
|
203
|
+
"name": field.name,
|
|
204
|
+
"dtype": pandera_type,
|
|
205
|
+
"title": field.title,
|
|
206
|
+
"description": field.description,
|
|
207
|
+
"checks": [],
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
kwargs["required"] = field.constraints.required
|
|
211
|
+
if not kwargs["required"]:
|
|
212
|
+
kwargs["nullable"] = True
|
|
213
|
+
kwargs["unique"] = field.constraints.unique
|
|
214
|
+
|
|
215
|
+
# check constraints
|
|
216
|
+
# Handle enum constraint
|
|
217
|
+
if enum_constraint := getattr(field.constraints, "enum", None):
|
|
218
|
+
kwargs["checks"].append(pa.Check.isin(enum_constraint))
|
|
219
|
+
return kwargs
|
|
220
|
+
|
|
221
|
+
def _convert_numeric_field(self, field: IntegerField | NumberField) -> pa.Column:
|
|
222
|
+
"""Convert a numeric field (IntegerField or NumberField) into a pandera
|
|
223
|
+
Column definition.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
field (IntegerField | NumberField): The numeric field to convert.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
pa.Column: A pandera Column representing the numeric field.
|
|
230
|
+
"""
|
|
231
|
+
pandera_type: type | str | None = None
|
|
232
|
+
if isinstance(field, IntegerField):
|
|
233
|
+
pandera_type = "Int64"
|
|
234
|
+
elif isinstance(field, NumberField):
|
|
235
|
+
pandera_type = float
|
|
236
|
+
else:
|
|
237
|
+
raise ValueError("Field must be an IntegerField or NumberField")
|
|
238
|
+
|
|
239
|
+
kwargs = self._init_pandera_kwargs(field, pandera_type)
|
|
240
|
+
|
|
241
|
+
# Handle minimum and maximum constraints
|
|
242
|
+
if field.constraints.minimum is not None:
|
|
243
|
+
kwargs["checks"].append(pa.Check.ge(field.constraints.minimum))
|
|
244
|
+
if field.constraints.maximum is not None:
|
|
245
|
+
kwargs["checks"].append(pa.Check.le(field.constraints.maximum))
|
|
246
|
+
|
|
247
|
+
return pa.Column(**kwargs)
|
|
248
|
+
|
|
249
|
+
def _convert_string_field(self, field: StringField) -> pa.Column:
|
|
250
|
+
"""Convert a StringField into a pandera Column definition.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
field (StringField): The StringField to convert.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
pa.Column: A pandera Column representing the StringField.
|
|
257
|
+
"""
|
|
258
|
+
kwargs = self._init_pandera_kwargs(field, str)
|
|
259
|
+
|
|
260
|
+
# Handle pattern constraint
|
|
261
|
+
if field.constraints.pattern is not None:
|
|
262
|
+
kwargs["regex"] = field.constraints.pattern
|
|
263
|
+
|
|
264
|
+
# Handle minLength and maxLength constraints
|
|
265
|
+
if (
|
|
266
|
+
field.constraints.minLength is not None
|
|
267
|
+
or field.constraints.maxLength is not None
|
|
268
|
+
):
|
|
269
|
+
kwargs["checks"].append(
|
|
270
|
+
pa.Check.str_length(
|
|
271
|
+
min_value=field.constraints.minLength,
|
|
272
|
+
max_value=field.constraints.maxLength,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return pa.Column(**kwargs)
|
|
277
|
+
|
|
278
|
+
def _convert_list_field(self, field: ListField) -> pa.Column:
|
|
279
|
+
"""Convert a ListField into a pandera Column definition.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
field (ListField): The ListField to convert.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
pa.Column: A pandera Column representing the ListField.
|
|
286
|
+
"""
|
|
287
|
+
# determine the pandera type for the list items
|
|
288
|
+
type_mapping: dict[str, type | str] = {
|
|
289
|
+
"string": list[str],
|
|
290
|
+
"integer": list[int],
|
|
291
|
+
"number": list[float],
|
|
292
|
+
"boolean": list[bool],
|
|
293
|
+
}
|
|
294
|
+
pandera_type = type_mapping.get(field.itemType)
|
|
295
|
+
if pandera_type is None: # pragma: no cover
|
|
296
|
+
# this is already validated at the schema level, so this should never
|
|
297
|
+
# happen but we add this check for type safety
|
|
298
|
+
raise ValueError(f"Unsupported itemType: {field.itemType}")
|
|
299
|
+
|
|
300
|
+
# initialize the pandera kwargs for the list field
|
|
301
|
+
kwargs = self._init_pandera_kwargs(field, pandera_type)
|
|
302
|
+
|
|
303
|
+
# Handle minLength and maxLength constraints
|
|
304
|
+
if field.constraints.minLength is not None:
|
|
305
|
+
kwargs["checks"].append(
|
|
306
|
+
pa.Check(
|
|
307
|
+
lambda s: s.apply(
|
|
308
|
+
lambda lst, m=field.constraints.minLength: len(lst) >= m
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
if field.constraints.maxLength is not None:
|
|
313
|
+
kwargs["checks"].append(
|
|
314
|
+
pa.Check(
|
|
315
|
+
lambda s: s.apply(
|
|
316
|
+
lambda lst, m=field.constraints.maxLength: len(lst) <= m
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
return pa.Column(**kwargs)
|
|
322
|
+
|
|
323
|
+
def _convert_datetime_field(self, field: DateTimeField) -> pa.Column:
|
|
324
|
+
"""Convert a DateTimeField into a pandera Column definition.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
field (DateTimeField): The DateTimeField to convert.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
pa.Column: A pandera Column representing the DateTimeField.
|
|
331
|
+
"""
|
|
332
|
+
kwargs = self._init_pandera_kwargs(
|
|
333
|
+
field,
|
|
334
|
+
pandas_engine.DateTime(tz=UTC, to_datetime_kwargs={"format": field.format}), # type: ignore[call-arg]
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Handle minimum and maximum constraints
|
|
338
|
+
if field.constraints.minimum is not None:
|
|
339
|
+
minimum = field.constraints.minimum
|
|
340
|
+
kwargs["checks"].append(
|
|
341
|
+
pa.Check(
|
|
342
|
+
lambda s: s.apply(
|
|
343
|
+
lambda dt, m=minimum, fmt=field.format: parse_datetime(dt, fmt) # type: ignore[operator]
|
|
344
|
+
>= parse_datetime(m, fmt)
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
if field.constraints.maximum is not None:
|
|
349
|
+
maximum = field.constraints.maximum
|
|
350
|
+
kwargs["checks"].append(
|
|
351
|
+
pa.Check(
|
|
352
|
+
lambda s: s.apply(
|
|
353
|
+
lambda dt, m=maximum, fmt=field.format: parse_datetime(dt, fmt) # type: ignore[operator]
|
|
354
|
+
<= parse_datetime(m, fmt)
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return pa.Column(**kwargs)
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _check_reference_inputs(given: Any):
|
|
363
|
+
"""Check that the given input for reference validation is in the correct
|
|
364
|
+
format, i.e., a list of tuples
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
given (Any): The input to check.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
ValueError: If the input is not a list of tuples.
|
|
371
|
+
"""
|
|
372
|
+
if given is None:
|
|
373
|
+
return
|
|
374
|
+
# check outer structure is a list, set, or tuple:
|
|
375
|
+
raise_error = False
|
|
376
|
+
if not isinstance(given, (list, set, tuple)):
|
|
377
|
+
raise_error = True
|
|
378
|
+
# check that the inner structure is a tuple:
|
|
379
|
+
elif not all(isinstance(item, tuple) for item in given):
|
|
380
|
+
raise_error = True
|
|
381
|
+
if raise_error:
|
|
382
|
+
raise ValueError(
|
|
383
|
+
"Existing references must be must be provided as a list of tuples, "
|
|
384
|
+
"where each tuple represents a valid referenced key. Example: "
|
|
385
|
+
"[(10,), (11,)] and not [10, 11] or [[10], [11]]."
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _check_pk_integrity(
|
|
390
|
+
df_sub: pd.DataFrame,
|
|
391
|
+
pk_fields: list[str],
|
|
392
|
+
existing_pk_set: set[tuple[Any, ...]],
|
|
393
|
+
) -> pd.Series:
|
|
394
|
+
# 1. Ensure no nulls in the columns
|
|
395
|
+
has_nulls = df_sub[pk_fields].isna().any(axis=1)
|
|
396
|
+
|
|
397
|
+
# 2. Check values in the DataFrame are internally unique
|
|
398
|
+
is_internally_unique = ~df_sub.duplicated(subset=pk_fields, keep=False)
|
|
399
|
+
|
|
400
|
+
# 3. Check values against existing primary key values
|
|
401
|
+
if existing_pk_set:
|
|
402
|
+
current_keys = pd.MultiIndex.from_frame(df_sub[pk_fields])
|
|
403
|
+
is_externally_unique = pd.Series(
|
|
404
|
+
~current_keys.isin(existing_pk_set),
|
|
405
|
+
index=df_sub.index,
|
|
406
|
+
)
|
|
407
|
+
return is_internally_unique & is_externally_unique & ~has_nulls
|
|
408
|
+
return is_internally_unique & ~has_nulls
|
|
409
|
+
|
|
410
|
+
@staticmethod
|
|
411
|
+
def _get_primary_key_check(
|
|
412
|
+
pk_fields: list[str],
|
|
413
|
+
primary_key_values: list[tuple[Any, ...]] | None,
|
|
414
|
+
) -> pa.Check:
|
|
415
|
+
"""Provide primary key uniqueness checks. The check ensures that primary
|
|
416
|
+
key values are unique within the DataFrame and against existing primary
|
|
417
|
+
key values.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
pk_fields (list[str]): The fields that make up the primary key.
|
|
421
|
+
primary_key_values (list[tuple[Any, ...]] | None): Existing primary
|
|
422
|
+
key values to check for uniqueness.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
pa.Check: A Pandera Check object that can be added to a DataFrameSchema.
|
|
426
|
+
"""
|
|
427
|
+
existing_pk_set = set(primary_key_values) if primary_key_values else set()
|
|
428
|
+
|
|
429
|
+
def check_pk_integrity(df_sub: pd.DataFrame) -> pd.Series:
|
|
430
|
+
return PanderaPandasAdapter._check_pk_integrity(
|
|
431
|
+
df_sub=df_sub,
|
|
432
|
+
pk_fields=pk_fields,
|
|
433
|
+
existing_pk_set=existing_pk_set,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
return pa.Check(
|
|
437
|
+
check_pk_integrity,
|
|
438
|
+
name=f"PrimaryKeyError: {list(pk_fields)}",
|
|
439
|
+
error=(
|
|
440
|
+
f"PrimaryKeyError: Primary key {pk_fields} must be non-null and "
|
|
441
|
+
"unique within the dataset and compared to existing primary key values."
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def _check_fk_integrity(
|
|
447
|
+
df_sub: pd.DataFrame,
|
|
448
|
+
fk_fields: list[str],
|
|
449
|
+
valid_values: set,
|
|
450
|
+
referenced_fields: list[str] | None,
|
|
451
|
+
) -> pd.Series:
|
|
452
|
+
"""Check function for the integrity of a foreign key constraint of a
|
|
453
|
+
DataFrame. The function checks whether the values in the foreign key
|
|
454
|
+
fields of the DataFrame exist in the set of valid referenced values,
|
|
455
|
+
which is the union of the provided valid values and the values in the
|
|
456
|
+
DataFrame itself in case of self-referencing foreign keys.
|
|
457
|
+
|
|
458
|
+
The function is usually not directly called, but is used within a Pandera
|
|
459
|
+
check
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
df_sub (pd.DataFrame): The DataFrame to check.
|
|
463
|
+
fk_fields (list[str]): The fields that make up the foreign key.
|
|
464
|
+
valid_values (set): The set of valid referenced values to check against.
|
|
465
|
+
referenced_fields (list[str] | None): The fields in the DataFrame that
|
|
466
|
+
hold the values to check against in case of self-referencing foreign
|
|
467
|
+
keys. If None, it is assumed that this is not a self-referencing
|
|
468
|
+
foreign key.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
pd.Series: A boolean Series indicating whether each row in the DataFrame
|
|
472
|
+
satisfies the foreign key constraint.
|
|
473
|
+
"""
|
|
474
|
+
# 1. Prepare valid set for this check
|
|
475
|
+
current_valid = valid_values.copy()
|
|
476
|
+
|
|
477
|
+
# If self-reference, add current dataframe values to valid set
|
|
478
|
+
if referenced_fields is not None:
|
|
479
|
+
internal_reference = df_sub[referenced_fields].apply(tuple, axis=1)
|
|
480
|
+
current_valid = set(current_valid).union(internal_reference)
|
|
481
|
+
|
|
482
|
+
# 2. Select the data
|
|
483
|
+
# We interpret empty strings as nulls
|
|
484
|
+
subset = df_sub[fk_fields].replace("", pd.NA)
|
|
485
|
+
|
|
486
|
+
# 3. Identify rows containing Nulls
|
|
487
|
+
# (Standard SQL: Nulls pass FK check)
|
|
488
|
+
is_null_row = subset.isna().any(axis=1)
|
|
489
|
+
|
|
490
|
+
# 4. Create a tuple for all rows
|
|
491
|
+
keys_to_check = pd.MultiIndex.from_frame(subset)
|
|
492
|
+
|
|
493
|
+
# 5. Check Existence
|
|
494
|
+
# This returns a boolean Series aligned with df_sub.index
|
|
495
|
+
is_present = pd.Series(keys_to_check.isin(current_valid), index=df_sub.index)
|
|
496
|
+
|
|
497
|
+
# 6. Final Logic: Valid if (Present in Reference) OR (Is Null)
|
|
498
|
+
return is_present | is_null_row
|
|
499
|
+
|
|
500
|
+
@staticmethod
|
|
501
|
+
def _get_foreign_key_check(
|
|
502
|
+
fk: ForeignKey,
|
|
503
|
+
foreign_key_values: list[tuple[Any, ...]] | None = None,
|
|
504
|
+
) -> pa.Check:
|
|
505
|
+
"""Provide a single foreign key integrity check. The check ensures that values
|
|
506
|
+
in the foreign key fields exist in the referenced dataset.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
fk (ForeignKey): The foreign key to create the check for.
|
|
510
|
+
foreign_key_values (list[tuple[Any, ...]] | None):
|
|
511
|
+
Existing foreign key values to check against.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
pa.Check: A Pandera Check object that can be added to a DataFrameSchema.
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
ValueError: If no referenced values are provided for validation.
|
|
518
|
+
"""
|
|
519
|
+
fk_fields = fk.fields
|
|
520
|
+
|
|
521
|
+
# Get external valid values
|
|
522
|
+
valid_values = set(foreign_key_values) if foreign_key_values else set()
|
|
523
|
+
|
|
524
|
+
# Handle Self-Reference
|
|
525
|
+
# the fields that hold the valid values in case of self-reference
|
|
526
|
+
referenced_fields = (
|
|
527
|
+
fk.reference.fields if fk.reference.resource is None else None
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
# If no external values and not self-reference, we can't validate
|
|
531
|
+
# so we raise a ValueError
|
|
532
|
+
if not valid_values and referenced_fields is None:
|
|
533
|
+
raise ValueError(
|
|
534
|
+
f"Cannot validate foreign key {fk_fields} as no referenced values "
|
|
535
|
+
"are provided."
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Capture closure variables
|
|
539
|
+
def check_fk_integrity(
|
|
540
|
+
df_sub: pd.DataFrame,
|
|
541
|
+
) -> pd.Series:
|
|
542
|
+
return PanderaPandasAdapter._check_fk_integrity(
|
|
543
|
+
df_sub=df_sub,
|
|
544
|
+
fk_fields=fk_fields,
|
|
545
|
+
valid_values=valid_values,
|
|
546
|
+
referenced_fields=referenced_fields,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
return pa.Check(
|
|
550
|
+
check_fk_integrity,
|
|
551
|
+
name=f"ForeignKeyError: {list(fk_fields)}",
|
|
552
|
+
error=(
|
|
553
|
+
f"ForeignKeyError: Values in {fk_fields} do not exist in referenced "
|
|
554
|
+
f"table."
|
|
555
|
+
),
|
|
556
|
+
ignore_na=False, # We handle NAs explicitly
|
|
557
|
+
)
|