test_data_rule_sql_execution.py 17 KB

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