sqlguard-ninja 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. sqlguard_ninja/__init__.py +35 -0
  2. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/01_destructive.yaml +169 -0
  3. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +155 -0
  4. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +205 -0
  5. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/04_privacy.yaml +245 -0
  6. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +167 -0
  7. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +168 -0
  8. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +243 -0
  9. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/grant_matrix.yaml +573 -0
  10. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/label_matrix.yaml +403 -0
  11. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/tenant_matrix.yaml +684 -0
  12. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/transforms.yaml +927 -0
  13. sqlguard_ninja/_data/benchmarks/datasets/v1/manifest.yaml +15 -0
  14. sqlguard_ninja/_data/benchmarks/policies/retail_saas.yaml +259 -0
  15. sqlguard_ninja/_version.py +1 -0
  16. sqlguard_ninja/benchmarking/__init__.py +1 -0
  17. sqlguard_ninja/benchmarking/adapters.py +303 -0
  18. sqlguard_ninja/benchmarking/builtin.py +39 -0
  19. sqlguard_ninja/benchmarking/compare.py +168 -0
  20. sqlguard_ninja/benchmarking/dataset.py +265 -0
  21. sqlguard_ninja/benchmarking/fixtures.py +274 -0
  22. sqlguard_ninja/benchmarking/metrics.py +114 -0
  23. sqlguard_ninja/benchmarking/runner.py +126 -0
  24. sqlguard_ninja/benchmarking/translate.py +64 -0
  25. sqlguard_ninja/benchmarking/workers/_protocol.py +53 -0
  26. sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +40 -0
  27. sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +25 -0
  28. sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +48 -0
  29. sqlguard_ninja/cli/__init__.py +1 -0
  30. sqlguard_ninja/cli/config.py +76 -0
  31. sqlguard_ninja/cli/main.py +433 -0
  32. sqlguard_ninja/cli/templates.py +91 -0
  33. sqlguard_ninja/core/__init__.py +1 -0
  34. sqlguard_ninja/core/analysis.py +581 -0
  35. sqlguard_ninja/core/findings.py +133 -0
  36. sqlguard_ninja/core/guard.py +256 -0
  37. sqlguard_ninja/core/rules/__init__.py +1 -0
  38. sqlguard_ninja/core/rules/access.py +127 -0
  39. sqlguard_ninja/core/rules/base.py +40 -0
  40. sqlguard_ninja/core/rules/catalog.py +61 -0
  41. sqlguard_ninja/core/rules/functions.py +197 -0
  42. sqlguard_ninja/core/rules/statements.py +99 -0
  43. sqlguard_ninja/core/rules/structure.py +72 -0
  44. sqlguard_ninja/core/rules/tenant.py +196 -0
  45. sqlguard_ninja/integrations/__init__.py +43 -0
  46. sqlguard_ninja/integrations/audit.py +109 -0
  47. sqlguard_ninja/integrations/checkpoint.py +342 -0
  48. sqlguard_ninja/integrations/executors.py +64 -0
  49. sqlguard_ninja/integrations/fastapi.py +89 -0
  50. sqlguard_ninja/integrations/langchain.py +67 -0
  51. sqlguard_ninja/integrations/langgraph.py +87 -0
  52. sqlguard_ninja/policy/__init__.py +39 -0
  53. sqlguard_ninja/policy/model.py +370 -0
  54. sqlguard_ninja/privacy/__init__.py +9 -0
  55. sqlguard_ninja/privacy/rules.py +286 -0
  56. sqlguard_ninja/privacy/suggest.py +46 -0
  57. sqlguard_ninja/py.typed +0 -0
  58. sqlguard_ninja/query_cost/__init__.py +1 -0
  59. sqlguard_ninja/query_cost/postgres.py +226 -0
  60. sqlguard_ninja/query_cost/static.py +241 -0
  61. sqlguard_ninja/reporting/__init__.py +1 -0
  62. sqlguard_ninja/reporting/markdown.py +137 -0
  63. sqlguard_ninja-0.1.0.dist-info/METADATA +370 -0
  64. sqlguard_ninja-0.1.0.dist-info/RECORD +68 -0
  65. sqlguard_ninja-0.1.0.dist-info/WHEEL +4 -0
  66. sqlguard_ninja-0.1.0.dist-info/entry_points.txt +2 -0
  67. sqlguard_ninja-0.1.0.dist-info/licenses/LICENSE +202 -0
  68. sqlguard_ninja-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +56 -0
@@ -0,0 +1,168 @@
1
+ """Compare a candidate benchmark result with an approved baseline.
2
+
3
+ A comparison never updates the baseline. Regressions are classified so CI can gate on the
4
+ ones that matter most:
5
+
6
+ * ``security_regressions`` — an unsafe case the baseline flagged is now ALLOWED.
7
+ * ``sensitive_regressions`` / ``tenant_regressions`` — subsets in privacy and tenant categories.
8
+ * ``usability_regressions`` — a safe case the baseline allowed is now blocked.
9
+ * ``decision_changes`` — any other change of decision (e.g. deny → review).
10
+ * ``new_errors`` — validator crashes or new parse failures not present in the baseline.
11
+ * ``latency`` — p95 change in percent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ PRIVACY_CATEGORIES = frozenset({"sensitive_field", "json_fields"})
20
+ TENANT_CATEGORIES = frozenset({"tenant_isolation"})
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Thresholds:
25
+ max_security_regressions: int = 0
26
+ max_usability_regressions: int = 0
27
+ max_new_errors: int = 0
28
+ min_recall: float | None = None
29
+ max_false_positive_rate: float | None = None
30
+ max_p95_latency_increase_pct: float | None = None
31
+ """None disables the latency gate (CI hardware varies)."""
32
+ allow_benchmark_change: bool = False
33
+ allow_label_changes: bool = False
34
+ """Expected outcomes of existing cases changed. Off by default: relabelling a case is
35
+ exactly how a regression could be hidden, so it needs explicit human approval."""
36
+
37
+
38
+ @dataclass
39
+ class Comparison:
40
+ baseline: dict[str, Any]
41
+ candidate: dict[str, Any]
42
+ security_regressions: list[str] = field(default_factory=list)
43
+ sensitive_regressions: list[str] = field(default_factory=list)
44
+ tenant_regressions: list[str] = field(default_factory=list)
45
+ usability_regressions: list[str] = field(default_factory=list)
46
+ improvements: list[str] = field(default_factory=list)
47
+ decision_changes: list[str] = field(default_factory=list)
48
+ new_errors: list[str] = field(default_factory=list)
49
+ new_parse_failures: list[str] = field(default_factory=list)
50
+ missing_cases: list[str] = field(default_factory=list)
51
+ label_changes: list[str] = field(default_factory=list)
52
+ added_cases: list[str] = field(default_factory=list)
53
+ p95_change_pct: float | None = None
54
+ failures: list[str] = field(default_factory=list)
55
+
56
+ @property
57
+ def passed(self) -> bool:
58
+ return not self.failures
59
+
60
+ def as_dict(self) -> dict[str, Any]:
61
+ keys = [
62
+ "security_regressions",
63
+ "sensitive_regressions",
64
+ "tenant_regressions",
65
+ "usability_regressions",
66
+ "improvements",
67
+ "decision_changes",
68
+ "new_errors",
69
+ "new_parse_failures",
70
+ "missing_cases",
71
+ "label_changes",
72
+ "added_cases",
73
+ "p95_change_pct",
74
+ "failures",
75
+ ]
76
+ return {"passed": self.passed, **{k: getattr(self, k) for k in keys}}
77
+
78
+
79
+ def _is_parse_failure(record: dict[str, Any]) -> bool:
80
+ return any(str(r).startswith("parse.") for r in record.get("reasons", ()))
81
+
82
+
83
+ def compare(
84
+ baseline: dict[str, Any], candidate: dict[str, Any], thresholds: Thresholds | None = None
85
+ ) -> Comparison:
86
+ t = thresholds or Thresholds()
87
+ c = Comparison(baseline, candidate)
88
+ base = {r["id"]: r for r in baseline["cases"]}
89
+ cand = {r["id"]: r for r in candidate["cases"]}
90
+ c.missing_cases = sorted(set(base) - set(cand))
91
+ c.added_cases = sorted(set(cand) - set(base))
92
+
93
+ for cid in sorted(set(base) & set(cand)):
94
+ b, n = base[cid], cand[cid]
95
+ if b["expected"] != n["expected"]:
96
+ c.label_changes.append(cid)
97
+ if n.get("error") and not b.get("error"):
98
+ c.new_errors.append(cid)
99
+ if _is_parse_failure(n) and not _is_parse_failure(b) and n["expected"] == "allow":
100
+ c.new_parse_failures.append(cid)
101
+ if b["actual"] == n["actual"]:
102
+ continue
103
+ unsafe = n["expected"] != "allow"
104
+ if unsafe and b["actual"] != "allow" and n["actual"] == "allow":
105
+ c.security_regressions.append(cid)
106
+ if n["category"] in PRIVACY_CATEGORIES:
107
+ c.sensitive_regressions.append(cid)
108
+ if n["category"] in TENANT_CATEGORIES:
109
+ c.tenant_regressions.append(cid)
110
+ elif not unsafe and b["actual"] == "allow" and n["actual"] != "allow":
111
+ c.usability_regressions.append(cid)
112
+ elif n["actual"] == n["expected"]:
113
+ c.improvements.append(cid)
114
+ else:
115
+ c.decision_changes.append(cid)
116
+
117
+ # Cases that are new in the candidate still count when they are unsafe and allowed:
118
+ # a PR must not be able to add a failing case and the code that fails it together.
119
+ for cid in c.added_cases:
120
+ if cand[cid]["kind"] == "fn":
121
+ c.security_regressions.append(cid)
122
+
123
+ bp95 = baseline["metrics"]["overall"]["latency_ms"]["p95"]
124
+ np95 = candidate["metrics"]["overall"]["latency_ms"]["p95"]
125
+ if bp95 and np95:
126
+ c.p95_change_pct = round((np95 - bp95) / bp95 * 100, 1)
127
+
128
+ bfp = baseline["benchmark"]["fingerprint"]
129
+ nfp = candidate["benchmark"]["fingerprint"]
130
+ if bfp != nfp and not t.allow_benchmark_change:
131
+ c.failures.append(
132
+ f"benchmark fingerprint changed ({bfp} -> {nfp}); regenerate and approve a new "
133
+ "baseline, or pass allow_benchmark_change"
134
+ )
135
+ if c.label_changes and not t.allow_label_changes:
136
+ c.failures.append(
137
+ f"{len(c.label_changes)} case label(s) changed; relabelling requires explicit approval"
138
+ )
139
+ if c.missing_cases and not t.allow_benchmark_change:
140
+ c.failures.append(f"{len(c.missing_cases)} baseline cases are missing from the candidate")
141
+ if len(c.security_regressions) > t.max_security_regressions:
142
+ c.failures.append(f"{len(c.security_regressions)} security regression(s)")
143
+ if len(c.usability_regressions) > t.max_usability_regressions:
144
+ c.failures.append(f"{len(c.usability_regressions)} newly blocked legitimate case(s)")
145
+ if len(c.new_errors) + len(c.new_parse_failures) > t.max_new_errors:
146
+ c.failures.append(
147
+ f"{len(c.new_errors)} new validator error(s), "
148
+ f"{len(c.new_parse_failures)} new parse failure(s)"
149
+ )
150
+ overall = candidate["metrics"]["overall"]
151
+ if t.min_recall is not None and (overall["recall"] or 0) < t.min_recall:
152
+ c.failures.append(f"recall {overall['recall']} below {t.min_recall}")
153
+ fpr = overall["false_positive_rate"]
154
+ if (
155
+ t.max_false_positive_rate is not None
156
+ and fpr is not None
157
+ and fpr > t.max_false_positive_rate
158
+ ):
159
+ c.failures.append(f"false-positive rate {fpr} above {t.max_false_positive_rate}")
160
+ if (
161
+ t.max_p95_latency_increase_pct is not None
162
+ and c.p95_change_pct is not None
163
+ and c.p95_change_pct > t.max_p95_latency_increase_pct
164
+ ):
165
+ c.failures.append(
166
+ f"p95 latency +{c.p95_change_pct}% exceeds {t.max_p95_latency_increase_pct}%"
167
+ )
168
+ return c
@@ -0,0 +1,265 @@
1
+ """Versioned benchmark corpus: case model, file format and loader.
2
+
3
+ A dataset directory contains ``manifest.yaml`` and one or more case files. Each case file has
4
+ optional ``defaults`` merged into every case. A case may target several dialects; the
5
+ loader expands it into one :class:`BenchmarkCase` per dialect with id ``<id>@<dialect>``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from collections import Counter
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
19
+
20
+ from sqlguard_ninja.core.findings import Decision
21
+ from sqlguard_ninja.policy.model import Policy
22
+
23
+
24
+ class Classification(StrEnum):
25
+ BENIGN = "benign"
26
+ """Legitimate query that should run."""
27
+ MALICIOUS = "malicious"
28
+ """Deliberate attack: destruction, exfiltration, DoS, injection, bypass attempts."""
29
+ POLICY_VIOLATION = "policy_violation"
30
+ """Over-reach that may be accidental: an ungranted column, a missing tenant filter."""
31
+ SENSITIVE = "sensitive"
32
+ """Permitted only after human review."""
33
+
34
+
35
+ class Reason(StrEnum):
36
+ """Tool-agnostic reason taxonomy used to label expected rejections."""
37
+
38
+ NONE = "none"
39
+ WRITE_STATEMENT = "write_statement"
40
+ MULTIPLE_STATEMENTS = "multiple_statements"
41
+ UNSUPPORTED_STATEMENT = "unsupported_statement"
42
+ SYSTEM_CATALOG = "system_catalog"
43
+ DANGEROUS_FUNCTION = "dangerous_function"
44
+ UNAUTHORIZED_TABLE = "unauthorized_table"
45
+ UNAUTHORIZED_COLUMN = "unauthorized_column"
46
+ SENSITIVE_DATA = "sensitive_data"
47
+ AGGREGATE_ONLY = "aggregate_only"
48
+ SMALL_COHORT = "small_cohort"
49
+ TENANT_ISOLATION = "tenant_isolation"
50
+ COST = "cost"
51
+ STRUCTURE_LIMIT = "structure_limit"
52
+ PARSE_ERROR = "parse_error"
53
+ UNRESOLVABLE = "unresolvable"
54
+ RECONNAISSANCE = "reconnaissance"
55
+
56
+
57
+ _DEFAULT_CLASSIFICATION = {
58
+ Decision.ALLOW: Classification.BENIGN,
59
+ Decision.DENY: Classification.POLICY_VIOLATION,
60
+ Decision.REVIEW: Classification.SENSITIVE,
61
+ }
62
+
63
+
64
+ class BenchmarkCase(BaseModel):
65
+ """One evaluation: a SQL string, a dialect, a role/context and the expected outcome."""
66
+
67
+ model_config = ConfigDict(extra="forbid", frozen=True)
68
+
69
+ id: str
70
+ base_id: str
71
+ sql: str
72
+ dialect: str
73
+ role: str | None
74
+ context: dict[str, Any] = Field(default_factory=dict)
75
+ expected: Decision
76
+ classification: Classification
77
+ reason: Reason
78
+ category: str
79
+ tags: tuple[str, ...] = ()
80
+ provenance: str = "handwritten"
81
+ explanation: str
82
+ expected_rules: tuple[str, ...] = ()
83
+ """Native rule ids expected to fire. Diagnostic only; never used for scoring."""
84
+
85
+ @property
86
+ def unsafe(self) -> bool:
87
+ """Positive class for detection metrics: anything that must not run unattended."""
88
+ return self.expected is not Decision.ALLOW
89
+
90
+ @model_validator(mode="after")
91
+ def _consistent(self) -> BenchmarkCase:
92
+ if self.expected is Decision.ALLOW and self.classification is not Classification.BENIGN:
93
+ raise ValueError(f"{self.id}: allow cases must be classified benign")
94
+ if self.expected is not Decision.ALLOW and self.classification is Classification.BENIGN:
95
+ raise ValueError(f"{self.id}: benign cases must expect allow")
96
+ if self.expected is Decision.ALLOW and self.reason is not Reason.NONE:
97
+ raise ValueError(f"{self.id}: allow cases must use reason 'none'")
98
+ if self.expected is not Decision.ALLOW and self.reason is Reason.NONE:
99
+ raise ValueError(f"{self.id}: a rejection needs a reason")
100
+ return self
101
+
102
+
103
+ class RawCase(BaseModel):
104
+ """A case as written in a file (may target several dialects)."""
105
+
106
+ model_config = ConfigDict(extra="forbid")
107
+
108
+ id: str
109
+ sql: str
110
+ dialects: tuple[str, ...]
111
+ role: str | None = None
112
+ context: dict[str, Any] = Field(default_factory=dict)
113
+ expected: Decision
114
+ classification: Classification | None = None
115
+ reason: Reason = Reason.NONE
116
+ category: str
117
+ tags: tuple[str, ...] = ()
118
+ provenance: str = "handwritten"
119
+ explanation: str
120
+ expected_rules: tuple[str, ...] = ()
121
+ expected_by_dialect: dict[str, Decision] = Field(default_factory=dict)
122
+ reason_by_dialect: dict[str, Reason] = Field(default_factory=dict)
123
+
124
+ @field_validator("dialects", mode="before")
125
+ @classmethod
126
+ def _one_or_many(cls, value: Any) -> Any:
127
+ return [value] if isinstance(value, str) else value
128
+
129
+
130
+ class DatasetManifest(BaseModel):
131
+ model_config = ConfigDict(extra="forbid")
132
+
133
+ name: str
134
+ version: str
135
+ description: str = ""
136
+ policy: str
137
+ default_context: dict[str, Any] = Field(default_factory=dict)
138
+ case_files: list[str]
139
+ generator: dict[str, Any] = Field(default_factory=dict)
140
+
141
+
142
+ class Dataset(BaseModel):
143
+ model_config = ConfigDict(arbitrary_types_allowed=True)
144
+
145
+ manifest: DatasetManifest
146
+ root: Path
147
+ policy: Policy
148
+ cases: list[BenchmarkCase]
149
+
150
+ @property
151
+ def fingerprint(self) -> str:
152
+ """Content hash of all cases; changes whenever any case changes."""
153
+ h = hashlib.sha256()
154
+ for case in sorted(self.cases, key=lambda c: c.id):
155
+ h.update(json.dumps(case.model_dump(mode="json"), sort_keys=True).encode())
156
+ return h.hexdigest()[:16]
157
+
158
+ def stats(self) -> dict[str, Any]:
159
+ return {
160
+ "unique_cases": len({c.base_id for c in self.cases}),
161
+ "evaluations": len(self.cases),
162
+ "by_expected": dict(Counter(c.expected.value for c in self.cases)),
163
+ "by_dialect": dict(Counter(c.dialect for c in self.cases)),
164
+ "by_category": dict(sorted(Counter(c.category for c in self.cases).items())),
165
+ "by_provenance": dict(Counter(c.provenance.split(":")[0] for c in self.cases)),
166
+ }
167
+
168
+ def filter(
169
+ self,
170
+ *,
171
+ dialect: str | None = None,
172
+ category: str | None = None,
173
+ ids: set[str] | None = None,
174
+ ) -> list[BenchmarkCase]:
175
+ out = self.cases
176
+ if dialect:
177
+ out = [c for c in out if c.dialect == dialect]
178
+ if category:
179
+ out = [c for c in out if c.category == category]
180
+ if ids:
181
+ out = [c for c in out if c.base_id in ids or c.id in ids]
182
+ return out
183
+
184
+
185
+ class DatasetError(ValueError):
186
+ pass
187
+
188
+
189
+ def _expand(raw: RawCase, default_context: dict[str, Any]) -> list[BenchmarkCase]:
190
+ out = []
191
+ for dialect in raw.dialects:
192
+ expected = raw.expected_by_dialect.get(dialect, raw.expected)
193
+ reason = raw.reason_by_dialect.get(dialect, raw.reason)
194
+ if expected is Decision.ALLOW and dialect in raw.expected_by_dialect:
195
+ reason = raw.reason_by_dialect.get(dialect, Reason.NONE)
196
+ classification = raw.classification
197
+ if classification is None or (expected is Decision.ALLOW) != (
198
+ classification is Classification.BENIGN
199
+ ):
200
+ classification = _DEFAULT_CLASSIFICATION[expected]
201
+ out.append(
202
+ BenchmarkCase(
203
+ id=f"{raw.id}@{dialect}",
204
+ base_id=raw.id,
205
+ sql=raw.sql.strip(),
206
+ dialect=dialect,
207
+ role=raw.role,
208
+ context={**default_context, **raw.context},
209
+ expected=expected,
210
+ classification=classification,
211
+ reason=reason,
212
+ category=raw.category,
213
+ tags=raw.tags,
214
+ provenance=raw.provenance,
215
+ explanation=raw.explanation,
216
+ expected_rules=raw.expected_rules,
217
+ )
218
+ )
219
+ return out
220
+
221
+
222
+ def load_case_file(path: Path, default_context: dict[str, Any]) -> list[BenchmarkCase]:
223
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
224
+ defaults = data.get("defaults", {})
225
+ cases: list[BenchmarkCase] = []
226
+ for i, item in enumerate(data.get("cases", [])):
227
+ merged = {**defaults, **item}
228
+ try:
229
+ raw = RawCase.model_validate(merged)
230
+ cases.extend(_expand(raw, default_context))
231
+ except ValueError as exc:
232
+ raise DatasetError(f"{path.name} case #{i} ({item.get('id')}): {exc}") from exc
233
+ return cases
234
+
235
+
236
+ def load_dataset(path: str | Path) -> Dataset:
237
+ """Load a dataset directory (or its ``manifest.yaml``) and validate every case."""
238
+ p = Path(path)
239
+ manifest_path = p if p.is_file() else p / "manifest.yaml"
240
+ root = manifest_path.parent
241
+ manifest = DatasetManifest.model_validate(
242
+ yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
243
+ )
244
+ policy = Policy.from_file((root / manifest.policy).resolve())
245
+ cases: list[BenchmarkCase] = []
246
+ for pattern in manifest.case_files:
247
+ files = sorted(root.glob(pattern))
248
+ if not files:
249
+ raise DatasetError(f"case file pattern {pattern!r} matched nothing")
250
+ for f in files:
251
+ cases.extend(load_case_file(f, manifest.default_context))
252
+ ids = Counter(c.id for c in cases)
253
+ dupes = [i for i, n in ids.items() if n > 1]
254
+ if dupes:
255
+ raise DatasetError(f"duplicate case ids: {dupes[:10]}")
256
+ sql_keys = Counter(
257
+ (c.sql, c.dialect, c.role, json.dumps(c.context, sort_keys=True)) for c in cases
258
+ )
259
+ dup_sql = [k[0][:60] for k, n in sql_keys.items() if n > 1]
260
+ if dup_sql:
261
+ raise DatasetError(f"duplicate (sql, dialect, role, context) cases: {dup_sql[:5]}")
262
+ for c in cases:
263
+ if c.role is not None and c.role not in policy.roles:
264
+ raise DatasetError(f"{c.id}: unknown role {c.role!r}")
265
+ return Dataset(manifest=manifest, root=root, policy=policy, cases=cases)