test_data_rule_sql_execution.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  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, context):
  51. self.idempotency = idempotency
  52. self.context = context
  53. self.record = None
  54. def load_bound_compile_context(self, **_ids):
  55. return self.context
  56. def persist_bound_component_plan(self, **kwargs):
  57. compiled = kwargs["compiled"]
  58. plan = compiled["plan"]
  59. self.record = {
  60. "component_binding_id": kwargs["component_binding_id"],
  61. "rule_version_id": kwargs["rule_version_id"],
  62. "backend": compiled["backend"],
  63. "compiler_version": compiled["compiler_version"],
  64. "plan": compiled["plan"],
  65. "plan_hash": compiled["plan_hash"],
  66. "schema_hashes": {
  67. "rule_spec_hash": plan["rule_spec_hash"],
  68. "input_schema_snapshot_id": plan["input_schema_snapshot_id"],
  69. "input_schema_hash": plan["input_schema_hash"],
  70. "output_schema_snapshot_id": plan["output_schema_snapshot_id"],
  71. "output_schema_hash": plan["output_schema_hash"],
  72. },
  73. "canonical_rule_spec_hash": plan["rule_spec_hash"],
  74. "canonical_input_schema_snapshot_id": plan[
  75. "input_schema_snapshot_id"
  76. ],
  77. "canonical_input_schema_hash": plan["input_schema_hash"],
  78. "canonical_output_schema_snapshot_id": plan[
  79. "output_schema_snapshot_id"
  80. ],
  81. "canonical_output_schema_hash": plan["output_schema_hash"],
  82. "plan_status": kwargs["status"],
  83. "rule_status": "published",
  84. "component_kind": "rule.apply",
  85. "binding_idempotency": self.idempotency,
  86. }
  87. return {
  88. "id": new_governance_uid(),
  89. "status": kwargs["status"],
  90. "plan_hash": compiled["plan_hash"],
  91. }
  92. def publish_with_evidence(self, plan_hash, evidence):
  93. assert self.record is not None
  94. assert self.record["plan_status"] == "compiled"
  95. assert self.record["plan_hash"] == plan_hash
  96. assert evidence["commit_outcome"] == "committed"
  97. assert evidence["rows_in"] >= evidence["rows_out"]
  98. self.record["plan_status"] = "tested"
  99. self.record["plan_status"] = "published"
  100. def load(self, **_kwargs):
  101. return dict(self.record)
  102. def _snapshot(schema_ref):
  103. fields = [
  104. {"name": "customer_id", "type": "integer", "nullable": False},
  105. {"name": "name", "type": "string", "nullable": True},
  106. {"name": "mobile", "type": "string", "nullable": True},
  107. ]
  108. return {
  109. "id": new_governance_uid(),
  110. "schema_ref": schema_ref,
  111. "schema_hash": canonical_schema_hash(fields),
  112. "fields": fields,
  113. "source_revision": "task4:integration",
  114. }
  115. @pytest.mark.parametrize(
  116. ("dialect", "url", "schema_name", "collation", "regex_engine"), CASES
  117. )
  118. def test_bound_rule_compiles_publishes_executes_and_rejects_tampering(
  119. dialect, url, schema_name, collation, regex_engine
  120. ):
  121. from app.core.data_rules.compilers import CompilerRegistry
  122. from app.core.data_rules.compilers.sql import SqlGlotRuleCompiler
  123. from app.core.data_rules.release import BoundSqlPlanService
  124. from app.runner.rule_sql import SqlGlotRulePlanAdapter
  125. from app.runner.rules import RulePlanExecutor
  126. engine = create_engine(url, pool_pre_ping=True)
  127. source_name = "task4_rule_source"
  128. target_name = "task4_rule_target"
  129. source_ref = f"{schema_name}.{source_name}"
  130. target_ref = f"{schema_name}.{target_name}"
  131. capabilities = {
  132. "dialect": dialect,
  133. "timezone": "Asia/Shanghai",
  134. "collation": collation,
  135. "rounding_mode": "half_away_from_zero",
  136. "regex_engine": regex_engine,
  137. }
  138. datasource_uid = new_governance_uid()
  139. input_schema = _snapshot("bd:task4:raw")
  140. output_schema = _snapshot("bd:task4:clean")
  141. input_binding = {
  142. "id": new_governance_uid(),
  143. "data_source_uid": datasource_uid,
  144. "object_kind": "table",
  145. "object_ref": source_ref,
  146. "schema_snapshot_id": input_schema["id"],
  147. "access_mode": "read",
  148. "dialect": dialect,
  149. "write_mode": "append",
  150. }
  151. output_binding = {
  152. "id": new_governance_uid(),
  153. "data_source_uid": datasource_uid,
  154. "object_kind": "table",
  155. "object_ref": target_ref,
  156. "schema_snapshot_id": output_schema["id"],
  157. "access_mode": "write",
  158. "dialect": dialect,
  159. "write_mode": "append",
  160. }
  161. spec = validate_rule_spec(
  162. {
  163. "schema_version": "2.0",
  164. "rule_uid": new_governance_uid(),
  165. "name": "task4_real_sql",
  166. "input_schema_ref": input_schema["schema_ref"],
  167. "output_schema_ref": output_schema["schema_ref"],
  168. "steps": [
  169. {
  170. "id": "trim_name",
  171. "op": "normalize_text",
  172. "column": "name",
  173. "trim": True,
  174. },
  175. {
  176. "id": "mobile_format",
  177. "op": "assert",
  178. "expression": "matches(mobile, '^[0-9]{11}$')",
  179. "on_failure": "reject",
  180. "severity": "error",
  181. },
  182. ],
  183. "null_policy": "explicit",
  184. "timezone": "Asia/Shanghai",
  185. }
  186. )
  187. rule = {
  188. "id": new_governance_uid(),
  189. "status": "published",
  190. "rule_spec": spec,
  191. "spec_hash": rule_spec_hash(spec),
  192. }
  193. try:
  194. with engine.begin() as connection:
  195. connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
  196. connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
  197. connection.execute(
  198. text(
  199. f"CREATE TABLE {source_name} ("
  200. "customer_id BIGINT PRIMARY KEY, "
  201. "name VARCHAR(100), mobile VARCHAR(30))"
  202. )
  203. )
  204. connection.execute(
  205. text(
  206. f"CREATE TABLE {target_name} ("
  207. "customer_id BIGINT PRIMARY KEY, "
  208. "name VARCHAR(100), mobile VARCHAR(30))"
  209. )
  210. )
  211. connection.execute(
  212. text(
  213. f"INSERT INTO {source_name} "
  214. "(customer_id, name, mobile) VALUES "
  215. "(1, ' Alice ', '13800138000'), "
  216. "(2, ' Bad ', 'not-a-mobile')"
  217. )
  218. )
  219. component_binding_id = new_governance_uid()
  220. idempotency = {
  221. "strategy": "upsert",
  222. "key": "customer_id",
  223. }
  224. repository = PlanRepository(
  225. idempotency,
  226. {
  227. "component_binding": {
  228. "id": component_binding_id,
  229. "rule_version_id": rule["id"],
  230. },
  231. "rule_version": rule,
  232. "input_schema": input_schema,
  233. "output_schema": output_schema,
  234. "input_binding": input_binding,
  235. "output_binding": output_binding,
  236. "backend": capabilities,
  237. },
  238. )
  239. BoundSqlPlanService(
  240. repository,
  241. CompilerRegistry(
  242. {dialect: SqlGlotRuleCompiler(dialect)}
  243. ),
  244. ).compile_and_persist(
  245. component_binding_id=component_binding_id,
  246. rule_version_id=rule["id"],
  247. input_schema_snapshot_id=input_schema["id"],
  248. output_schema_snapshot_id=output_schema["id"],
  249. input_binding_id=input_binding["id"],
  250. output_binding_id=output_binding["id"],
  251. )
  252. record = repository.record
  253. assert record["plan_status"] == "compiled"
  254. compiled = {
  255. "plan": record["plan"],
  256. "plan_hash": record["plan_hash"],
  257. }
  258. adapter = SqlGlotRulePlanAdapter(
  259. DirectManager(
  260. engine,
  261. Definition(dialect, capabilities),
  262. )
  263. )
  264. node = {
  265. "id": "task4_real_rule",
  266. "type": "rule.apply",
  267. "purpose": "write",
  268. "idempotency": idempotency,
  269. "config": {
  270. "component_binding_id": component_binding_id,
  271. "rule_version_id": rule["id"],
  272. "execution_plan_hash": compiled["plan_hash"],
  273. },
  274. }
  275. preflight_evidence = adapter.execute(
  276. plan=compiled["plan"],
  277. node=node,
  278. parameters={},
  279. write_authorized=True,
  280. )
  281. with engine.begin() as connection:
  282. connection.execute(text(f"DELETE FROM {target_name}"))
  283. repository.publish_with_evidence(
  284. compiled["plan_hash"],
  285. preflight_evidence,
  286. )
  287. executor = RulePlanExecutor(
  288. repository,
  289. adapters={"sql_pushdown": adapter},
  290. )
  291. result = executor.execute(node, {}, write_authorized=True)
  292. assert result["rows_in"] == 2
  293. assert result["rows_out"] == 1
  294. assert result["rows_rejected"] == 1
  295. with engine.connect() as connection:
  296. rows = connection.execute(
  297. text(
  298. f"SELECT customer_id, name, mobile "
  299. f"FROM {target_name} ORDER BY customer_id"
  300. )
  301. ).tuples().all()
  302. assert rows == [(1, "Alice", "13800138000")]
  303. repeated = executor.execute(node, {}, write_authorized=True)
  304. assert repeated["rows_out"] == 1
  305. assert repeated["rows_rejected"] == 1
  306. with engine.connect() as connection:
  307. assert connection.execute(
  308. text(f"SELECT COUNT(*) FROM {target_name}")
  309. ).scalar_one() == 1
  310. repository.record["plan"] = {
  311. **repository.record["plan"],
  312. "result_contract": {
  313. **repository.record["plan"]["result_contract"],
  314. "rows_rejected": "unknown",
  315. },
  316. }
  317. with pytest.raises(NodeExecutionError, match="not executable"):
  318. executor.execute(node, {}, write_authorized=True)
  319. finally:
  320. with engine.begin() as connection:
  321. connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
  322. connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
  323. engine.dispose()
  324. def test_mysql_upsert_rejects_real_nonunique_and_alternate_unique_targets():
  325. from app.core.data_rules.compilers.sql import SqlGlotRuleCompiler
  326. from app.runner.rule_sql import SqlGlotRulePlanAdapter
  327. dialect, url, schema_name, collation, regex_engine = CASES[1]
  328. engine = create_engine(url, pool_pre_ping=True)
  329. source_name = "task4_unique_source"
  330. nonunique_name = "task4_nonunique_target"
  331. alternate_name = "task4_alternate_target"
  332. capabilities = {
  333. "dialect": dialect,
  334. "timezone": "Asia/Shanghai",
  335. "collation": collation,
  336. "rounding_mode": "half_away_from_zero",
  337. "regex_engine": regex_engine,
  338. }
  339. datasource_uid = new_governance_uid()
  340. input_schema = _snapshot("bd:task4:unique:raw")
  341. output_schema = _snapshot("bd:task4:unique:clean")
  342. spec = validate_rule_spec(
  343. {
  344. "schema_version": "2.0",
  345. "rule_uid": new_governance_uid(),
  346. "name": "task4_unique_attestation",
  347. "input_schema_ref": input_schema["schema_ref"],
  348. "output_schema_ref": output_schema["schema_ref"],
  349. "steps": [
  350. {
  351. "id": "trim_name",
  352. "op": "normalize_text",
  353. "column": "name",
  354. "trim": True,
  355. }
  356. ],
  357. "null_policy": "explicit",
  358. "timezone": "Asia/Shanghai",
  359. }
  360. )
  361. rule = {
  362. "id": new_governance_uid(),
  363. "status": "published",
  364. "rule_spec": spec,
  365. "spec_hash": rule_spec_hash(spec),
  366. }
  367. input_binding = {
  368. "id": new_governance_uid(),
  369. "data_source_uid": datasource_uid,
  370. "object_kind": "table",
  371. "object_ref": f"{schema_name}.{source_name}",
  372. "schema_snapshot_id": input_schema["id"],
  373. "access_mode": "read",
  374. "dialect": dialect,
  375. "write_mode": "append",
  376. }
  377. try:
  378. with engine.begin() as connection:
  379. for name in (alternate_name, nonunique_name, source_name):
  380. connection.execute(text(f"DROP TABLE IF EXISTS {name}"))
  381. connection.execute(
  382. text(
  383. f"CREATE TABLE {source_name} ("
  384. "customer_id BIGINT PRIMARY KEY, "
  385. "name VARCHAR(100), mobile VARCHAR(30))"
  386. )
  387. )
  388. connection.execute(
  389. text(
  390. f"CREATE TABLE {nonunique_name} ("
  391. "customer_id BIGINT, name VARCHAR(100), mobile VARCHAR(30))"
  392. )
  393. )
  394. connection.execute(
  395. text(
  396. f"CREATE TABLE {alternate_name} ("
  397. "customer_id BIGINT PRIMARY KEY, "
  398. "name VARCHAR(100), mobile VARCHAR(30) UNIQUE)"
  399. )
  400. )
  401. connection.execute(
  402. text(
  403. f"INSERT INTO {source_name} "
  404. "(customer_id, name, mobile) "
  405. "VALUES (1, ' Alice ', '13800138000')"
  406. )
  407. )
  408. adapter = SqlGlotRulePlanAdapter(
  409. DirectManager(engine, Definition(dialect, capabilities))
  410. )
  411. for target_name, error in (
  412. (nonunique_name, "exact unique key"),
  413. (alternate_name, "alternate unique"),
  414. ):
  415. output_binding = {
  416. "id": new_governance_uid(),
  417. "data_source_uid": datasource_uid,
  418. "object_kind": "table",
  419. "object_ref": f"{schema_name}.{target_name}",
  420. "schema_snapshot_id": output_schema["id"],
  421. "access_mode": "write",
  422. "dialect": dialect,
  423. "write_mode": "append",
  424. }
  425. compiled = SqlGlotRuleCompiler(dialect).compile(
  426. rule_version=rule,
  427. input_schema=input_schema,
  428. output_schema=output_schema,
  429. input_binding=input_binding,
  430. output_binding=output_binding,
  431. backend=capabilities,
  432. )
  433. node = {
  434. "id": "task4_unique_rule",
  435. "type": "rule.apply",
  436. "purpose": "write",
  437. "idempotency": {
  438. "strategy": "upsert",
  439. "key": "customer_id",
  440. },
  441. "config": {
  442. "component_binding_id": new_governance_uid(),
  443. "rule_version_id": rule["id"],
  444. "execution_plan_hash": compiled["plan_hash"],
  445. },
  446. }
  447. with pytest.raises(NodeExecutionError, match=error):
  448. adapter.execute(
  449. plan=compiled["plan"],
  450. node=node,
  451. parameters={},
  452. write_authorized=True,
  453. )
  454. with engine.connect() as connection:
  455. assert connection.execute(
  456. text(f"SELECT COUNT(*) FROM {target_name}")
  457. ).scalar_one() == 0
  458. finally:
  459. with engine.begin() as connection:
  460. for name in (alternate_name, nonunique_name, source_name):
  461. connection.execute(text(f"DROP TABLE IF EXISTS {name}"))
  462. engine.dispose()