laskoviymishka commented on code in PR #17:
URL:
https://github.com/apache/iceberg-verification/pull/17#discussion_r4536787137
##########
dev/schema/cases.partition-transforms.schema.json:
##########
@@ -0,0 +1,99 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id":
"https://iceberg.apache.org/verification/cases.partition-transforms.schema.json",
+ "title": "Conformance cases (partition-transforms surface extension)",
+ "description": "Extra structural constraints for
table-spec/partition-transforms. Applied on top of cases.base.schema.json by
dev/validate-fixtures.py.",
+ "type": "object",
+ "required": ["cases"],
+ "additionalProperties": false,
+ "properties": {
+ "cases": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/case" }
+ }
+ },
+ "$defs": {
+ "case": {
+ "type": "object",
+ "required": ["id", "valid", "input", "decoded", "clause", "spec_ref"],
+ "additionalProperties": false,
+ "properties": {
+ "id": { "type": "string", "minLength": 1 },
+ "valid": { "const": true },
+ "input": { "$ref": "#/$defs/input" },
+ "decoded": { "$ref": "#/$defs/decoded" },
+ "clause": { "type": "string", "minLength": 1 },
+ "spec_ref": { "type": "string", "minLength": 1 },
+ "normative_level": { "enum": ["must", "should"] }
+ }
+ },
+ "input": {
+ "type": "object",
+ "required": ["transform", "source_type", "values"],
+ "additionalProperties": false,
+ "properties": {
+ "transform": {
+ "type": "string",
+ "pattern":
"^(identity|bucket\\[[0-9]+\\]|truncate\\[[0-9]+\\]|year|month|day|hour|void)$"
Review Comment:
`truncate[0]` gets through this pattern and the validator: a `truncate/`
case using it validates `OK`, and so does `bucket[0]` outside `bucket/`. Java's
[`Truncate`](https://github.com/apache/iceberg/blob/ea73769d120dc3819d8cfa2253b6dba4bb394886/api/src/main/java/org/apache/iceberg/transforms/Truncate.java#L41-L53)
rejects width <= 0 at construction, so such a fixture would expect results
from a transform that implementations refuse to build.
##########
dev/validate-fixtures.py:
##########
@@ -47,10 +52,317 @@ def _is_types_surface(path):
return "/table-spec/types/" in p or p.startswith("table-spec/types/")
+def _is_partition_transforms_surface(path):
+ p = _rel(path)
+ return "/table-spec/partition-transforms/" in p or p.startswith(
+ "table-spec/partition-transforms/"
+ )
+
+
+IDENTITY_TYPES = {
+ "unknown",
+ "boolean",
+ "int",
+ "long",
+ "float",
+ "double",
+ "decimal",
+ "date",
+ "time",
+ "timestamp",
+ "timestamptz",
+ "timestamp_ns",
+ "timestamptz_ns",
+ "string",
+ "uuid",
+ "fixed",
+ "binary",
+}
+BUCKET_TYPES = {
+ "int",
+ "long",
+ "decimal",
+ "date",
+ "time",
+ "timestamp",
+ "timestamptz",
+ "timestamp_ns",
+ "timestamptz_ns",
+ "string",
+ "uuid",
+ "fixed",
+ "binary",
+}
+TRUNCATE_TYPES = {"int", "long", "decimal", "string", "binary"}
+DATE_TRANSFORM_TYPES = {
+ "date",
+ "timestamp",
+ "timestamptz",
+ "timestamp_ns",
+ "timestamptz_ns",
+}
+HOUR_TYPES = {"timestamp", "timestamptz", "timestamp_ns", "timestamptz_ns"}
+VOID_TYPES = IDENTITY_TYPES | {"geometry", "geography"}
+
+
+def _type_family(type_name):
+ if not isinstance(type_name, str):
+ return None
+ if type_name.startswith("decimal("):
+ return "decimal"
+ if type_name.startswith("fixed["):
+ return "fixed"
+ if type_name.startswith("geometry("):
+ return "geometry"
+ if type_name.startswith("geography("):
+ return "geography"
+ return type_name
+
+
+def _transform_family(transform):
+ if not isinstance(transform, str):
+ return None
+ if transform.startswith("bucket["):
+ return "bucket"
+ if transform.startswith("truncate["):
+ return "truncate"
+ return transform
+
+
+def _json_scalar_equal(left, right):
+ return type(left) is type(right) and left == right
+
+
+def _partition_transform_errors(path, doc, check_bucket=True):
+ errors = []
+ cases = doc.get("cases") if isinstance(doc, dict) else None
+ if not isinstance(cases, list):
+ return errors
+
+ allowed_sources = {
+ "identity": IDENTITY_TYPES,
+ "bucket": BUCKET_TYPES,
+ "truncate": TRUNCATE_TYPES,
+ "year": DATE_TRANSFORM_TYPES,
+ "month": DATE_TRANSFORM_TYPES,
+ "day": DATE_TRANSFORM_TYPES,
+ "hour": HOUR_TYPES,
+ "void": VOID_TYPES,
+ }
+
+ for index, case in enumerate(cases):
+ if not isinstance(case, dict):
+ continue
+ input_value = case.get("input")
+ decoded = case.get("decoded")
+ if not isinstance(input_value, dict) or not isinstance(decoded, dict):
+ continue
+
+ transform = input_value.get("transform")
+ source_type = input_value.get("source_type")
+ values = input_value.get("values")
+ result_types = decoded.get("result_types")
+ expected = decoded.get("values")
+ transform_family = _transform_family(transform)
+ source_family = _type_family(source_type)
+ label = f"{path}: cases/{index}"
+
+ permitted = allowed_sources.get(transform_family)
+ if permitted is None:
+ errors.append(f"{label}: unknown transform '{transform}'")
+ elif source_family not in permitted:
+ errors.append(
+ f"{label}: transform '{transform}' does not support source
type "
+ f"'{source_type}'"
+ )
+
+ if isinstance(values, list) and isinstance(expected, list):
+ if len(values) != len(expected):
+ errors.append(
+ f"{label}: input.values and decoded.values must have equal
lengths"
+ )
+ if None not in values:
+ errors.append(f"{label}: input.values must include null")
+ for value_index, (value, output) in enumerate(zip(values,
expected)):
+ if value is None and output is not None:
+ errors.append(
+ f"{label}: decoded.values/{value_index} must be null
for a "
+ "null input"
+ )
+ if transform_family == "void" and output is not None:
+ errors.append(
+ f"{label}: decoded.values/{value_index} must be null
for void"
+ )
+ if transform_family == "identity" and not _json_scalar_equal(
+ value, output
+ ):
+ errors.append(
+ f"{label}: decoded.values/{value_index} must equal its
"
+ "identity input"
+ )
+
+ required_result_types = None
+ if transform_family in {"bucket", "year", "month", "hour"}:
+ required_result_types = {"int"}
+ elif transform_family == "day":
+ required_result_types = {"date"}
+ elif transform_family in {"identity", "truncate"} and isinstance(
+ source_type, str
+ ):
+ required_result_types = {source_type}
+ elif transform_family == "void" and isinstance(source_type, str):
+ required_result_types = {source_type, "int"}
+
+ if (
+ required_result_types is not None
+ and isinstance(result_types, list)
+ and all(isinstance(result_type, str) for result_type in
result_types)
+ ):
+ if set(result_types) != required_result_types:
+ errors.append(
+ f"{label}: decoded.result_types must be exactly "
+ f"{sorted(required_result_types)}"
+ )
+
+ bucket_path = path.replace("\\", "/").endswith(
+ "/partition-transforms/bucket/cases.json"
+ )
+ if check_bucket and not errors and bucket_path:
+ errors.extend(_bucket_errors(path, doc))
+
+ return errors
+
+
+EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
+TIMESTAMP = re.compile(
+ r"^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.(\d{1,9}))?(Z|[+-]\d\d:\d\d)?$"
+)
+MASK = 0xFFFFFFFF
+
+
+def murmur3_x86_32(data):
+ """MurmurHash3 x86 32-bit with seed 0, returned as an unsigned integer."""
+ h = 0
+ for offset in range(0, len(data) // 4 * 4, 4):
+ k = int.from_bytes(data[offset : offset + 4], "little")
+ k = (k * 0xCC9E2D51) & MASK
+ k = ((k << 15) | (k >> 17)) & MASK
+ k = (k * 0x1B873593) & MASK
+ h ^= k
+ h = ((h << 13) | (h >> 19)) & MASK
+ h = (h * 5 + 0xE6546B64) & MASK
+
+ tail = data[len(data) // 4 * 4 :]
+ if tail:
+ k = int.from_bytes(tail, "little")
+ k = (k * 0xCC9E2D51) & MASK
+ k = ((k << 15) | (k >> 17)) & MASK
+ k = (k * 0x1B873593) & MASK
+ h ^= k
+
+ h ^= len(data)
+ h ^= h >> 16
+ h = (h * 0x85EBCA6B) & MASK
+ h ^= h >> 13
+ h = (h * 0xC2B2AE35) & MASK
+ return (h ^ (h >> 16)) & MASK
+
+
+def epoch_units(value, nanos=False):
+ match = TIMESTAMP.fullmatch(value)
+ if not match:
+ raise ValueError(f"invalid timestamp: {value}")
+ base, fraction, zone = match.groups()
+ instant = datetime.fromisoformat(base + ("+00:00" if zone == "Z" else zone
or ""))
+ instant = instant.astimezone(timezone.utc) if zone else
instant.replace(tzinfo=timezone.utc)
+ delta = instant - EPOCH
+ seconds = delta.days * 86400 + delta.seconds
+ digits = (fraction or "").ljust(9, "0")
+ nanoseconds = seconds * 1_000_000_000 + int(digits or "0")
+ if nanos and nanoseconds < 0 and nanoseconds % 1000:
+ raise ValueError("negative nanoseconds between microseconds need a
spec rounding rule")
+ return nanoseconds // 1000
+
+
+def decimal_bytes(value):
+ decimal = Decimal(value)
+ digits = decimal.as_tuple()
+ unscaled = int("".join(map(str, digits.digits)))
+ if digits.sign:
+ unscaled = -unscaled
+ length = 1
+ while not -(1 << (length * 8 - 1)) <= unscaled < (1 << (length * 8 - 1)):
+ length += 1
+ return unscaled.to_bytes(length, "big", signed=True)
+
+
+def encode_for_hash(source_type, value):
+ if source_type in ("int", "long"):
+ return struct.pack("<q", int(value))
Review Comment:
For `int`, `long` and decimal the bucket check hashes whatever it's given,
so values that break the [README
encoding](https://github.com/apache/iceberg-verification/blob/a45307a7e7fa019fc339d03ba317387038030b12/table-spec/partition-transforms/README.md?plain=1#L100-L104)
are accepted: `int` `34.5` (hashed as 34), `true` (hashed as 1), a bare-number
`long` `34`, and `"14.2"` under `decimal(9, 2)`, since `decimal_bytes` never
reads `P` or `S`. The committed fixtures all follow the encoding.
##########
dev/validate-fixtures.py:
##########
@@ -47,10 +52,317 @@ def _is_types_surface(path):
return "/table-spec/types/" in p or p.startswith("table-spec/types/")
+def _is_partition_transforms_surface(path):
+ p = _rel(path)
+ return "/table-spec/partition-transforms/" in p or p.startswith(
+ "table-spec/partition-transforms/"
+ )
+
+
+IDENTITY_TYPES = {
+ "unknown",
+ "boolean",
+ "int",
+ "long",
+ "float",
+ "double",
+ "decimal",
+ "date",
+ "time",
+ "timestamp",
+ "timestamptz",
+ "timestamp_ns",
+ "timestamptz_ns",
+ "string",
+ "uuid",
+ "fixed",
+ "binary",
+}
+BUCKET_TYPES = {
+ "int",
+ "long",
+ "decimal",
+ "date",
+ "time",
+ "timestamp",
+ "timestamptz",
+ "timestamp_ns",
+ "timestamptz_ns",
+ "string",
+ "uuid",
+ "fixed",
+ "binary",
+}
+TRUNCATE_TYPES = {"int", "long", "decimal", "string", "binary"}
+DATE_TRANSFORM_TYPES = {
+ "date",
+ "timestamp",
+ "timestamptz",
+ "timestamp_ns",
+ "timestamptz_ns",
+}
+HOUR_TYPES = {"timestamp", "timestamptz", "timestamp_ns", "timestamptz_ns"}
+VOID_TYPES = IDENTITY_TYPES | {"geometry", "geography"}
+
+
+def _type_family(type_name):
+ if not isinstance(type_name, str):
+ return None
+ if type_name.startswith("decimal("):
+ return "decimal"
+ if type_name.startswith("fixed["):
+ return "fixed"
+ if type_name.startswith("geometry("):
+ return "geometry"
+ if type_name.startswith("geography("):
+ return "geography"
+ return type_name
+
+
+def _transform_family(transform):
+ if not isinstance(transform, str):
+ return None
+ if transform.startswith("bucket["):
+ return "bucket"
+ if transform.startswith("truncate["):
+ return "truncate"
+ return transform
+
+
+def _json_scalar_equal(left, right):
+ return type(left) is type(right) and left == right
+
+
+def _partition_transform_errors(path, doc, check_bucket=True):
+ errors = []
+ cases = doc.get("cases") if isinstance(doc, dict) else None
+ if not isinstance(cases, list):
+ return errors
+
+ allowed_sources = {
+ "identity": IDENTITY_TYPES,
+ "bucket": BUCKET_TYPES,
+ "truncate": TRUNCATE_TYPES,
+ "year": DATE_TRANSFORM_TYPES,
+ "month": DATE_TRANSFORM_TYPES,
+ "day": DATE_TRANSFORM_TYPES,
+ "hour": HOUR_TYPES,
+ "void": VOID_TYPES,
+ }
+
+ for index, case in enumerate(cases):
+ if not isinstance(case, dict):
+ continue
+ input_value = case.get("input")
+ decoded = case.get("decoded")
+ if not isinstance(input_value, dict) or not isinstance(decoded, dict):
+ continue
+
+ transform = input_value.get("transform")
+ source_type = input_value.get("source_type")
+ values = input_value.get("values")
+ result_types = decoded.get("result_types")
+ expected = decoded.get("values")
+ transform_family = _transform_family(transform)
+ source_family = _type_family(source_type)
+ label = f"{path}: cases/{index}"
+
+ permitted = allowed_sources.get(transform_family)
+ if permitted is None:
+ errors.append(f"{label}: unknown transform '{transform}'")
+ elif source_family not in permitted:
+ errors.append(
+ f"{label}: transform '{transform}' does not support source
type "
+ f"'{source_type}'"
+ )
+
+ if isinstance(values, list) and isinstance(expected, list):
+ if len(values) != len(expected):
+ errors.append(
+ f"{label}: input.values and decoded.values must have equal
lengths"
+ )
+ if None not in values:
+ errors.append(f"{label}: input.values must include null")
+ for value_index, (value, output) in enumerate(zip(values,
expected)):
+ if value is None and output is not None:
+ errors.append(
+ f"{label}: decoded.values/{value_index} must be null
for a "
+ "null input"
+ )
+ if transform_family == "void" and output is not None:
+ errors.append(
+ f"{label}: decoded.values/{value_index} must be null
for void"
+ )
+ if transform_family == "identity" and not _json_scalar_equal(
+ value, output
+ ):
+ errors.append(
+ f"{label}: decoded.values/{value_index} must equal its
"
+ "identity input"
+ )
+
+ required_result_types = None
+ if transform_family in {"bucket", "year", "month", "hour"}:
+ required_result_types = {"int"}
+ elif transform_family == "day":
+ required_result_types = {"date"}
+ elif transform_family in {"identity", "truncate"} and isinstance(
+ source_type, str
+ ):
+ required_result_types = {source_type}
+ elif transform_family == "void" and isinstance(source_type, str):
+ required_result_types = {source_type, "int"}
+
+ if (
+ required_result_types is not None
+ and isinstance(result_types, list)
+ and all(isinstance(result_type, str) for result_type in
result_types)
+ ):
+ if set(result_types) != required_result_types:
+ errors.append(
+ f"{label}: decoded.result_types must be exactly "
+ f"{sorted(required_result_types)}"
+ )
+
+ bucket_path = path.replace("\\", "/").endswith(
+ "/partition-transforms/bucket/cases.json"
+ )
+ if check_bucket and not errors and bucket_path:
+ errors.extend(_bucket_errors(path, doc))
+
+ return errors
+
+
+EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
+TIMESTAMP = re.compile(
+ r"^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.(\d{1,9}))?(Z|[+-]\d\d:\d\d)?$"
+)
+MASK = 0xFFFFFFFF
+
+
+def murmur3_x86_32(data):
+ """MurmurHash3 x86 32-bit with seed 0, returned as an unsigned integer."""
+ h = 0
+ for offset in range(0, len(data) // 4 * 4, 4):
+ k = int.from_bytes(data[offset : offset + 4], "little")
+ k = (k * 0xCC9E2D51) & MASK
+ k = ((k << 15) | (k >> 17)) & MASK
+ k = (k * 0x1B873593) & MASK
+ h ^= k
+ h = ((h << 13) | (h >> 19)) & MASK
+ h = (h * 5 + 0xE6546B64) & MASK
+
+ tail = data[len(data) // 4 * 4 :]
+ if tail:
+ k = int.from_bytes(tail, "little")
+ k = (k * 0xCC9E2D51) & MASK
+ k = ((k << 15) | (k >> 17)) & MASK
+ k = (k * 0x1B873593) & MASK
+ h ^= k
+
+ h ^= len(data)
+ h ^= h >> 16
+ h = (h * 0x85EBCA6B) & MASK
+ h ^= h >> 13
+ h = (h * 0xC2B2AE35) & MASK
+ return (h ^ (h >> 16)) & MASK
+
+
+def epoch_units(value, nanos=False):
+ match = TIMESTAMP.fullmatch(value)
+ if not match:
+ raise ValueError(f"invalid timestamp: {value}")
+ base, fraction, zone = match.groups()
+ instant = datetime.fromisoformat(base + ("+00:00" if zone == "Z" else zone
or ""))
+ instant = instant.astimezone(timezone.utc) if zone else
instant.replace(tzinfo=timezone.utc)
+ delta = instant - EPOCH
+ seconds = delta.days * 86400 + delta.seconds
+ digits = (fraction or "").ljust(9, "0")
+ nanoseconds = seconds * 1_000_000_000 + int(digits or "0")
+ if nanos and nanoseconds < 0 and nanoseconds % 1000:
+ raise ValueError("negative nanoseconds between microseconds need a
spec rounding rule")
+ return nanoseconds // 1000
+
+
+def decimal_bytes(value):
+ decimal = Decimal(value)
+ digits = decimal.as_tuple()
+ unscaled = int("".join(map(str, digits.digits)))
+ if digits.sign:
+ unscaled = -unscaled
+ length = 1
+ while not -(1 << (length * 8 - 1)) <= unscaled < (1 << (length * 8 - 1)):
+ length += 1
+ return unscaled.to_bytes(length, "big", signed=True)
+
+
+def encode_for_hash(source_type, value):
+ if source_type in ("int", "long"):
+ return struct.pack("<q", int(value))
+ if source_type.startswith("decimal("):
+ return decimal_bytes(value)
+ if source_type == "date":
+ days = (date.fromisoformat(value) - date(1970, 1, 1)).days
+ return struct.pack("<q", days)
+ if source_type == "time":
+ clock = time.fromisoformat(value)
+ micros = ((clock.hour * 60 + clock.minute) * 60 + clock.second) *
1_000_000
+ return struct.pack("<q", micros + clock.microsecond)
+ if source_type in ("timestamp", "timestamptz", "timestamp_ns",
"timestamptz_ns"):
+ return struct.pack("<q", epoch_units(value,
source_type.endswith("_ns")))
+ if source_type == "string":
+ return value.encode("utf-8")
+ if source_type == "uuid":
+ return uuid.UUID(value).bytes
+ if source_type == "binary" or source_type.startswith("fixed["):
+ return bytes.fromhex(value)
+ raise ValueError(f"unsupported bucket source type: {source_type}")
+
+
+def _bucket_errors(path, doc):
+ vectors = (
+ (struct.pack("<q", 34), 2017239379),
+ (struct.pack("<q", 17486), -653330422 & MASK),
+ (b"iceberg", 1210000089),
+ )
+ for data, expected_hash in vectors:
+ if murmur3_x86_32(data) != expected_hash:
+ raise AssertionError("Murmur3 does not reproduce an Appendix B
vector")
+
+ errors = []
+ for case_index, case in enumerate(doc["cases"]):
+ width = re.fullmatch(r"bucket\[(\d+)\]", case["input"]["transform"])
+ if not width or int(width[1]) <= 0:
+ errors.append(f"{path}: cases/{case_index}: invalid bucket
transform")
+ continue
+ count = int(width[1])
+ source_type = case["input"]["source_type"]
+ values = case["input"]["values"]
+ expected = case["decoded"]["values"]
+ for index, (value, wanted) in enumerate(zip(values, expected)):
+ try:
+ actual = None
+ if value is not None:
+ hashed = murmur3_x86_32(encode_for_hash(source_type,
value))
+ actual = (hashed & 0x7FFFFFFF) % count
+ except (ValueError, OverflowError, struct.error) as exc:
Review Comment:
A value that doesn't match `source_type` crashes the validator here instead
of producing a `FAILED:` report. With `"source_type": "string", "values":
[null, 5]` under `bucket/`, the run exits 1 with `AttributeError: 'int' object
has no attribute 'encode'` and no `cases/N` location; decimal, date, time,
timestamp, uuid and binary behave the same. The [selftest
contract](https://github.com/apache/iceberg-verification/blob/a45307a7e7fa019fc339d03ba317387038030b12/Makefile#L52-L55)
asks for a FAILED report, not a crash.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]