Skip to content

Commit a2937e7

Browse files
Remove schema mutation for schema-sync *sigh*
1 parent 81f5c53 commit a2937e7

3 files changed

Lines changed: 49 additions & 112 deletions

File tree

frictionless/detector/detector.py

Lines changed: 0 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -411,33 +411,6 @@ def detect_schema(
411411
fields[index] = AnyField(name=name, schema=schema) # type: ignore
412412
schema.fields = fields # type: ignore
413413

414-
# Sync schema
415-
if self.schema_sync:
416-
if labels:
417-
case_sensitive = options["header_case"]
418-
419-
if not case_sensitive:
420-
labels = [label.lower() for label in labels]
421-
422-
if len(labels) != len(set(labels)):
423-
note = '"schema_sync" requires unique labels in the header'
424-
raise FrictionlessException(note)
425-
426-
mapped_fields = self.mapped_schema_fields_names(
427-
schema.fields, # type: ignore
428-
case_sensitive,
429-
)
430-
431-
self.rearrange_schema_fields_given_labels(
432-
mapped_fields,
433-
schema,
434-
labels,
435-
)
436-
437-
self.add_missing_required_labels_to_schema_fields(
438-
mapped_fields, schema, labels, case_sensitive
439-
)
440-
441414
# Patch schema
442415
if self.schema_patch:
443416
patch = deepcopy(self.schema_patch)
@@ -452,56 +425,3 @@ def detect_schema(
452425

453426
return schema
454427

455-
@staticmethod
456-
def mapped_schema_fields_names(
457-
fields: List[Field], case_sensitive: bool
458-
) -> Dict[str, Field]:
459-
"""Create a dictionnary to map field names with schema fields"""
460-
if case_sensitive:
461-
return {field.name: field for field in fields}
462-
else:
463-
return {field.name.lower(): field for field in fields}
464-
465-
@staticmethod
466-
def rearrange_schema_fields_given_labels(
467-
fields_mapping: Dict[str, Field],
468-
schema: Schema,
469-
labels: List[str],
470-
):
471-
"""Rearrange fields according to the order of labels. All fields
472-
missing from labels are dropped"""
473-
schema.clear_fields()
474-
475-
for name in labels:
476-
default_field = Field.from_descriptor({"name": name, "type": "any"})
477-
field = fields_mapping.get(name, default_field)
478-
schema.add_field(field)
479-
480-
def add_missing_required_labels_to_schema_fields(
481-
self,
482-
fields_mapping: Dict[str, Field],
483-
schema: Schema,
484-
labels: List[str],
485-
case_sensitive: bool,
486-
):
487-
"""This method aims to add missing required labels and
488-
primary key field not in labels to schema fields.
489-
"""
490-
for name, field in fields_mapping.items():
491-
if (
492-
self.field_is_required(field, schema, case_sensitive)
493-
and name not in labels
494-
):
495-
schema.add_field(field)
496-
497-
@staticmethod
498-
def field_is_required(
499-
field: Field,
500-
schema: Schema,
501-
case_sensitive: bool,
502-
) -> bool:
503-
if case_sensitive:
504-
return field.required or field.name in schema.primary_key
505-
else:
506-
lower_primary_key = [pk.lower() for pk in schema.primary_key]
507-
return field.required or field.name.lower() in lower_primary_key

frictionless/resource/__spec__/test_validate.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,10 +509,12 @@ def test_resource_validate_detector_sync_schema():
509509
)
510510
report = resource.validate()
511511
assert report.valid
512+
# schema_sync no longer mutates the user-provided schema: the order
513+
# given by the user is preserved.
512514
assert resource.schema.to_descriptor() == {
513515
"fields": [
514-
{"name": "name", "type": "string"},
515516
{"name": "id", "type": "integer"},
517+
{"name": "name", "type": "string"},
516518
],
517519
}
518520

frictionless/table/header.py

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from functools import cached_property
4-
from typing import List, Optional
4+
from typing import List, Optional, Tuple
55

66
from ..exception import FrictionlessException
77
from ..schema import Field
@@ -155,31 +155,42 @@ def _get_extra_labels(self) -> List[str]:
155155
return self.__labels[len(self.__fields) :]
156156
return []
157157

158-
def _get_missing_fields(self) -> List[Field]:
159-
"""Returns schema fields that don't have a corresponding label.
158+
def _get_missing_fields(self) -> List[Tuple[int, Field]]:
159+
"""Returns (field_number, field) pairs for schema fields that don't
160+
have a corresponding label.
160161
161162
Without `schema_sync`, fields beyond the labels count are considered
162163
missing. With `schema_sync`, only required fields whose name is not
163164
among the labels are missing.
165+
166+
The field_number is `len(labels) + offset + 1` in both modes: under
167+
no-sync the missing fields are precisely the tail of the schema, so
168+
this matches their position; under sync the missing fields have no
169+
natural position in the data, so we place them after the labels by
170+
convention.
164171
"""
165172
fields = self.__fields
166173
labels = self.__labels
167-
if not self.__schema_sync:
168-
if len(fields) > len(labels):
169-
return fields[len(labels) :]
170-
return []
171174

172-
normalized_labels = [self.__normalize(label) for label in labels]
175+
if not self.__schema_sync:
176+
missing = fields[len(labels) :] if len(fields) > len(labels) else []
177+
else:
178+
normalized_labels = [self.__normalize(label) for label in labels]
179+
180+
def required_and_missing(field: Field) -> bool:
181+
required = field.required or (
182+
field.schema is not None
183+
and field.name in field.schema.primary_key
184+
)
185+
return (
186+
required
187+
and self.__normalize(field.name) not in normalized_labels
188+
)
173189

174-
def required_and_missing(field: Field) -> bool:
175-
required = field.required or (
176-
field.schema is not None and field.name in field.schema.primary_key
177-
)
178-
return (
179-
required and self.__normalize(field.name) not in normalized_labels
180-
)
190+
missing = [field for field in fields if required_and_missing(field)]
181191

182-
return [field for field in fields if required_and_missing(field)]
192+
start = len(labels) + 1
193+
return [(start + offset, field) for offset, field in enumerate(missing)]
183194

184195
def __find_field_by_name(self, name: str) -> Optional[Field]:
185196
target = self.__normalize(name)
@@ -232,24 +243,28 @@ def __process(self):
232243
)
233244

234245
# Missing fields
235-
missing_fields = self._get_missing_fields()
236-
if missing_fields:
237-
missing_ids = {id(field) for field in missing_fields}
238-
for field_number, field in enumerate(fields, start=1):
239-
if field is None or id(field) not in missing_ids:
240-
continue
241-
self.__errors.append(
242-
errors.MissingLabelError(
243-
note="",
244-
labels=list(map(str, labels)),
245-
row_numbers=self.__row_numbers,
246-
label="",
247-
field_name=field.name,
248-
field_number=field_number,
249-
)
246+
for field_number, field in self._get_missing_fields():
247+
self.__errors.append(
248+
errors.MissingLabelError(
249+
note="",
250+
labels=list(map(str, labels)),
251+
row_numbers=self.__row_numbers,
252+
label="",
253+
field_name=field.name,
254+
field_number=field_number,
250255
)
256+
)
251257

252258
# Iterate items
259+
# Under schema_sync, labels and fields are matched by name (not by
260+
# position), so the positional comparisons below (blank label vs
261+
# field at the same index, incorrect label vs field name at the same
262+
# index) don't apply. Duplicate labels are still invalid, but they
263+
# are rejected earlier by get_expected_fields(), which raises a
264+
# FrictionlessException — so detecting them here would be redundant.
265+
if self.__schema_sync:
266+
return
267+
253268
field_number = 0
254269
for field, label in zip(fields, labels):
255270
field_number += 1

0 commit comments

Comments
 (0)