rule_sql.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  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. class SqlGlotRulePlanAdapter:
  125. """Execute a validated INSERT plan and count its source in one transaction."""
  126. def __init__(self, manager):
  127. self.manager = manager
  128. def execute(
  129. self,
  130. *,
  131. plan,
  132. node,
  133. parameters,
  134. write_authorized,
  135. ):
  136. try:
  137. normalized = validate_bound_sql_plan(plan)
  138. except ValueError as exc:
  139. raise NodeExecutionError("published SQL rule plan is invalid") from exc
  140. config = node.get("config") or {}
  141. if config.get("execution_plan_hash") != bound_sql_plan_hash(normalized):
  142. raise NodeExecutionError("published SQL rule plan hash does not match")
  143. if config.get("rule_version_id") != normalized["rule_version_id"]:
  144. raise NodeExecutionError("published SQL rule plan rule id does not match")
  145. idempotency = node.get("idempotency")
  146. if (
  147. node.get("type") != "rule.apply"
  148. or node.get("purpose") != "write"
  149. or not write_authorized
  150. or not isinstance(idempotency, dict)
  151. or idempotency.get("strategy") != "upsert"
  152. or not str(idempotency.get("key") or "").strip()
  153. ):
  154. raise NodeExecutionError(
  155. "governed write authorization and idempotency are required"
  156. )
  157. if parameters not in ({}, None):
  158. raise NodeExecutionError(
  159. "bound SQL rule plans do not accept unbound runtime parameters"
  160. )
  161. definition = self.manager.definitions.get(
  162. normalized["data_source_uid"]
  163. )
  164. if definition is None:
  165. raise NodeExecutionError("published SQL rule datasource was not found")
  166. if _dialect(getattr(definition, "database_type", None)) != normalized[
  167. "dialect"
  168. ]:
  169. raise NodeExecutionError(
  170. "published SQL rule datasource dialect does not match"
  171. )
  172. datasource_capabilities = dict(
  173. getattr(definition, "extra_properties", {}) or {}
  174. ).get("sql_rule_capabilities")
  175. if datasource_capabilities != normalized["capabilities"]:
  176. raise NodeExecutionError(
  177. "published SQL rule datasource capabilities do not match"
  178. )
  179. statement = normalized["statements"][0]
  180. try:
  181. with self.manager.connect(
  182. normalized["data_source_uid"],
  183. purpose="dataflow_write",
  184. ) as connection:
  185. rows_in = int(
  186. connection.execute(
  187. text(_source_count_statement(normalized)),
  188. {},
  189. ).scalar_one()
  190. or 0
  191. )
  192. rows_out = int(
  193. connection.execute(
  194. text(_accepted_count_statement(normalized)),
  195. statement["parameters"],
  196. ).scalar_one()
  197. or 0
  198. )
  199. result = connection.execute(
  200. text(
  201. _upsert_statement(
  202. normalized,
  203. idempotency["key"],
  204. )
  205. ),
  206. statement["parameters"],
  207. )
  208. if result.rowcount is not None and int(result.rowcount) < 0:
  209. raise NodeExecutionError(
  210. "governed SQL rule returned an invalid write count"
  211. )
  212. metrics = {
  213. "rows_in": rows_in,
  214. "rows_out": rows_out,
  215. "rows_rejected": max(0, rows_in - rows_out),
  216. "commit_outcome": "committed",
  217. }
  218. except DataSourceWriteOutcomeUnknown as exc:
  219. raise NodeExecutionError(
  220. "governed SQL rule commit outcome is unknown",
  221. commit_outcome="unknown",
  222. ) from exc
  223. except NodeExecutionError:
  224. raise
  225. except Exception as exc:
  226. raise NodeExecutionError(
  227. "governed SQL rule write failed",
  228. commit_outcome="not_committed",
  229. ) from exc
  230. return metrics
  231. class SqlGlotQualityPlanAdapter:
  232. """Explicit fail-closed placeholder until a read-only quality plan exists."""
  233. def execute(self, **_kwargs):
  234. raise NodeExecutionError(
  235. "quality_check requires an explicit read-only compiled plan"
  236. )
  237. __all__ = ["SqlGlotQualityPlanAdapter", "SqlGlotRulePlanAdapter"]