test_data_rule_sql_execution.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  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 ReadOnlyPreflightManager:
  50. def __init__(self, engine):
  51. self.engine = engine
  52. @contextmanager
  53. def connect(self, _uid, purpose):
  54. assert purpose == "dataflow_read"
  55. with self.engine.connect() as connection:
  56. transaction = connection.begin()
  57. try:
  58. yield connection
  59. finally:
  60. transaction.rollback()
  61. class PlanRepository:
  62. def __init__(self, idempotency, context):
  63. self.idempotency = idempotency
  64. self.context = context
  65. self.record = None
  66. def load_bound_compile_context(self, **_ids):
  67. return self.context
  68. def persist_bound_component_plan(self, **kwargs):
  69. compiled = kwargs["compiled"]
  70. plan = compiled["plan"]
  71. self.record = {
  72. "component_binding_id": kwargs["component_binding_id"],
  73. "rule_version_id": kwargs["rule_version_id"],
  74. "backend": compiled["backend"],
  75. "compiler_version": compiled["compiler_version"],
  76. "plan": compiled["plan"],
  77. "plan_hash": compiled["plan_hash"],
  78. "schema_hashes": {
  79. "rule_spec_hash": plan["rule_spec_hash"],
  80. "input_schema_snapshot_id": plan["input_schema_snapshot_id"],
  81. "input_schema_hash": plan["input_schema_hash"],
  82. "output_schema_snapshot_id": plan["output_schema_snapshot_id"],
  83. "output_schema_hash": plan["output_schema_hash"],
  84. },
  85. "canonical_rule_spec_hash": plan["rule_spec_hash"],
  86. "canonical_input_schema_snapshot_id": plan[
  87. "input_schema_snapshot_id"
  88. ],
  89. "canonical_input_schema_hash": plan["input_schema_hash"],
  90. "canonical_output_schema_snapshot_id": plan[
  91. "output_schema_snapshot_id"
  92. ],
  93. "canonical_output_schema_hash": plan["output_schema_hash"],
  94. "plan_status": kwargs["status"],
  95. "rule_status": "published",
  96. "component_kind": "rule.apply",
  97. "binding_idempotency": self.idempotency,
  98. }
  99. return {
  100. "id": new_governance_uid(),
  101. "status": kwargs["status"],
  102. "plan_hash": compiled["plan_hash"],
  103. }
  104. def trust_test_only_preflight_and_publish(self, plan_hash, evidence):
  105. assert self.record is not None
  106. assert self.record["plan_status"] == "compiled"
  107. assert self.record["plan_hash"] == plan_hash
  108. assert evidence["commit_outcome"] == "committed"
  109. assert evidence["rows_in"] >= evidence["rows_out"]
  110. self.record["plan_status"] = "published"
  111. def load(self, **_kwargs):
  112. return dict(self.record)
  113. def _snapshot(schema_ref):
  114. fields = [
  115. {"name": "customer_id", "type": "integer", "nullable": False},
  116. {"name": "name", "type": "string", "nullable": True},
  117. {"name": "mobile", "type": "string", "nullable": True},
  118. ]
  119. return {
  120. "id": new_governance_uid(),
  121. "schema_ref": schema_ref,
  122. "schema_hash": canonical_schema_hash(fields),
  123. "fields": fields,
  124. "source_revision": "task4:integration",
  125. }
  126. @pytest.mark.parametrize(
  127. ("dialect", "url", "schema_name", "collation", "regex_engine"), CASES
  128. )
  129. def test_server_owned_sql_preflight_explains_without_writing(
  130. dialect, url, schema_name, collation, regex_engine
  131. ):
  132. from app.core.data_rules.compilers.sql import SqlGlotRuleCompiler
  133. from app.core.data_rules.publication import (
  134. ServerOwnedPhysicalPreflightRunner,
  135. )
  136. engine = create_engine(url, pool_pre_ping=True)
  137. suffix = dialect.replace("postgresql", "pg")
  138. source_name = f"task7_preflight_source_{suffix}"
  139. target_name = f"task7_preflight_target_{suffix}"
  140. capabilities = {
  141. "dialect": dialect,
  142. "timezone": "Asia/Shanghai",
  143. "collation": collation,
  144. "rounding_mode": "half_away_from_zero",
  145. "regex_engine": regex_engine,
  146. }
  147. datasource_uid = new_governance_uid()
  148. input_schema = _snapshot(f"bd:task7:{dialect}:raw")
  149. output_schema = _snapshot(f"bd:task7:{dialect}:clean")
  150. input_binding = {
  151. "id": new_governance_uid(),
  152. "data_source_uid": datasource_uid,
  153. "object_kind": "table",
  154. "object_ref": f"{schema_name}.{source_name}",
  155. "schema_snapshot_id": input_schema["id"],
  156. "access_mode": "read",
  157. "dialect": dialect,
  158. "write_mode": "append",
  159. }
  160. output_binding = {
  161. "id": new_governance_uid(),
  162. "data_source_uid": datasource_uid,
  163. "object_kind": "table",
  164. "object_ref": f"{schema_name}.{target_name}",
  165. "schema_snapshot_id": output_schema["id"],
  166. "access_mode": "write",
  167. "dialect": dialect,
  168. "write_mode": "append",
  169. }
  170. spec = validate_rule_spec(
  171. {
  172. "schema_version": "2.0",
  173. "rule_uid": new_governance_uid(),
  174. "name": f"task7_{dialect}_safe_preflight",
  175. "input_schema_ref": input_schema["schema_ref"],
  176. "output_schema_ref": output_schema["schema_ref"],
  177. "steps": [
  178. {
  179. "id": "trim_name",
  180. "op": "normalize_text",
  181. "column": "name",
  182. "trim": True,
  183. }
  184. ],
  185. "null_policy": "explicit",
  186. "timezone": "Asia/Shanghai",
  187. }
  188. )
  189. rule = {
  190. "id": new_governance_uid(),
  191. "status": "published",
  192. "rule_spec": spec,
  193. "spec_hash": rule_spec_hash(spec),
  194. }
  195. try:
  196. with engine.begin() as connection:
  197. connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
  198. connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
  199. connection.execute(
  200. text(
  201. f"CREATE TABLE {source_name} ("
  202. "customer_id BIGINT PRIMARY KEY, "
  203. "name VARCHAR(100), mobile VARCHAR(30))"
  204. )
  205. )
  206. connection.execute(
  207. text(
  208. f"CREATE TABLE {target_name} ("
  209. "customer_id BIGINT PRIMARY KEY, "
  210. "name VARCHAR(100), mobile VARCHAR(30))"
  211. )
  212. )
  213. connection.execute(
  214. text(
  215. f"INSERT INTO {source_name} "
  216. "(customer_id, name, mobile) "
  217. "VALUES (1, ' Alice ', '13800138000')"
  218. )
  219. )
  220. compiled = SqlGlotRuleCompiler(dialect).compile(
  221. rule_version=rule,
  222. input_schema=input_schema,
  223. output_schema=output_schema,
  224. input_binding=input_binding,
  225. output_binding=output_binding,
  226. backend=capabilities,
  227. )
  228. schema_hashes = {
  229. "input_schema_hash": input_schema["schema_hash"],
  230. "output_schema_hash": output_schema["schema_hash"],
  231. }
  232. binding_hashes = {
  233. "input": "a" * 64,
  234. "output": "b" * 64,
  235. }
  236. result = ServerOwnedPhysicalPreflightRunner(
  237. artifact_store=None,
  238. datasource_manager=ReadOnlyPreflightManager(engine),
  239. ).run(
  240. {
  241. "backend": "sql_pushdown",
  242. "plan": compiled["plan"],
  243. "plan_hash": compiled["plan_hash"],
  244. "schema_hashes": schema_hashes,
  245. "binding_hashes": binding_hashes,
  246. }
  247. )
  248. assert result["status"] == "success"
  249. assert result["plan_hash"] == compiled["plan_hash"]
  250. assert result["attestation"]["dialect"] == dialect
  251. with engine.connect() as connection:
  252. assert connection.execute(
  253. text(f"SELECT COUNT(*) FROM {target_name}")
  254. ).scalar_one() == 0
  255. finally:
  256. with engine.begin() as connection:
  257. connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
  258. connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
  259. engine.dispose()
  260. @pytest.mark.parametrize(
  261. ("dialect", "url", "schema_name", "collation", "regex_engine"), CASES
  262. )
  263. def test_bound_rule_compiles_publishes_executes_and_rejects_tampering(
  264. dialect, url, schema_name, collation, regex_engine
  265. ):
  266. from app.core.data_rules.compilers import CompilerRegistry
  267. from app.core.data_rules.compilers.sql import SqlGlotRuleCompiler
  268. from app.core.data_rules.release import BoundSqlPlanService
  269. from app.runner.rule_sql import SqlGlotRulePlanAdapter
  270. from app.runner.rules import RulePlanExecutor
  271. engine = create_engine(url, pool_pre_ping=True)
  272. source_name = "task4_rule_source"
  273. target_name = "task4_rule_target"
  274. source_ref = f"{schema_name}.{source_name}"
  275. target_ref = f"{schema_name}.{target_name}"
  276. capabilities = {
  277. "dialect": dialect,
  278. "timezone": "Asia/Shanghai",
  279. "collation": collation,
  280. "rounding_mode": "half_away_from_zero",
  281. "regex_engine": regex_engine,
  282. }
  283. datasource_uid = new_governance_uid()
  284. input_schema = _snapshot("bd:task4:raw")
  285. output_schema = _snapshot("bd:task4:clean")
  286. input_binding = {
  287. "id": new_governance_uid(),
  288. "data_source_uid": datasource_uid,
  289. "object_kind": "table",
  290. "object_ref": source_ref,
  291. "schema_snapshot_id": input_schema["id"],
  292. "access_mode": "read",
  293. "dialect": dialect,
  294. "write_mode": "append",
  295. }
  296. output_binding = {
  297. "id": new_governance_uid(),
  298. "data_source_uid": datasource_uid,
  299. "object_kind": "table",
  300. "object_ref": target_ref,
  301. "schema_snapshot_id": output_schema["id"],
  302. "access_mode": "write",
  303. "dialect": dialect,
  304. "write_mode": "append",
  305. }
  306. spec = validate_rule_spec(
  307. {
  308. "schema_version": "2.0",
  309. "rule_uid": new_governance_uid(),
  310. "name": "task4_real_sql",
  311. "input_schema_ref": input_schema["schema_ref"],
  312. "output_schema_ref": output_schema["schema_ref"],
  313. "steps": [
  314. {
  315. "id": "trim_name",
  316. "op": "normalize_text",
  317. "column": "name",
  318. "trim": True,
  319. },
  320. {
  321. "id": "mobile_format",
  322. "op": "assert",
  323. "expression": "matches(mobile, '^[0-9]{11}$')",
  324. "on_failure": "reject",
  325. "severity": "error",
  326. },
  327. ],
  328. "null_policy": "explicit",
  329. "timezone": "Asia/Shanghai",
  330. }
  331. )
  332. rule = {
  333. "id": new_governance_uid(),
  334. "status": "published",
  335. "rule_spec": spec,
  336. "spec_hash": rule_spec_hash(spec),
  337. }
  338. try:
  339. with engine.begin() as connection:
  340. connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
  341. connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
  342. connection.execute(
  343. text(
  344. f"CREATE TABLE {source_name} ("
  345. "customer_id BIGINT PRIMARY KEY, "
  346. "name VARCHAR(100), mobile VARCHAR(30))"
  347. )
  348. )
  349. connection.execute(
  350. text(
  351. f"CREATE TABLE {target_name} ("
  352. "customer_id BIGINT PRIMARY KEY, "
  353. "name VARCHAR(100), mobile VARCHAR(30))"
  354. )
  355. )
  356. connection.execute(
  357. text(
  358. f"INSERT INTO {source_name} "
  359. "(customer_id, name, mobile) VALUES "
  360. "(1, ' Alice ', '13800138000'), "
  361. "(2, ' Bad ', 'not-a-mobile')"
  362. )
  363. )
  364. component_binding_id = new_governance_uid()
  365. idempotency = {
  366. "strategy": "upsert",
  367. "key": "customer_id",
  368. }
  369. repository = PlanRepository(
  370. idempotency,
  371. {
  372. "component_binding": {
  373. "id": component_binding_id,
  374. "rule_version_id": rule["id"],
  375. },
  376. "rule_version": rule,
  377. "input_schema": input_schema,
  378. "output_schema": output_schema,
  379. "input_binding": input_binding,
  380. "output_binding": output_binding,
  381. "backend": capabilities,
  382. },
  383. )
  384. BoundSqlPlanService(
  385. repository,
  386. CompilerRegistry(
  387. {dialect: SqlGlotRuleCompiler(dialect)}
  388. ),
  389. ).compile_and_persist(
  390. component_binding_id=component_binding_id,
  391. rule_version_id=rule["id"],
  392. input_schema_snapshot_id=input_schema["id"],
  393. output_schema_snapshot_id=output_schema["id"],
  394. input_binding_id=input_binding["id"],
  395. output_binding_id=output_binding["id"],
  396. )
  397. record = repository.record
  398. assert record["plan_status"] == "compiled"
  399. compiled = {
  400. "plan": record["plan"],
  401. "plan_hash": record["plan_hash"],
  402. }
  403. adapter = SqlGlotRulePlanAdapter(
  404. DirectManager(
  405. engine,
  406. Definition(dialect, capabilities),
  407. )
  408. )
  409. node = {
  410. "id": "task4_real_rule",
  411. "type": "rule.apply",
  412. "purpose": "write",
  413. "idempotency": idempotency,
  414. "config": {
  415. "component_binding_id": component_binding_id,
  416. "rule_version_id": rule["id"],
  417. "execution_plan_hash": compiled["plan_hash"],
  418. },
  419. }
  420. preflight_evidence = adapter.execute(
  421. plan=compiled["plan"],
  422. node=node,
  423. parameters={},
  424. write_authorized=True,
  425. )
  426. with engine.begin() as connection:
  427. connection.execute(text(f"DELETE FROM {target_name}"))
  428. repository.trust_test_only_preflight_and_publish(
  429. compiled["plan_hash"],
  430. preflight_evidence,
  431. )
  432. executor = RulePlanExecutor(
  433. repository,
  434. adapters={"sql_pushdown": adapter},
  435. )
  436. result = executor.execute(node, {}, write_authorized=True)
  437. assert result["rows_in"] == 2
  438. assert result["rows_out"] == 1
  439. assert result["rows_rejected"] == 1
  440. with engine.connect() as connection:
  441. rows = connection.execute(
  442. text(
  443. f"SELECT customer_id, name, mobile "
  444. f"FROM {target_name} ORDER BY customer_id"
  445. )
  446. ).tuples().all()
  447. assert rows == [(1, "Alice", "13800138000")]
  448. repeated = executor.execute(node, {}, write_authorized=True)
  449. assert repeated["rows_out"] == 1
  450. assert repeated["rows_rejected"] == 1
  451. with engine.connect() as connection:
  452. assert connection.execute(
  453. text(f"SELECT COUNT(*) FROM {target_name}")
  454. ).scalar_one() == 1
  455. repository.record["plan"] = {
  456. **repository.record["plan"],
  457. "result_contract": {
  458. **repository.record["plan"]["result_contract"],
  459. "rows_rejected": "unknown",
  460. },
  461. }
  462. with pytest.raises(NodeExecutionError, match="not executable"):
  463. executor.execute(node, {}, write_authorized=True)
  464. finally:
  465. with engine.begin() as connection:
  466. connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
  467. connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
  468. engine.dispose()
  469. def test_mysql_upsert_rejects_real_nonunique_and_alternate_unique_targets():
  470. from app.core.data_rules.compilers.sql import SqlGlotRuleCompiler
  471. from app.runner.rule_sql import SqlGlotRulePlanAdapter
  472. dialect, url, schema_name, collation, regex_engine = CASES[1]
  473. engine = create_engine(url, pool_pre_ping=True)
  474. source_name = "task4_unique_source"
  475. nonunique_name = "task4_nonunique_target"
  476. alternate_name = "task4_alternate_target"
  477. capabilities = {
  478. "dialect": dialect,
  479. "timezone": "Asia/Shanghai",
  480. "collation": collation,
  481. "rounding_mode": "half_away_from_zero",
  482. "regex_engine": regex_engine,
  483. }
  484. datasource_uid = new_governance_uid()
  485. input_schema = _snapshot("bd:task4:unique:raw")
  486. output_schema = _snapshot("bd:task4:unique:clean")
  487. spec = validate_rule_spec(
  488. {
  489. "schema_version": "2.0",
  490. "rule_uid": new_governance_uid(),
  491. "name": "task4_unique_attestation",
  492. "input_schema_ref": input_schema["schema_ref"],
  493. "output_schema_ref": output_schema["schema_ref"],
  494. "steps": [
  495. {
  496. "id": "trim_name",
  497. "op": "normalize_text",
  498. "column": "name",
  499. "trim": True,
  500. }
  501. ],
  502. "null_policy": "explicit",
  503. "timezone": "Asia/Shanghai",
  504. }
  505. )
  506. rule = {
  507. "id": new_governance_uid(),
  508. "status": "published",
  509. "rule_spec": spec,
  510. "spec_hash": rule_spec_hash(spec),
  511. }
  512. input_binding = {
  513. "id": new_governance_uid(),
  514. "data_source_uid": datasource_uid,
  515. "object_kind": "table",
  516. "object_ref": f"{schema_name}.{source_name}",
  517. "schema_snapshot_id": input_schema["id"],
  518. "access_mode": "read",
  519. "dialect": dialect,
  520. "write_mode": "append",
  521. }
  522. try:
  523. with engine.begin() as connection:
  524. for name in (alternate_name, nonunique_name, source_name):
  525. connection.execute(text(f"DROP TABLE IF EXISTS {name}"))
  526. connection.execute(
  527. text(
  528. f"CREATE TABLE {source_name} ("
  529. "customer_id BIGINT PRIMARY KEY, "
  530. "name VARCHAR(100), mobile VARCHAR(30))"
  531. )
  532. )
  533. connection.execute(
  534. text(
  535. f"CREATE TABLE {nonunique_name} ("
  536. "customer_id BIGINT, name VARCHAR(100), mobile VARCHAR(30))"
  537. )
  538. )
  539. connection.execute(
  540. text(
  541. f"CREATE TABLE {alternate_name} ("
  542. "customer_id BIGINT PRIMARY KEY, "
  543. "name VARCHAR(100), mobile VARCHAR(30) UNIQUE)"
  544. )
  545. )
  546. connection.execute(
  547. text(
  548. f"INSERT INTO {source_name} "
  549. "(customer_id, name, mobile) "
  550. "VALUES (1, ' Alice ', '13800138000')"
  551. )
  552. )
  553. adapter = SqlGlotRulePlanAdapter(
  554. DirectManager(engine, Definition(dialect, capabilities))
  555. )
  556. for target_name, error in (
  557. (nonunique_name, "exact unique key"),
  558. (alternate_name, "alternate unique"),
  559. ):
  560. output_binding = {
  561. "id": new_governance_uid(),
  562. "data_source_uid": datasource_uid,
  563. "object_kind": "table",
  564. "object_ref": f"{schema_name}.{target_name}",
  565. "schema_snapshot_id": output_schema["id"],
  566. "access_mode": "write",
  567. "dialect": dialect,
  568. "write_mode": "append",
  569. }
  570. compiled = SqlGlotRuleCompiler(dialect).compile(
  571. rule_version=rule,
  572. input_schema=input_schema,
  573. output_schema=output_schema,
  574. input_binding=input_binding,
  575. output_binding=output_binding,
  576. backend=capabilities,
  577. )
  578. node = {
  579. "id": "task4_unique_rule",
  580. "type": "rule.apply",
  581. "purpose": "write",
  582. "idempotency": {
  583. "strategy": "upsert",
  584. "key": "customer_id",
  585. },
  586. "config": {
  587. "component_binding_id": new_governance_uid(),
  588. "rule_version_id": rule["id"],
  589. "execution_plan_hash": compiled["plan_hash"],
  590. },
  591. }
  592. with pytest.raises(NodeExecutionError, match=error):
  593. adapter.execute(
  594. plan=compiled["plan"],
  595. node=node,
  596. parameters={},
  597. write_authorized=True,
  598. )
  599. with engine.connect() as connection:
  600. assert connection.execute(
  601. text(f"SELECT COUNT(*) FROM {target_name}")
  602. ).scalar_one() == 0
  603. finally:
  604. with engine.begin() as connection:
  605. for name in (alternate_name, nonunique_name, source_name):
  606. connection.execute(text(f"DROP TABLE IF EXISTS {name}"))
  607. engine.dispose()