test_data_rule_sql_execution.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. from __future__ import annotations
  2. from contextlib import contextmanager
  3. import pytest
  4. from sqlalchemy import create_engine, text
  5. from app.core.common.identifiers import new_governance_uid
  6. from app.core.data_rules.contracts import rule_spec_hash, validate_rule_spec
  7. from app.core.data_rules.execution_contracts import canonical_schema_hash
  8. from app.runner.nodes import NodeExecutionError
  9. CASES = [
  10. (
  11. "postgresql",
  12. "postgresql+psycopg2://source_reader:source-test-password@127.0.0.1:25432/acceptance",
  13. "public",
  14. "C",
  15. "posix",
  16. ),
  17. (
  18. "mysql",
  19. "mysql+pymysql://source_reader:source-test-password@127.0.0.1:23306/acceptance",
  20. "acceptance",
  21. "utf8mb4_0900_bin",
  22. "icu",
  23. ),
  24. ]
  25. class Definition:
  26. def __init__(self, dialect, capabilities):
  27. self.database_type = dialect
  28. self.extra_properties = {"sql_rule_capabilities": capabilities}
  29. class Definitions:
  30. def __init__(self, definition):
  31. self.definition = definition
  32. def get(self, _uid):
  33. return self.definition
  34. class DirectManager:
  35. def __init__(self, engine, definition):
  36. self.engine = engine
  37. self.definitions = Definitions(definition)
  38. @contextmanager
  39. def connect(self, _uid, purpose):
  40. assert purpose == "dataflow_write"
  41. with self.engine.connect() as connection:
  42. transaction = connection.begin()
  43. try:
  44. yield connection
  45. transaction.commit()
  46. except Exception:
  47. transaction.rollback()
  48. raise
  49. class PlanRepository:
  50. def __init__(self, idempotency):
  51. self.idempotency = idempotency
  52. self.record = None
  53. def persist_bound_component_plan(self, **kwargs):
  54. compiled = kwargs["compiled"]
  55. self.record = {
  56. "component_binding_id": kwargs["component_binding_id"],
  57. "rule_version_id": kwargs["rule_version_id"],
  58. "backend": compiled["backend"],
  59. "plan": compiled["plan"],
  60. "plan_hash": compiled["plan_hash"],
  61. "plan_status": kwargs["status"],
  62. "rule_status": "published",
  63. "component_kind": "rule.apply",
  64. "binding_idempotency": self.idempotency,
  65. }
  66. return {
  67. "id": new_governance_uid(),
  68. "status": kwargs["status"],
  69. "plan_hash": compiled["plan_hash"],
  70. }
  71. def publish_with_evidence(self, plan_hash, evidence):
  72. assert self.record is not None
  73. assert self.record["plan_status"] == "compiled"
  74. assert self.record["plan_hash"] == plan_hash
  75. assert evidence["commit_outcome"] == "committed"
  76. assert evidence["rows_in"] >= evidence["rows_out"]
  77. self.record["plan_status"] = "published"
  78. def load(self, **_kwargs):
  79. return dict(self.record)
  80. def _snapshot(schema_ref):
  81. fields = [
  82. {"name": "customer_id", "type": "integer", "nullable": False},
  83. {"name": "name", "type": "string", "nullable": True},
  84. {"name": "mobile", "type": "string", "nullable": True},
  85. ]
  86. return {
  87. "id": new_governance_uid(),
  88. "schema_ref": schema_ref,
  89. "schema_hash": canonical_schema_hash(fields),
  90. "fields": fields,
  91. "source_revision": "task4:integration",
  92. }
  93. @pytest.mark.parametrize(
  94. ("dialect", "url", "schema_name", "collation", "regex_engine"), CASES
  95. )
  96. def test_bound_rule_compiles_publishes_executes_and_rejects_tampering(
  97. dialect, url, schema_name, collation, regex_engine
  98. ):
  99. from app.core.data_rules.compilers import CompilerRegistry
  100. from app.core.data_rules.compilers.sql import SqlGlotRuleCompiler
  101. from app.core.data_rules.release import BoundSqlPlanService
  102. from app.runner.rule_sql import SqlGlotRulePlanAdapter
  103. from app.runner.rules import RulePlanExecutor
  104. engine = create_engine(url, pool_pre_ping=True)
  105. source_name = "task4_rule_source"
  106. target_name = "task4_rule_target"
  107. source_ref = f"{schema_name}.{source_name}"
  108. target_ref = f"{schema_name}.{target_name}"
  109. capabilities = {
  110. "dialect": dialect,
  111. "timezone": "Asia/Shanghai",
  112. "collation": collation,
  113. "rounding_mode": "half_away_from_zero",
  114. "regex_engine": regex_engine,
  115. }
  116. datasource_uid = new_governance_uid()
  117. input_schema = _snapshot("bd:task4:raw")
  118. output_schema = _snapshot("bd:task4:clean")
  119. input_binding = {
  120. "id": new_governance_uid(),
  121. "data_source_uid": datasource_uid,
  122. "object_kind": "table",
  123. "object_ref": source_ref,
  124. "schema_snapshot_id": input_schema["id"],
  125. "access_mode": "read",
  126. "dialect": dialect,
  127. "write_mode": "append",
  128. }
  129. output_binding = {
  130. "id": new_governance_uid(),
  131. "data_source_uid": datasource_uid,
  132. "object_kind": "table",
  133. "object_ref": target_ref,
  134. "schema_snapshot_id": output_schema["id"],
  135. "access_mode": "write",
  136. "dialect": dialect,
  137. "write_mode": "append",
  138. }
  139. spec = validate_rule_spec(
  140. {
  141. "schema_version": "2.0",
  142. "rule_uid": new_governance_uid(),
  143. "name": "task4_real_sql",
  144. "input_schema_ref": input_schema["schema_ref"],
  145. "output_schema_ref": output_schema["schema_ref"],
  146. "steps": [
  147. {
  148. "id": "trim_name",
  149. "op": "normalize_text",
  150. "column": "name",
  151. "trim": True,
  152. },
  153. {
  154. "id": "mobile_format",
  155. "op": "assert",
  156. "expression": "matches(mobile, '^[0-9]{11}$')",
  157. "on_failure": "reject",
  158. "severity": "error",
  159. },
  160. ],
  161. "null_policy": "explicit",
  162. "timezone": "Asia/Shanghai",
  163. }
  164. )
  165. rule = {
  166. "id": new_governance_uid(),
  167. "status": "published",
  168. "rule_spec": spec,
  169. "spec_hash": rule_spec_hash(spec),
  170. }
  171. try:
  172. with engine.begin() as connection:
  173. connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
  174. connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
  175. connection.execute(
  176. text(
  177. f"CREATE TABLE {source_name} ("
  178. "customer_id BIGINT PRIMARY KEY, "
  179. "name VARCHAR(100), mobile VARCHAR(30))"
  180. )
  181. )
  182. connection.execute(
  183. text(
  184. f"CREATE TABLE {target_name} ("
  185. "customer_id BIGINT PRIMARY KEY, "
  186. "name VARCHAR(100), mobile VARCHAR(30))"
  187. )
  188. )
  189. connection.execute(
  190. text(
  191. f"INSERT INTO {source_name} "
  192. "(customer_id, name, mobile) VALUES "
  193. "(1, ' Alice ', '13800138000'), "
  194. "(2, ' Bad ', 'not-a-mobile')"
  195. )
  196. )
  197. component_binding_id = new_governance_uid()
  198. idempotency = {
  199. "strategy": "upsert",
  200. "key": "customer_id",
  201. }
  202. repository = PlanRepository(idempotency)
  203. BoundSqlPlanService(
  204. repository,
  205. CompilerRegistry(
  206. {dialect: SqlGlotRuleCompiler(dialect)}
  207. ),
  208. ).compile_and_persist(
  209. component_binding_id=component_binding_id,
  210. rule_version=rule,
  211. input_schema=input_schema,
  212. output_schema=output_schema,
  213. input_binding=input_binding,
  214. output_binding=output_binding,
  215. backend=capabilities,
  216. )
  217. record = repository.record
  218. assert record["plan_status"] == "compiled"
  219. compiled = {
  220. "plan": record["plan"],
  221. "plan_hash": record["plan_hash"],
  222. }
  223. adapter = SqlGlotRulePlanAdapter(
  224. DirectManager(
  225. engine,
  226. Definition(dialect, capabilities),
  227. )
  228. )
  229. node = {
  230. "id": "task4_real_rule",
  231. "type": "rule.apply",
  232. "purpose": "write",
  233. "idempotency": idempotency,
  234. "config": {
  235. "component_binding_id": component_binding_id,
  236. "rule_version_id": rule["id"],
  237. "execution_plan_hash": compiled["plan_hash"],
  238. },
  239. }
  240. preflight_evidence = adapter.execute(
  241. plan=compiled["plan"],
  242. node=node,
  243. parameters={},
  244. write_authorized=True,
  245. )
  246. with engine.begin() as connection:
  247. connection.execute(text(f"DELETE FROM {target_name}"))
  248. repository.publish_with_evidence(
  249. compiled["plan_hash"],
  250. preflight_evidence,
  251. )
  252. executor = RulePlanExecutor(
  253. repository,
  254. adapters={"sql_pushdown": adapter},
  255. )
  256. result = executor.execute(node, {}, write_authorized=True)
  257. assert result["rows_in"] == 2
  258. assert result["rows_out"] == 1
  259. assert result["rows_rejected"] == 1
  260. with engine.connect() as connection:
  261. rows = connection.execute(
  262. text(
  263. f"SELECT customer_id, name, mobile "
  264. f"FROM {target_name} ORDER BY customer_id"
  265. )
  266. ).tuples().all()
  267. assert rows == [(1, "Alice", "13800138000")]
  268. repeated = executor.execute(node, {}, write_authorized=True)
  269. assert repeated["rows_out"] == 1
  270. assert repeated["rows_rejected"] == 1
  271. with engine.connect() as connection:
  272. assert connection.execute(
  273. text(f"SELECT COUNT(*) FROM {target_name}")
  274. ).scalar_one() == 1
  275. repository.record["plan"] = {
  276. **repository.record["plan"],
  277. "result_contract": {
  278. **repository.record["plan"]["result_contract"],
  279. "rows_rejected": "unknown",
  280. },
  281. }
  282. with pytest.raises(NodeExecutionError, match="not executable"):
  283. executor.execute(node, {}, write_authorized=True)
  284. finally:
  285. with engine.begin() as connection:
  286. connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
  287. connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
  288. engine.dispose()