rule_sql.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. """Runner adapter for exact, published SQLGlot rule plans."""
  2. from __future__ import annotations
  3. import copy
  4. import re
  5. from sqlalchemy import text
  6. from sqlglot import exp, parse_one
  7. from app.core.data_rules.compilers.sql import (
  8. bound_sql_plan_hash,
  9. validate_bound_sql_plan,
  10. )
  11. from app.core.data_source.errors import DataSourceWriteOutcomeUnknown
  12. from app.runner.nodes import NodeExecutionError
  13. def _dialect(value):
  14. normalized = str(value or "").strip().lower()
  15. return "postgresql" if normalized == "postgres" else normalized
  16. def _source_count_statement(plan):
  17. dialect = "postgres" if plan["dialect"] == "postgresql" else plan["dialect"]
  18. insert = parse_one(plan["statements"][0]["sql"], read=dialect)
  19. source_tables = list(insert.expression.find_all(exp.Table))
  20. if len(source_tables) != 1:
  21. raise NodeExecutionError("published SQL rule plan has an invalid source")
  22. count = exp.Select(
  23. expressions=[exp.Count(this=exp.Star())]
  24. ).from_(copy.deepcopy(source_tables[0]))
  25. return count.sql(dialect=dialect)
  26. def _accepted_count_statement(plan):
  27. dialect = "postgres" if plan["dialect"] == "postgresql" else plan["dialect"]
  28. insert = parse_one(plan["statements"][0]["sql"], read=dialect)
  29. accepted = exp.Select(
  30. expressions=[exp.Count(this=exp.Star())]
  31. ).from_(
  32. exp.Subquery(
  33. this=copy.deepcopy(insert.expression),
  34. alias=exp.TableAlias(
  35. this=exp.Identifier(this="_dataops_accepted")
  36. ),
  37. )
  38. )
  39. statement = accepted.sql(dialect=dialect)
  40. if plan["dialect"] == "postgresql":
  41. statement = re.sub(
  42. r"%\(([A-Za-z_][A-Za-z0-9_]*)\)s",
  43. r":\1",
  44. statement,
  45. )
  46. return statement
  47. def _upsert_statement(plan, key):
  48. if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", str(key or "")) is None:
  49. raise NodeExecutionError("governed SQL rule idempotency key is invalid")
  50. dialect = "postgres" if plan["dialect"] == "postgresql" else plan["dialect"]
  51. insert = parse_one(plan["statements"][0]["sql"], read=dialect)
  52. target = insert.this
  53. if not isinstance(target, exp.Schema):
  54. raise NodeExecutionError("published SQL rule target schema is invalid")
  55. columns = [column.name for column in target.expressions]
  56. if key not in columns:
  57. raise NodeExecutionError(
  58. "governed SQL rule idempotency key is not an output field"
  59. )
  60. assignments = []
  61. if plan["dialect"] == "postgresql":
  62. for name in columns:
  63. if name == key:
  64. continue
  65. assignments.append(
  66. exp.EQ(
  67. this=exp.Column(
  68. this=exp.Identifier(this=name, quoted=True)
  69. ),
  70. expression=exp.Column(
  71. this=exp.Identifier(this=name, quoted=True),
  72. table=exp.Identifier(this="EXCLUDED"),
  73. ),
  74. )
  75. )
  76. insert.set(
  77. "conflict",
  78. exp.OnConflict(
  79. duplicate=False,
  80. expressions=assignments,
  81. action=exp.Var(
  82. this="DO UPDATE" if assignments else "DO NOTHING"
  83. ),
  84. conflict_keys=[
  85. exp.Ordered(
  86. this=exp.Column(
  87. this=exp.Identifier(this=key, quoted=True)
  88. )
  89. )
  90. ],
  91. ),
  92. )
  93. else:
  94. for name in columns:
  95. assignments.append(
  96. exp.EQ(
  97. this=exp.Column(
  98. this=exp.Identifier(this=name, quoted=True)
  99. ),
  100. expression=exp.Anonymous(
  101. this="VALUES",
  102. expressions=[
  103. exp.Identifier(this=name, quoted=True)
  104. ],
  105. ),
  106. )
  107. )
  108. insert.set(
  109. "conflict",
  110. exp.OnConflict(
  111. duplicate=True,
  112. expressions=assignments,
  113. action=exp.Var(this="UPDATE"),
  114. ),
  115. )
  116. statement = insert.sql(dialect=dialect)
  117. if plan["dialect"] == "postgresql":
  118. statement = re.sub(
  119. r"%\(([A-Za-z_][A-Za-z0-9_]*)\)s",
  120. r":\1",
  121. statement,
  122. )
  123. return statement
  124. def _target_relation(plan):
  125. dialect = "postgres" if plan["dialect"] == "postgresql" else plan["dialect"]
  126. insert = parse_one(plan["statements"][0]["sql"], read=dialect)
  127. target = insert.this
  128. if not isinstance(target, exp.Schema) or not isinstance(
  129. target.this, exp.Table
  130. ):
  131. raise NodeExecutionError("published SQL rule target schema is invalid")
  132. table = target.this
  133. if not table.db or not table.name:
  134. raise NodeExecutionError(
  135. "published SQL rule target must be schema-qualified"
  136. )
  137. return table.db, table.name
  138. def _attest_upsert_key(connection, plan, key):
  139. """Prove the exact server-side unique-key contract before any write."""
  140. schema_name, table_name = _target_relation(plan)
  141. if plan["dialect"] == "postgresql":
  142. query = text(
  143. "SELECT tc.constraint_name, tc.constraint_type, "
  144. "string_agg(kcu.column_name, ',' ORDER BY kcu.ordinal_position) "
  145. "AS columns "
  146. "FROM information_schema.table_constraints tc "
  147. "JOIN information_schema.key_column_usage kcu "
  148. "ON kcu.constraint_catalog = tc.constraint_catalog "
  149. "AND kcu.constraint_schema = tc.constraint_schema "
  150. "AND kcu.constraint_name = tc.constraint_name "
  151. "WHERE tc.table_schema = :schema_name "
  152. "AND tc.table_name = :table_name "
  153. "AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE') "
  154. "GROUP BY tc.constraint_name, tc.constraint_type"
  155. )
  156. else:
  157. query = text(
  158. "SELECT index_name AS constraint_name, "
  159. "CASE WHEN index_name = 'PRIMARY' THEN 'PRIMARY KEY' ELSE 'UNIQUE' END "
  160. "AS constraint_type, "
  161. "GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') "
  162. "AS columns "
  163. "FROM information_schema.statistics "
  164. "WHERE table_schema = :schema_name "
  165. "AND table_name = :table_name AND non_unique = 0 "
  166. "GROUP BY index_name"
  167. )
  168. rows = (
  169. connection.execute(
  170. query,
  171. {"schema_name": schema_name, "table_name": table_name},
  172. )
  173. .mappings()
  174. .all()
  175. )
  176. def columns(row):
  177. value = row["columns"]
  178. if isinstance(value, str):
  179. return value.split(",") if value else []
  180. return list(value or [])
  181. unique_keys = [columns(row) for row in rows]
  182. if [key] not in unique_keys:
  183. raise NodeExecutionError(
  184. "governed SQL rule idempotency key is not an exact unique key"
  185. )
  186. if plan["dialect"] == "mysql" and any(
  187. unique_key != [key] for unique_key in unique_keys
  188. ):
  189. raise NodeExecutionError(
  190. "MySQL upsert target has an alternate unique collision path"
  191. )
  192. class SqlGlotRulePlanAdapter:
  193. """Execute a validated INSERT plan and count its source in one transaction."""
  194. def __init__(self, manager):
  195. self.manager = manager
  196. def execute(
  197. self,
  198. *,
  199. plan,
  200. node,
  201. parameters,
  202. write_authorized,
  203. ):
  204. try:
  205. normalized = validate_bound_sql_plan(plan)
  206. except ValueError as exc:
  207. raise NodeExecutionError("published SQL rule plan is invalid") from exc
  208. config = node.get("config") or {}
  209. if config.get("execution_plan_hash") != bound_sql_plan_hash(normalized):
  210. raise NodeExecutionError("published SQL rule plan hash does not match")
  211. if config.get("rule_version_id") != normalized["rule_version_id"]:
  212. raise NodeExecutionError("published SQL rule plan rule id does not match")
  213. idempotency = node.get("idempotency")
  214. if (
  215. node.get("type") != "rule.apply"
  216. or node.get("purpose") != "write"
  217. or not write_authorized
  218. or not isinstance(idempotency, dict)
  219. or idempotency.get("strategy") != "upsert"
  220. or not str(idempotency.get("key") or "").strip()
  221. ):
  222. raise NodeExecutionError(
  223. "governed write authorization and idempotency are required"
  224. )
  225. if parameters not in ({}, None):
  226. raise NodeExecutionError(
  227. "bound SQL rule plans do not accept unbound runtime parameters"
  228. )
  229. definition = self.manager.definitions.get(
  230. normalized["data_source_uid"]
  231. )
  232. if definition is None:
  233. raise NodeExecutionError("published SQL rule datasource was not found")
  234. if _dialect(getattr(definition, "database_type", None)) != normalized[
  235. "dialect"
  236. ]:
  237. raise NodeExecutionError(
  238. "published SQL rule datasource dialect does not match"
  239. )
  240. datasource_capabilities = dict(
  241. getattr(definition, "extra_properties", {}) or {}
  242. ).get("sql_rule_capabilities")
  243. if datasource_capabilities != normalized["capabilities"]:
  244. raise NodeExecutionError(
  245. "published SQL rule datasource capabilities do not match"
  246. )
  247. statement = normalized["statements"][0]
  248. try:
  249. with self.manager.connect(
  250. normalized["data_source_uid"],
  251. purpose="dataflow_write",
  252. ) as connection:
  253. rows_in = int(
  254. connection.execute(
  255. text(_source_count_statement(normalized)),
  256. {},
  257. ).scalar_one()
  258. or 0
  259. )
  260. rows_out = int(
  261. connection.execute(
  262. text(_accepted_count_statement(normalized)),
  263. statement["parameters"],
  264. ).scalar_one()
  265. or 0
  266. )
  267. _attest_upsert_key(
  268. connection,
  269. normalized,
  270. idempotency["key"],
  271. )
  272. result = connection.execute(
  273. text(
  274. _upsert_statement(
  275. normalized,
  276. idempotency["key"],
  277. )
  278. ),
  279. statement["parameters"],
  280. )
  281. if result.rowcount is not None and int(result.rowcount) < 0:
  282. raise NodeExecutionError(
  283. "governed SQL rule returned an invalid write count"
  284. )
  285. metrics = {
  286. "rows_in": rows_in,
  287. "rows_out": rows_out,
  288. "rows_rejected": max(0, rows_in - rows_out),
  289. "commit_outcome": "committed",
  290. }
  291. except DataSourceWriteOutcomeUnknown as exc:
  292. raise NodeExecutionError(
  293. "governed SQL rule commit outcome is unknown",
  294. commit_outcome="unknown",
  295. ) from exc
  296. except NodeExecutionError:
  297. raise
  298. except Exception as exc:
  299. raise NodeExecutionError(
  300. "governed SQL rule write failed",
  301. commit_outcome="not_committed",
  302. ) from exc
  303. return metrics
  304. class SqlGlotQualityPlanAdapter:
  305. """Evaluate one attested SQLGlot plan without executing its write."""
  306. def __init__(self, manager):
  307. self.manager = manager
  308. def execute(
  309. self,
  310. *,
  311. plan,
  312. node,
  313. parameters,
  314. write_authorized,
  315. ):
  316. try:
  317. normalized = validate_bound_sql_plan(plan)
  318. except ValueError as exc:
  319. raise NodeExecutionError(
  320. "published SQL quality plan is invalid"
  321. ) from exc
  322. config = node.get("config") or {}
  323. if config.get("execution_plan_hash") != bound_sql_plan_hash(normalized):
  324. raise NodeExecutionError(
  325. "published SQL quality plan hash does not match"
  326. )
  327. if config.get("rule_version_id") != normalized["rule_version_id"]:
  328. raise NodeExecutionError(
  329. "published SQL quality plan rule id does not match"
  330. )
  331. if (
  332. node.get("type") != "quality.check"
  333. or node.get("purpose") != "read"
  334. or write_authorized
  335. or node.get("idempotency") is not None
  336. ):
  337. raise NodeExecutionError("quality check must be read only")
  338. if parameters not in ({}, None):
  339. raise NodeExecutionError(
  340. "bound SQL quality plans do not accept runtime parameters"
  341. )
  342. definition = self.manager.definitions.get(
  343. normalized["data_source_uid"]
  344. )
  345. if definition is None:
  346. raise NodeExecutionError(
  347. "published SQL quality datasource was not found"
  348. )
  349. if _dialect(getattr(definition, "database_type", None)) != normalized[
  350. "dialect"
  351. ]:
  352. raise NodeExecutionError(
  353. "published SQL quality datasource dialect does not match"
  354. )
  355. datasource_capabilities = dict(
  356. getattr(definition, "extra_properties", {}) or {}
  357. ).get("sql_rule_capabilities")
  358. if datasource_capabilities != normalized["capabilities"]:
  359. raise NodeExecutionError(
  360. "published SQL quality datasource capabilities do not match"
  361. )
  362. statement = normalized["statements"][0]
  363. try:
  364. with self.manager.connect(
  365. normalized["data_source_uid"],
  366. purpose="dataflow_read",
  367. ) as connection:
  368. rows_in = int(
  369. connection.execute(
  370. text(_source_count_statement(normalized)),
  371. {},
  372. ).scalar_one()
  373. or 0
  374. )
  375. rows_out = int(
  376. connection.execute(
  377. text(_accepted_count_statement(normalized)),
  378. statement["parameters"],
  379. ).scalar_one()
  380. or 0
  381. )
  382. except NodeExecutionError:
  383. raise
  384. except Exception as exc:
  385. raise NodeExecutionError(
  386. "governed SQL quality check failed",
  387. commit_outcome="not_applicable",
  388. ) from exc
  389. rows_rejected = max(0, rows_in - rows_out)
  390. return {
  391. "rows_in": rows_in,
  392. "rows_out": rows_out,
  393. "rows_rejected": rows_rejected,
  394. "rows_quarantined": 0,
  395. "violation_count": rows_rejected,
  396. "violations": (
  397. [
  398. {
  399. "step_id": "quality_check",
  400. "count": rows_rejected,
  401. }
  402. ]
  403. if rows_rejected
  404. else []
  405. ),
  406. "commit_outcome": "not_applicable",
  407. }
  408. __all__ = ["SqlGlotQualityPlanAdapter", "SqlGlotRulePlanAdapter"]