| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382 |
- from __future__ import annotations
- import re
- from pathlib import Path
- import polars as pl
- from minio import Minio
- from sqlalchemy import create_engine, text
- from app.core.common.identifiers import new_governance_uid
- from app.core.data_rules.contracts import rule_spec_hash, validate_rule_spec
- from app.core.data_rules.execution_contracts import canonical_schema_hash
- COMPOSE = (
- Path(__file__).resolve().parents[2]
- / "deploy"
- / "docker"
- / "docker-compose.yml"
- )
- def _compose_value(pattern):
- source = COMPOSE.read_text(encoding="utf-8")
- match = re.search(pattern, source, flags=re.DOTALL)
- assert match is not None
- return match.group(1)
- def _schema(schema_ref, fields):
- normalized = [
- {"name": name, "type": field_type, "nullable": nullable}
- for name, field_type, nullable in fields
- ]
- return {
- "id": new_governance_uid(),
- "schema_ref": schema_ref,
- "schema_hash": canonical_schema_hash(normalized),
- "fields": normalized,
- "source_revision": "task5:real-cross-source",
- }
- def _binding(schema, *, source_uid, access_mode, object_ref):
- return {
- "id": new_governance_uid(),
- "data_source_uid": source_uid,
- "object_kind": "parquet_artifact",
- "object_ref": object_ref,
- "schema_snapshot_id": schema["id"],
- "access_mode": access_mode,
- "dialect": "parquet",
- "write_mode": "append",
- }
- class Resolver:
- def __init__(self, artifacts):
- self.artifacts = artifacts
- def resolve(self, *, binding_id, correlation_id):
- artifact = self.artifacts[binding_id]
- assert f"/rules/{correlation_id}/" in artifact["artifact_ref"]
- return artifact
- def test_real_postgres_mysql_minio_polars_cross_source_execution():
- from app.core.data_rules.compilers.polars import PolarsRuleCompiler
- from app.runner.artifacts import ArtifactStore
- from app.runner.rule_polars import PolarsRulePlanAdapter
- source_user = _compose_value(
- r"source-postgres:.*?POSTGRES_USER:\s*([^\s]+)"
- )
- source_password = _compose_value(
- r"source-postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
- )
- postgres_port = _compose_value(r'"(25432):5432"')
- mysql_port = _compose_value(r'"(23306):3306"')
- minio_user = _compose_value(r"MINIO_ROOT_USER:\s*([^\s]+)")
- minio_password = _compose_value(r"MINIO_ROOT_PASSWORD:\s*([^\s]+)")
- minio_port = _compose_value(r'"(19000):9000"')
- bucket = _compose_value(r"mc mb --ignore-existing local/([^\s]+)")
- postgres = create_engine(
- f"postgresql+psycopg2://{source_user}:{source_password}"
- f"@127.0.0.1:{postgres_port}/acceptance",
- pool_pre_ping=True,
- )
- mysql = create_engine(
- f"mysql+pymysql://{source_user}:{source_password}"
- f"@127.0.0.1:{mysql_port}/acceptance",
- pool_pre_ping=True,
- )
- minio = Minio(
- f"127.0.0.1:{minio_port}",
- access_key=minio_user,
- secret_key=minio_password,
- secure=False,
- )
- store = ArtifactStore(
- minio,
- bucket=bucket,
- max_artifact_bytes=4 * 1024 * 1024,
- max_rows=1_000,
- memory_limit_bytes=16 * 1024 * 1024,
- max_ttl_seconds=3600,
- )
- correlation_id = new_governance_uid()
- prefix = f"rules/{correlation_id}/"
- customer_table = "task5_polars_customers"
- segment_table = "task5_polars_segments"
- try:
- with postgres.begin() as connection:
- connection.execute(text(f"DROP TABLE IF EXISTS {customer_table}"))
- connection.execute(
- text(
- f"CREATE TABLE {customer_table} ("
- "customer_id BIGINT NOT NULL, "
- "name VARCHAR(100), mobile VARCHAR(30), "
- "segment_code VARCHAR(20), version_no BIGINT NOT NULL)"
- )
- )
- connection.execute(
- text(
- f"INSERT INTO {customer_table} "
- "(customer_id, name, mobile, segment_code, version_no) "
- "VALUES "
- "(1, ' Alice ', '13800138000', 'A', 1), "
- "(1, ' Alice Updated ', '13800138000', 'A', 2), "
- "(2, ' Bad ', 'invalid', 'B', 1), "
- "(3, ' Carol ', '13900139000', 'C', 1)"
- )
- )
- with mysql.begin() as connection:
- connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
- connection.execute(
- text(
- f"CREATE TABLE {segment_table} ("
- "code VARCHAR(20) PRIMARY KEY, "
- "segment_name VARCHAR(100) NOT NULL)"
- )
- )
- connection.execute(
- text(
- f"INSERT INTO {segment_table} (code, segment_name) "
- "VALUES ('A', 'Gold'), ('B', 'Basic'), ('C', 'Silver')"
- )
- )
- with postgres.connect() as connection:
- customer_rows = [
- dict(row)
- for row in connection.execute(
- text(
- f"SELECT customer_id, name, mobile, "
- f"segment_code, version_no FROM {customer_table}"
- )
- ).mappings()
- ]
- with mysql.connect() as connection:
- segment_rows = [
- dict(row)
- for row in connection.execute(
- text(
- f"SELECT code, segment_name FROM {segment_table}"
- )
- ).mappings()
- ]
- customer_artifact = store.write(
- pl.DataFrame(customer_rows).lazy(), correlation_id, 900
- )
- segment_artifact = store.write(
- pl.DataFrame(segment_rows).lazy(), correlation_id, 900
- )
- input_schema = _schema(
- "bd:task5:customer:raw",
- [
- ("customer_id", "integer", False),
- ("name", "string", True),
- ("mobile", "string", True),
- ("segment_code", "string", True),
- ("version_no", "integer", False),
- ],
- )
- lookup_schema = _schema(
- "bd:task5:segment:lookup",
- [
- ("code", "string", False),
- ("segment_name", "string", False),
- ],
- )
- output_schema = _schema(
- "bd:task5:customer:enriched",
- [
- ("customer_id", "integer", False),
- ("name", "string", True),
- ("mobile", "string", True),
- ("segment_code", "string", True),
- ("version_no", "integer", False),
- ("segment_name", "string", True),
- ],
- )
- input_binding = _binding(
- input_schema,
- source_uid=new_governance_uid(),
- access_mode="read",
- object_ref="postgres-customer-artifact",
- )
- lookup_binding = _binding(
- lookup_schema,
- source_uid=new_governance_uid(),
- access_mode="read",
- object_ref="mysql-segment-artifact",
- )
- output_binding = _binding(
- output_schema,
- source_uid=new_governance_uid(),
- access_mode="write",
- object_ref="polars-output-artifact",
- )
- spec = validate_rule_spec(
- {
- "schema_version": "2.0",
- "rule_uid": new_governance_uid(),
- "name": "task5_real_cross_source",
- "input_schema_ref": input_schema["schema_ref"],
- "output_schema_ref": output_schema["schema_ref"],
- "steps": [
- {
- "id": "normalize_name",
- "op": "normalize_text",
- "column": "name",
- "trim": True,
- },
- {
- "id": "join_segment",
- "op": "lookup_join",
- "lookup": {
- "binding_id": lookup_binding["id"],
- "left_on": ["segment_code"],
- "right_on": ["code"],
- "select": {
- "segment_name": "segment_name"
- },
- "how": "left",
- },
- },
- {
- "id": "valid_mobile",
- "op": "assert",
- "expression": "matches(mobile, '^[0-9]{11}$')",
- "on_failure": "reject",
- "severity": "error",
- },
- {
- "id": "latest_customer",
- "op": "deduplicate",
- "keys": ["customer_id"],
- "order_by": ["version_no"],
- "keep": "last",
- },
- ],
- "null_policy": "explicit",
- "timezone": "Asia/Shanghai",
- }
- )
- rule = {
- "id": new_governance_uid(),
- "status": "published",
- "rule_spec": spec,
- "spec_hash": rule_spec_hash(spec),
- }
- compiled = PolarsRuleCompiler().compile(
- rule_version=rule,
- input_schema=input_schema,
- output_schema=output_schema,
- input_binding=input_binding,
- output_binding=output_binding,
- backend={
- "max_rows": 1_000,
- "max_artifact_bytes": 4 * 1024 * 1024,
- "memory_limit_bytes": 16 * 1024 * 1024,
- "masking_policies": {},
- "lookup_bindings": {
- lookup_binding["id"]: {
- "binding": lookup_binding,
- "schema": lookup_schema,
- }
- },
- },
- )
- lookup_operation = compiled["plan"]["operations"][1]
- resolver = Resolver(
- {
- input_binding["id"]: {
- **customer_artifact,
- "binding_hash": compiled["plan"][
- "input_binding_hash"
- ],
- },
- lookup_binding["id"]: {
- **segment_artifact,
- "binding_hash": lookup_operation[
- "lookup_binding_hash"
- ],
- },
- }
- )
- node = {
- "id": "task5_real_polars",
- "type": "rule.apply",
- "purpose": "write",
- "idempotency": {
- "strategy": "deduplication_key",
- "key": "customer_id",
- },
- "config": {
- "component_binding_id": new_governance_uid(),
- "rule_version_id": rule["id"],
- "execution_plan_hash": compiled["plan_hash"],
- },
- }
- result = PolarsRulePlanAdapter(
- artifact_store=store,
- artifact_resolver=resolver,
- artifact_ttl_seconds=900,
- ).execute(
- plan=compiled["plan"],
- node=node,
- parameters={},
- write_authorized=True,
- correlation_id=correlation_id,
- )
- assert result["rows_in"] == 4
- assert result["rows_out"] == 2
- assert result["rows_rejected"] == 2
- assert result["violation_count"] == 1
- assert result["violations"] == [
- {"step_id": "valid_mobile", "count": 1}
- ]
- output = store.read(
- result["artifact_ref"], result["digest"]
- ).collect()
- assert output.sort("customer_id").to_dicts() == [
- {
- "customer_id": 1,
- "mobile": "13800138000",
- "name": "Alice Updated",
- "segment_code": "A",
- "segment_name": "Gold",
- "version_no": 2,
- },
- {
- "customer_id": 3,
- "mobile": "13900139000",
- "name": "Carol",
- "segment_code": "C",
- "segment_name": "Silver",
- "version_no": 1,
- },
- ]
- assert all(
- item.object_name.startswith(prefix)
- for item in minio.list_objects(
- bucket, prefix=prefix, recursive=True
- )
- )
- finally:
- for item in list(
- minio.list_objects(bucket, prefix=prefix, recursive=True)
- ):
- minio.remove_object(bucket, item.object_name)
- assert list(
- minio.list_objects(bucket, prefix=prefix, recursive=True)
- ) == []
- with postgres.begin() as connection:
- connection.execute(text(f"DROP TABLE IF EXISTS {customer_table}"))
- with mysql.begin() as connection:
- connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
- postgres.dispose()
- mysql.dispose()
|