blob: ad78d30ce09f0076a7266f75bfd0272eff6b54c9 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Ossie Semantic Model Validator
Validates Ossie YAML files against:
1. JSON Schema (structure, types, enums)
2. Unique names (datasets, fields, metrics, relationships)
3. Valid relationship references
4. SQL syntax (using sqlglot)
Usage:
python validation/validate.py <yaml_file>
python validation/validate.py <yaml_file> --schema ontology/ontology.json
python validation/validate.py examples/tpcds_semantic_model.yaml
"""
import json
import sys
from pathlib import Path
try:
import yaml
from jsonschema import Draft202012Validator
except ImportError:
print("Missing dependencies. Install with:")
print(" pip install pyyaml jsonschema")
sys.exit(1)
try:
import sqlglot
from sqlglot.errors import ParseError
SQLGLOT_AVAILABLE = True
except ImportError:
SQLGLOT_AVAILABLE = False
# Map Ossie dialects to sqlglot dialects
DIALECT_MAP = {
"ANSI_SQL": None, # sqlglot default
"SNOWFLAKE": "snowflake",
"DATABRICKS": "databricks",
"MDX": None, # Not supported by sqlglot, skip validation
"TABLEAU": None, # Not supported by sqlglot, skip validation
"MAQL": None, # Not supported by sqlglot, skip validation
}
# Dialects that sqlglot cannot parse
SKIP_SQL_VALIDATION = {"MDX", "TABLEAU", "MAQL"}
def validate_schema(data: dict, schema: dict) -> list[str]:
"""Validate against JSON Schema."""
validator = Draft202012Validator(schema)
errors = []
for error in validator.iter_errors(data):
path = " -> ".join(str(p) for p in error.absolute_path) if error.absolute_path else "(root)"
errors.append(f"[Schema] {path}: {error.message}")
return errors
def find_duplicates(items: list[str]) -> list[str]:
"""Find duplicate items in a list."""
seen = set()
duplicates = []
for item in items:
if item in seen:
duplicates.append(item)
seen.add(item)
return duplicates
def validate_unique_names(data: dict) -> list[str]:
"""Validate unique names for datasets, fields, metrics, relationships."""
errors = []
for model in data.get("semantic_model", []):
model_name = model.get("name", "<unnamed>")
# Check unique dataset names
dataset_names = [d.get("name") for d in model.get("datasets", []) if d.get("name")]
for dup in find_duplicates(dataset_names):
errors.append(f"[Unique] Duplicate dataset name '{dup}' in model '{model_name}'")
# Check unique field names within each dataset
for dataset in model.get("datasets", []):
dataset_name = dataset.get("name", "<unnamed>")
field_names = [f.get("name") for f in dataset.get("fields", []) if f.get("name")]
for dup in find_duplicates(field_names):
errors.append(f"[Unique] Duplicate field name '{dup}' in dataset '{dataset_name}'")
# Check unique metric names
metric_names = [m.get("name") for m in model.get("metrics", []) if m.get("name")]
for dup in find_duplicates(metric_names):
errors.append(f"[Unique] Duplicate metric name '{dup}' in model '{model_name}'")
# Check unique relationship names
rel_names = [r.get("name") for r in model.get("relationships", []) if r.get("name")]
for dup in find_duplicates(rel_names):
errors.append(f"[Unique] Duplicate relationship name '{dup}' in model '{model_name}'")
return errors
def validate_references(data: dict) -> list[str]:
"""Validate that relationships reference existing datasets."""
errors = []
for model in data.get("semantic_model", []):
model_name = model.get("name", "<unnamed>")
dataset_names = {d.get("name") for d in model.get("datasets", []) if d.get("name")}
for rel in model.get("relationships", []):
rel_name = rel.get("name", "<unnamed>")
from_ds = rel.get("from")
to_ds = rel.get("to")
if from_ds and from_ds not in dataset_names:
errors.append(f"[Reference] Relationship '{rel_name}' references unknown dataset '{from_ds}'")
if to_ds and to_ds not in dataset_names:
errors.append(f"[Reference] Relationship '{rel_name}' references unknown dataset '{to_ds}'")
return errors
def validate_sql_expression(expr: str, dialect: str, context: str) -> str | None:
"""Validate a single SQL expression. Returns error message or None if valid."""
if not SQLGLOT_AVAILABLE:
return None
if dialect in SKIP_SQL_VALIDATION:
return None
sqlglot_dialect = DIALECT_MAP.get(dialect)
try:
# Try parsing as expression first (for field expressions like "column_name")
sqlglot.parse_one(expr, dialect=sqlglot_dialect)
return None
except ParseError:
pass
try:
# Try wrapping in SELECT for simple column references
sqlglot.parse_one(f"SELECT {expr}", dialect=sqlglot_dialect)
return None
except ParseError as e:
return f"[SQL] {context}: {str(e).split(chr(10))[0]}"
def validate_sql(data: dict) -> list[str]:
"""Validate SQL expressions in fields and metrics."""
# Only semantic model files contain SQL expressions to validate.
if not data.get("semantic_model"):
return []
if not SQLGLOT_AVAILABLE:
return ["[SQL] Warning: sqlglot not installed, skipping SQL validation. Install with: pip install sqlglot"]
errors = []
for model in data.get("semantic_model", []):
model_name = model.get("name", "<unnamed>")
# Validate field expressions
for dataset in model.get("datasets", []):
dataset_name = dataset.get("name", "<unnamed>")
for field in dataset.get("fields", []):
field_name = field.get("name", "<unnamed>")
expression = field.get("expression", {})
for dialect_expr in expression.get("dialects", []):
dialect = dialect_expr.get("dialect", "ANSI_SQL")
expr = dialect_expr.get("expression", "")
if expr:
context = f"Field '{dataset_name}.{field_name}' ({dialect})"
error = validate_sql_expression(expr, dialect, context)
if error:
errors.append(error)
# Validate metric expressions
for metric in model.get("metrics", []):
metric_name = metric.get("name", "<unnamed>")
expression = metric.get("expression", {})
for dialect_expr in expression.get("dialects", []):
dialect = dialect_expr.get("dialect", "ANSI_SQL")
expr = dialect_expr.get("expression", "")
if expr:
context = f"Metric '{metric_name}' ({dialect})"
error = validate_sql_expression(expr, dialect, context)
if error:
errors.append(error)
return errors
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
args = sys.argv[1:]
yaml_path = Path(args[0])
schema_path = Path(__file__).parent.parent / "core-spec" / "ossie-schema.json"
if len(args) > 1:
if len(args) == 3 and args[1] == "--schema":
schema_path = Path(args[2])
else:
print("Usage: python validation/validate.py <yaml_file> [--schema <schema_file>]")
sys.exit(1)
if not yaml_path.exists():
print(f"Error: File not found: {yaml_path}")
sys.exit(1)
if not schema_path.exists():
print(f"Error: Schema not found: {schema_path}")
sys.exit(1)
# Load files
with open(schema_path) as f:
schema = json.load(f)
with open(yaml_path) as f:
try:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
print(f"Error: Invalid YAML: {e}")
sys.exit(1)
# Run validations
errors = []
errors.extend(validate_schema(data, schema))
# Run semantic-model-specific checks only for semantic model payloads.
if data.get("semantic_model"):
errors.extend(validate_unique_names(data))
errors.extend(validate_references(data))
errors.extend(validate_sql(data))
# Report results
if errors:
# Separate warnings from errors
warnings = [e for e in errors if "Warning:" in e]
actual_errors = [e for e in errors if "Warning:" not in e]
for warning in warnings:
print(f" {warning}")
if actual_errors:
print(f"\nValidation FAILED with {len(actual_errors)} error(s):\n")
for error in actual_errors:
print(f" {error}")
sys.exit(1)
else:
print(f"Validation PASSED: {yaml_path.name}")
sys.exit(0)
else:
print(f"Validation PASSED: {yaml_path.name}")
sys.exit(0)
if __name__ == "__main__":
main()