Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,44 @@ selftest: $(VENV_STAMP) ## Check the validator accepts a good fixture and reject
assert_reject "decoded decimal typo (precison)" '{"cases":[{"id":"a","valid":true,"input":"decimal(9,2)","decoded":{"type":"decimal","precison":9,"scale":2},"clause":"c","spec_ref":"s"}]}'; \
assert_reject "decoded fixed carrying len not length" '{"cases":[{"id":"a","valid":true,"input":"fixed[16]","decoded":{"type":"fixed","len":16},"clause":"c","spec_ref":"s"}]}'; \
assert_reject "decoded unknown type name" '{"cases":[{"id":"a","valid":true,"input":"x","decoded":{"type":"notatype"},"clause":"c","spec_ref":"s"}]}'; \
assert_reject "decoded simple type with extra key" '{"cases":[{"id":"a","valid":true,"input":"int","decoded":{"type":"int","precision":9},"clause":"c","spec_ref":"s"}]}'
assert_reject "decoded simple type with extra key" '{"cases":[{"id":"a","valid":true,"input":"int","decoded":{"type":"int","precision":9},"clause":"c","spec_ref":"s"}]}'; \
run_partition_transform_validator() { \
d="$$(mktemp -d)"; \
mkdir -p "$$d/table-spec/partition-transforms/x"; \
printf '%s' "$$1" > "$$d/table-spec/partition-transforms/x/cases.json"; \
rc=0; out="$$($(VENV_PYTHON) dev/validate-fixtures.py "$$d" 2>&1)" || rc=$$?; \
rm -rf "$$d"; \
}; \
assert_partition_transform_accept() { \
run_partition_transform_validator "$$2"; \
if [ "$$rc" -ne 0 ]; then \
echo "FAILED: validator rejected a well-formed fixture ($$1)"; echo "$$out"; exit 1; \
fi; \
echo "ok: accepted ($$1)"; \
}; \
assert_partition_transform_reject() { \
run_partition_transform_validator "$$2"; \
if [ "$$rc" -ne 1 ] || ! grep -q '^FAILED: ' <<< "$$out"; then \
echo "FAILED: validator did not reject a malformed fixture ($$1), exit code $$rc"; echo "$$out"; exit 1; \
fi; \
if [ -n "$$3" ] && ! grep -Fq "$$3" <<< "$$out"; then \
echo "FAILED: validator report did not contain '$$3' ($$1)"; echo "$$out"; exit 1; \
fi; \
echo "ok: rejected ($$1)"; \
}; \
assert_partition_transform_accept "well-formed partition transform" '{"cases":[{"id":"a","valid":true,"input":{"transform":"bucket[100]","source_type":"int","values":[34,null]},"decoded":{"result_types":["int"],"values":[79,null]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "unknown partition transform" '{"cases":[{"id":"a","valid":true,"input":{"transform":"mystery","source_type":"int","values":[1,null]},"decoded":{"result_types":["int"],"values":[1,null]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "unsupported transform source pair" '{"cases":[{"id":"a","valid":true,"input":{"transform":"bucket[10]","source_type":"boolean","values":[true,null]},"decoded":{"result_types":["int"],"values":[1,null]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "unequal partition transform value lengths" '{"cases":[{"id":"a","valid":true,"input":{"transform":"identity","source_type":"int","values":[1,null]},"decoded":{"result_types":["int"],"values":[1]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "partition transform without null input" '{"cases":[{"id":"a","valid":true,"input":{"transform":"identity","source_type":"int","values":[1]},"decoded":{"result_types":["int"],"values":[1]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "partition transform null propagation mismatch" '{"cases":[{"id":"a","valid":true,"input":{"transform":"bucket[10]","source_type":"int","values":[null]},"decoded":{"result_types":["int"],"values":[0]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "wrong bucket result type" '{"cases":[{"id":"a","valid":true,"input":{"transform":"bucket[10]","source_type":"long","values":["1",null]},"decoded":{"result_types":["long"],"values":[1,null]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "wrong day result type" '{"cases":[{"id":"a","valid":true,"input":{"transform":"day","source_type":"date","values":["1970-01-01",null]},"decoded":{"result_types":["int"],"values":["1970-01-01",null]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "identity value mismatch" '{"cases":[{"id":"a","valid":true,"input":{"transform":"identity","source_type":"string","values":["a",null]},"decoded":{"result_types":["string"],"values":["b",null]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "void missing an allowed result type" '{"cases":[{"id":"a","valid":true,"input":{"transform":"void","source_type":"string","values":["a",null]},"decoded":{"result_types":["int"],"values":[null,null]},"clause":"c","spec_ref":"s"}]}'; \
assert_partition_transform_reject "object in partition transform result types" '{"cases":[{"id":"a","valid":true,"input":{"transform":"bucket[100]","source_type":"int","values":[34,null]},"decoded":{"result_types":["int",{}],"values":[79,null]},"clause":"c","spec_ref":"s"}]}' 'cases/0/decoded/result_types/1'; \
assert_partition_transform_reject "nested list in partition transform result types" '{"cases":[{"id":"a","valid":true,"input":{"transform":"bucket[100]","source_type":"int","values":[34,null]},"decoded":{"result_types":[[]],"values":[79,null]},"clause":"c","spec_ref":"s"}]}' 'cases/0/decoded/result_types/0'; \
assert_partition_transform_reject "partition transform misspelled field" '{"cases":[{"id":"a","valid":true,"input":{"transform":"identity","source-type":"int","values":[1,null]},"decoded":{"result_types":["int"],"values":[1,null]},"clause":"c","spec_ref":"s"}]}'

license: ## Check ASF license headers with Apache RAT
dev/check-license
Expand Down
99 changes: 99 additions & 0 deletions dev/schema/cases.partition-transforms.schema.json
Original file line number Diff line number Diff line change
@@ -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)$"
},
"source_type": { "$ref": "#/$defs/typeName" },
"values": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/value" }
}
}
},
"decoded": {
"type": "object",
"required": ["result_types", "values"],
"additionalProperties": false,
"properties": {
"result_types": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/$defs/typeName" }
},
"values": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/value" }
}
}
},
"typeName": {
"type": "string",
"anyOf": [
{
"enum": [
"unknown",
"boolean",
"int",
"long",
"float",
"double",
"date",
"time",
"timestamp",
"timestamptz",
"timestamp_ns",
"timestamptz_ns",
"string",
"uuid",
"binary",
"geometry",
"geography"
]
},
{ "pattern": "^decimal\\([1-9][0-9]*, [0-9]+\\)$" },
{ "pattern": "^fixed\\[[1-9][0-9]*\\]$" },
{ "pattern": "^geometry\\([^()]+\\)$" },
{ "pattern": "^geography\\([^()]+, [^()]+\\)$" }
]
},
"value": {
"type": ["null", "boolean", "number", "string"]
}
}
}
189 changes: 186 additions & 3 deletions dev/validate-fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Validate the conformance type fixtures against the JSON Schemas in dev/schema/.
"""Validate conformance fixtures against the JSON Schemas in dev/schema/.

Each cases.json is validated against the base structural schema and the types schema
(which requires clause and spec_ref); case ids must be unique within a surface.
Each cases.json is validated against the base structural schema and its surface
schema, when present; case ids must be unique within a surface.
"""

import glob
Expand Down Expand Up @@ -47,10 +47,188 @@ 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):
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)}"
)

return errors


def main():
root = sys.argv[1] if len(sys.argv) > 1 else "."
base_validator = jsonschema.Draft202012Validator(_load_schema("cases.base.schema.json"))
types_validator = jsonschema.Draft202012Validator(_load_schema("cases.types.schema.json"))
partition_transforms_validator = jsonschema.Draft202012Validator(
_load_schema("cases.partition-transforms.schema.json")
)

files = sorted(glob.glob(f"{root}/table-spec/**/cases.json", recursive=True))
if not files:
Expand All @@ -71,11 +249,16 @@ def main():
validators = [base_validator]
if _is_types_surface(path):
validators.append(types_validator)
if _is_partition_transforms_surface(path):
validators.append(partition_transforms_validator)
for validator in validators:
for err in sorted(validator.iter_errors(doc), key=str):
where = "/".join(str(p) for p in err.absolute_path) or "(root)"
errors.append(f"{path}: {where}: {err.message}")

if _is_partition_transforms_surface(path):
errors.extend(_partition_transform_errors(path, doc))

cases = doc.get("cases") if isinstance(doc, dict) else None
if isinstance(cases, list):
# ids are unique per surface (the top-level dir under table-spec), so a
Expand Down
Loading