feat(appliance): initial release of MERICA standalone appliance v1.0.0

This commit is contained in:
2026-10-21 17:16:46 +07:00
commit 851be0cda7
144 changed files with 31135 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
import os
import re
import logging
import yaml
from typing import List, Dict, Any, Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
def infer_data_type(col_name: str) -> str:
"""Infer standard Lakehouse data type for dbt contract enforcement."""
name = col_name.lower().strip()
if name.endswith("_date") or name == "date":
return "date"
if name.endswith("_at") or "timestamp" in name or name.endswith("_time"):
return "timestamp with time zone"
if any(k in name for k in ["score", "rate", "amount", "percent", "price", "val", "total", "count"]):
return "numeric"
if name.endswith("_id") or name.endswith("_number") or name == "id" or "code" in name or "status" in name or "type" in name:
return "character varying(50)"
return "character varying(255)"
def generate_model_schema_dict(
model_name: str,
test_configs: Optional[Dict[str, Any]] = None,
sql_query: Optional[str] = None
) -> Dict[str, Any]:
"""Generate dbt schema dictionary for a model, configuring tests and contract enforcement."""
clean_name = model_name.replace(".sql", "")
cfg = test_configs or {}
unique_cols = cfg.get("unique_columns") or cfg.get("unique_cols") or []
if isinstance(unique_cols, str):
unique_cols = [c.strip() for c in unique_cols.split(",") if c.strip()]
not_null_cols = cfg.get("not_null_columns") or cfg.get("not_null_cols") or []
if isinstance(not_null_cols, str):
not_null_cols = [c.strip() for c in not_null_cols.split(",") if c.strip()]
accepted_values = cfg.get("accepted_values") or {}
relationships = cfg.get("relationships") or {}
enforce_contract = cfg.get("enforce_contract", False)
column_types = cfg.get("column_types") or {}
# Collect all configured columns
all_cols = set(unique_cols) | set(not_null_cols) | set(accepted_values.keys()) | set(relationships.keys()) | set(column_types.keys())
# If contract is enforced, extract all output columns from SQL query to satisfy dbt contract rules
effective_sql = sql_query or cfg.get("sql_query") or cfg.get("query") or ""
if enforce_contract and effective_sql:
m = re.search(r'SELECT\s+(.*?)\s+FROM', effective_sql, re.IGNORECASE | re.DOTALL)
if m:
for raw_col in m.group(1).split(","):
raw_col = raw_col.strip()
if not raw_col:
continue
if " as " in raw_col.lower():
col_id = re.split(r'\s+as\s+', raw_col, flags=re.IGNORECASE)[-1].strip().strip('"').strip("'")
else:
col_id = raw_col.split(".")[-1].strip().strip('"').strip("'")
if col_id and re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', col_id):
all_cols.add(col_id)
columns_list = []
for col in sorted(all_cols):
col_tests = []
if col in unique_cols:
col_tests.append("unique")
if col in not_null_cols:
col_tests.append("not_null")
if col in accepted_values and accepted_values[col]:
vals = accepted_values[col]
if isinstance(vals, str):
vals = [v.strip() for v in vals.split(",") if v.strip()]
col_tests.append({"accepted_values": {"values": vals}})
if col in relationships and relationships[col]:
rel_info = relationships[col]
if isinstance(rel_info, dict):
col_tests.append({"relationships": {
"to": rel_info.get("to", "ref('upstream')"),
"field": rel_info.get("field", "id")
}})
col_dict: Dict[str, Any] = {"name": col}
if col_tests:
col_dict["tests"] = col_tests
if col in column_types:
col_dict["data_type"] = column_types[col]
elif enforce_contract:
col_dict["data_type"] = infer_data_type(col)
columns_list.append(col_dict)
model_entry: Dict[str, Any] = {
"name": clean_name,
"description": f"dbt Model {clean_name} auto-registered with Shift-Left quality assertions"
}
if enforce_contract:
model_entry["config"] = {"contract": {"enforced": True}}
if columns_list:
model_entry["columns"] = columns_list
return model_entry
def compile_dbt_model(
model_name: str,
layer: str = "silver",
sql_query: str = "",
test_configs: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Perform pre-flight compilation check for dbt models:
- Verifies Jinja delimiters pairing.
- Extracts {{ ref('...') }} and {{ source('...', '...') }}.
- Compiles Jinja refs into simulated physical lakehouse identifiers.
- Validates SQL structure (SELECT/WITH statements, parentheses matching).
- Generates corresponding dbt schema.yml definition with quality tests.
"""
clean_name = (model_name or "model").replace(".sql", "")
errors: List[str] = []
warnings: List[str] = []
if not sql_query or not sql_query.strip():
errors.append("SQL query cannot be empty.")
# 1. Jinja delimiter syntax checking
open_brackets = len(re.findall(r'\{\{', sql_query))
close_brackets = len(re.findall(r'\}\}', sql_query))
if open_brackets != close_brackets:
errors.append(f"Mismatched Jinja expression delimiters: {open_brackets} '{{{{' vs {close_brackets} '}}}}'.")
open_blocks = len(re.findall(r'\{%', sql_query))
close_blocks = len(re.findall(r'%\}', sql_query))
if open_blocks != close_blocks:
errors.append(f"Mismatched Jinja block delimiters: {open_blocks} '{{%' vs {close_blocks} '%}}'.")
# 2. Extract references & sources
extracted_refs = re.findall(r"ref\(\s*['\"]([^'\"]+)['\"]\s*\)", sql_query)
extracted_sources = re.findall(r"source\(\s*['\"]([^'\"]+)['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)", sql_query)
# 3. Compile simulated SQL (substituting Jinja with lakehouse catalogue paths)
catalog = os.getenv("ICEBERG_CATALOG_NAME", "dlh_lakehouse")
compiled_sql = sql_query
for ref in extracted_refs:
escaped_ref = re.escape(ref)
replacement = f"{catalog}.{layer}.{ref}" if not ref.startswith("bronze") else f"{catalog}.bronze.{ref}"
pattern = re.compile(r"\{\{\s*ref\(\s*['\"]" + escaped_ref + r"['\"]\s*\)\s*\}\}")
compiled_sql = pattern.sub(replacement, compiled_sql)
for src_schema, src_tbl in extracted_sources:
escaped_schema = re.escape(src_schema)
escaped_tbl = re.escape(src_tbl)
replacement = f"{catalog}.bronze.{src_tbl}"
pattern = re.compile(r"\{\{\s*source\(\s*['\"]" + escaped_schema + r"['\"]\s*,\s*['\"]" + escaped_tbl + r"['\"]\s*\)\s*\}\}")
compiled_sql = pattern.sub(replacement, compiled_sql)
# Clean up dbt config() macro in compiled preview
compiled_sql = re.sub(r"\{\{\s*config\([^)]*\)\s*\}\}", "-- [dbt config applied]", compiled_sql)
# 4. Basic SQL structural validation
clean_sql = re.sub(r'--.*', '', compiled_sql).strip()
if clean_sql:
if not re.search(r'\b(select|with)\b', clean_sql, re.IGNORECASE):
warnings.append("SQL query does not start with SELECT or WITH clause; ensure it produces a valid dataset.")
if clean_sql.count('(') != clean_sql.count(')'):
errors.append("Unbalanced parentheses '(' and ')' detected in SQL query.")
# 5. Generate Schema YAML testing representation
schema_dict = generate_model_schema_dict(clean_name, test_configs, sql_query)
wrapper = {"version": 2, "models": [schema_dict]}
schema_yml_str = yaml.dump(wrapper, sort_keys=False, default_flow_style=False)
is_valid = len(errors) == 0
return {
"status": "success" if is_valid else "error",
"valid": is_valid,
"model_name": clean_name,
"layer": layer,
"compiled_sql": compiled_sql.strip(),
"schema_yml": schema_yml_str,
"extracted_refs": extracted_refs,
"extracted_sources": [{"schema": s[0], "table": s[1]} for s in extracted_sources],
"warnings": warnings,
"errors": errors
}
def sync_dbt_models_from_nodes(nodes: List[Dict[str, Any]]) -> List[str]:
"""Sync dbt SQL model files, schema.yml (with tests), and sources.yml physically in both dbt target directories."""
synced_files = []
target_dbt_dirs = [settings.DBT_DIR]
if hasattr(settings, "DEFAULT_DBT_DIR") and settings.DEFAULT_DBT_DIR and settings.DEFAULT_DBT_DIR != settings.DBT_DIR:
target_dbt_dirs.append(settings.DEFAULT_DBT_DIR)
for base_dbt_dir in target_dbt_dirs:
try:
models_dir = os.path.join(base_dbt_dir, "models")
os.makedirs(models_dir, exist_ok=True)
models_by_layer: Dict[str, List[Dict[str, Any]]] = {}
# 1. Sync dbt SQL models
for node in nodes:
node_type = node.get("data", {}).get("type", "")
cfg = node.get("data", {}).get("config", {})
if node_type == "dbt_transform":
layer = (cfg.get("dbt_layer") or cfg.get("sel") or "silver").lower()
model_name = cfg.get("dbt_model") or cfg.get("sel") or node.get("data", {}).get("label", "").lower().replace(" ", "_")
if not model_name:
continue
if not model_name.endswith(".sql"):
model_name += ".sql"
target_dir = os.path.join(models_dir, layer)
os.makedirs(target_dir, exist_ok=True)
sql_content = cfg.get("query") or cfg.get("sql_query_preview") or f"-- dbt Model: {model_name}\nSELECT * FROM {{{{ ref('bronze_raw') }}}}"
model_path = os.path.join(target_dir, model_name)
with open(model_path, "w", encoding="utf-8") as f:
f.write(sql_content)
logger.info("Auto-synced dbt model file to %s", model_path)
synced_files.append(model_path)
# Collect for schema.yml tests
test_cfgs = cfg.get("test_configs") or cfg.get("quality_tests")
if not test_cfgs and any(k in cfg for k in ["unique_columns", "not_null_columns", "accepted_values", "enforce_contract"]):
test_cfgs = {
"unique_columns": cfg.get("unique_columns") or [],
"not_null_columns": cfg.get("not_null_columns") or [],
"accepted_values": cfg.get("accepted_values") or {},
"enforce_contract": cfg.get("enforce_contract") or False,
}
if test_cfgs:
model_entry = generate_model_schema_dict(model_name, test_cfgs, sql_content)
models_by_layer.setdefault(layer, []).append(model_entry)
# 2. Auto-sync schema.yml per layer if test_configs are present
for layer, m_list in models_by_layer.items():
if not m_list:
continue
schema_file = os.path.join(models_dir, layer, "schema.yml")
existing_schema: Dict[str, Any] = {"version": 2, "models": []}
if os.path.exists(schema_file):
try:
with open(schema_file, "r", encoding="utf-8") as sf:
existing_schema = yaml.safe_load(sf) or {"version": 2, "models": []}
except Exception:
pass
existing_models = {m.get("name"): m for m in existing_schema.get("models", [])}
for new_m in m_list:
existing_models[new_m["name"]] = new_m
existing_schema["models"] = list(existing_models.values())
with open(schema_file, "w", encoding="utf-8") as sf:
yaml.safe_dump(existing_schema, sf, sort_keys=False, default_flow_style=False)
logger.info("Auto-synced dbt schema tests to %s", schema_file)
synced_files.append(schema_file)
# 3. Auto-sync sources.yml from source_db nodes
sources_file = os.path.join(models_dir, "sources.yml")
source_tables_by_schema = {}
for node in nodes:
node_type = node.get("data", {}).get("type", "")
cfg = node.get("data", {}).get("config", {})
if node_type in ["source_db", "database"]:
tbl = cfg.get("table_name", "")
if tbl:
parts = tbl.split(".")
s_name = parts[0] if len(parts) > 1 else "public"
t_name = parts[1] if len(parts) > 1 else parts[0]
source_tables_by_schema.setdefault(s_name, set()).add(t_name)
if source_tables_by_schema and os.path.exists(sources_file):
try:
with open(sources_file, "r", encoding="utf-8") as sf:
src_data = yaml.safe_load(sf) or {}
sources_list = src_data.get("sources", [])
modified = False
for schema_name, tbl_set in source_tables_by_schema.items():
schema_entry = next((s for s in sources_list if s.get("schema") == schema_name), None)
if not schema_entry:
schema_entry = {"name": schema_name, "schema": schema_name, "tables": []}
sources_list.append(schema_entry)
modified = True
existing_tables = {t.get("name") for t in schema_entry.get("tables", [])}
for t in tbl_set:
if t not in existing_tables:
schema_entry.setdefault("tables", []).append({
"name": t,
"description": f"Auto-registered source table '{t}' from canvas pipeline"
})
modified = True
if modified:
src_data["sources"] = sources_list
with open(sources_file, "w", encoding="utf-8") as sf:
yaml.safe_dump(src_data, sf, sort_keys=False, default_flow_style=False)
logger.info("Auto-synced sources.yml at %s", sources_file)
except Exception as se:
logger.warning("Auto-sync sources.yml warning: %s", se)
# 4. Auto-sync bronze view models from source_db tables so downstream models can ref() them
bronze_dir = os.path.join(models_dir, "bronze")
os.makedirs(bronze_dir, exist_ok=True)
for schema_name, tbl_set in source_tables_by_schema.items():
for t_name in tbl_set:
m_name = f"bronze_{t_name}.sql" if not t_name.startswith("bronze_") else f"{t_name}.sql"
m_path = os.path.join(bronze_dir, m_name)
if not os.path.exists(m_path):
b_content = (
f"-- dbt Bronze View Model: {t_name}\n"
f"{{{{ config(materialized='view') }}}}\n\n"
f"SELECT * FROM {{{{ source('{schema_name}', '{t_name}') }}}}\n"
)
with open(m_path, "w", encoding="utf-8") as bf:
bf.write(b_content)
logger.info("Auto-synced bronze dbt model to %s", m_path)
synced_files.append(m_path)
except Exception as e:
logger.warning("dbt Model Sync Note: %s", e)
return synced_files