truequery 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.
- truequery/__init__.py +3 -0
- truequery/analyzers/__init__.py +0 -0
- truequery/analyzers/project_checks.py +220 -0
- truequery/analyzers/sql.py +529 -0
- truequery/checks/__init__.py +0 -0
- truequery/checks/sql.py +1278 -0
- truequery/cli.py +710 -0
- truequery/engine.py +346 -0
- truequery/hook.py +860 -0
- truequery/keylookup.py +135 -0
- truequery/models.py +32 -0
- truequery/project.py +561 -0
- truequery/registry_impl.json +127 -0
- truequery/report.py +343 -0
- truequery/schema.py +206 -0
- truequery/settings.py +239 -0
- truequery/standard/consume/README.md +111 -0
- truequery/standard/consume/adhoc-limit-required.yaml +50 -0
- truequery/standard/consume/aggregate-before-export.yaml +39 -0
- truequery/standard/consume/consumer-cost-guard.yaml +44 -0
- truequery/standard/consume/consumer-reads-marts.yaml +38 -0
- truequery/standard/consume/dashboard-query-budget.yaml +40 -0
- truequery/standard/consume/dashboard-refresh-schedule.yaml +47 -0
- truequery/standard/consume/deterministic-export-order.yaml +44 -0
- truequery/standard/consume/explicit-timezone-consumers.yaml +44 -0
- truequery/standard/consume/explore-answer-carries-reproducible-query.yaml +56 -0
- truequery/standard/consume/explore-caveats-and-unchecked-stated.yaml +52 -0
- truequery/standard/consume/explore-day-boundaries-and-partial-periods.yaml +51 -0
- truequery/standard/consume/explore-experiment-readout-follows-plan.yaml +63 -0
- truequery/standard/consume/explore-freshness-and-coverage-checked.yaml +54 -0
- truequery/standard/consume/explore-inspect-distributions-and-examples.yaml +47 -0
- truequery/standard/consume/explore-join-row-count-checks.yaml +53 -0
- truequery/standard/consume/explore-null-aware-negative-filters.yaml +50 -0
- truequery/standard/consume/explore-question-metric-grain-stated.yaml +55 -0
- truequery/standard/consume/explore-rates-not-counts-across-segments.yaml +51 -0
- truequery/standard/consume/explore-reconcile-to-known-total.yaml +51 -0
- truequery/standard/consume/explore-reuse-governed-metric.yaml +52 -0
- truequery/standard/consume/export-row-cap.yaml +45 -0
- truequery/standard/consume/freshness-displayed.yaml +44 -0
- truequery/standard/consume/no-deep-offset.yaml +38 -0
- truequery/standard/consume/parameterized-dashboard-filters.yaml +38 -0
- truequery/standard/consume/reverse-etl-idempotent-keys.yaml +45 -0
- truequery/standard/consume/reverse-etl-pii-consent.yaml +50 -0
- truequery/standard/consume/reverse-etl-schema-contract.yaml +47 -0
- truequery/standard/dq/README.md +117 -0
- truequery/standard/dq/accuracy-proxy-or-unmeasurable.yaml +45 -0
- truequery/standard/dq/audits-blocking-every-run.yaml +47 -0
- truequery/standard/dq/checks-versioned-with-code.yaml +52 -0
- truequery/standard/dq/completeness-declare-missing-sentinels.yaml +44 -0
- truequery/standard/dq/completeness-fill-rate-thresholds.yaml +45 -0
- truequery/standard/dq/completeness-not-null-required-columns.yaml +60 -0
- truequery/standard/dq/completeness-table-not-empty.yaml +48 -0
- truequery/standard/dq/consistency-cross-column-row-rules.yaml +49 -0
- truequery/standard/dq/consistency-no-orphan-keys.yaml +49 -0
- truequery/standard/dq/consistency-paired-aggregate-reconciliation.yaml +48 -0
- truequery/standard/dq/schema-conformance-checked-on-read.yaml +48 -0
- truequery/standard/dq/unenforced-constraints-are-tested.yaml +51 -0
- truequery/standard/dq/uniqueness-composite-grain.yaml +52 -0
- truequery/standard/dq/uniqueness-key-no-duplicates.yaml +53 -0
- truequery/standard/dq/unit-tests-with-fixtures.yaml +52 -0
- truequery/standard/dq/validity-allowed-value-sets.yaml +46 -0
- truequery/standard/dq/validity-enforce-model-contracts.yaml +51 -0
- truequery/standard/dq/validity-range-format-type-rules.yaml +50 -0
- truequery/standard/gov/README.md +94 -0
- truequery/standard/gov/audit-columns-or-snapshots.yaml +45 -0
- truequery/standard/gov/column-grants-for-pii.yaml +41 -0
- truequery/standard/gov/declared-model-owner.yaml +43 -0
- truequery/standard/gov/description-coverage-for-shared-models.yaml +40 -0
- truequery/standard/gov/erasure-path-documented.yaml +43 -0
- truequery/standard/gov/exposures-registered.yaml +73 -0
- truequery/standard/gov/group-private-access.yaml +41 -0
- truequery/standard/gov/least-privilege-grants.yaml +80 -0
- truequery/standard/gov/lineage-from-declared-sources.yaml +41 -0
- truequery/standard/gov/mesh-public-contracts-only.yaml +41 -0
- truequery/standard/gov/no-bypassrls-roles.yaml +39 -0
- truequery/standard/gov/no-public-privileges.yaml +39 -0
- truequery/standard/gov/no-raw-pii-in-seeds.yaml +41 -0
- truequery/standard/gov/pii-classification-declared.yaml +42 -0
- truequery/standard/gov/production-pii-not-in-dev.yaml +43 -0
- truequery/standard/gov/redacting-views-for-pii.yaml +41 -0
- truequery/standard/gov/retention-declared.yaml +42 -0
- truequery/standard/gov/row-policy-for-sensitive-tables.yaml +38 -0
- truequery/standard/ingest/README.md +100 -0
- truequery/standard/ingest/cap-intake-cost-and-volume.yaml +46 -0
- truequery/standard/ingest/cdc-replica-identity-required.yaml +47 -0
- truequery/standard/ingest/checkpoint-streaming-intake.yaml +42 -0
- truequery/standard/ingest/choose-batch-vs-streaming-deliberately.yaml +45 -0
- truequery/standard/ingest/declare-source-freshness.yaml +80 -0
- truequery/standard/ingest/dedupe-on-natural-key-before-append.yaml +45 -0
- truequery/standard/ingest/handle-schema-drift-explicitly.yaml +45 -0
- truequery/standard/ingest/idempotent-batch-loads.yaml +49 -0
- truequery/standard/ingest/isolate-source-failures.yaml +50 -0
- truequery/standard/ingest/landing-rebuildable-from-source.yaml +43 -0
- truequery/standard/ingest/monitor-cdc-lag-and-slots.yaml +47 -0
- truequery/standard/ingest/pin-connector-and-schema-versions.yaml +42 -0
- truequery/standard/ingest/pin-file-format-options.yaml +42 -0
- truequery/standard/ingest/propagate-deletes-explicitly.yaml +48 -0
- truequery/standard/ingest/quarantine-invalid-rows-at-intake.yaml +56 -0
- truequery/standard/ingest/raw-landing-append-only.yaml +48 -0
- truequery/standard/ingest/reconcile-row-counts-per-load.yaml +43 -0
- truequery/standard/ingest/record-file-lineage-at-intake.yaml +48 -0
- truequery/standard/ingest/reference-sources-not-raw-tables.yaml +42 -0
- truequery/standard/ingest/require-completeness-marker.yaml +50 -0
- truequery/standard/ingest/scan-pii-at-landing.yaml +45 -0
- truequery/standard/ingest/seeds-small-static-only.yaml +43 -0
- truequery/standard/ingest/test-intake-against-fixtures.yaml +46 -0
- truequery/standard/ingest/watermark-and-late-data-policy.yaml +49 -0
- truequery/standard/model/README.md +91 -0
- truequery/standard/model/dbt-contract-enforced.yaml +50 -0
- truequery/standard/model/delete-handling-incremental.yaml +51 -0
- truequery/standard/model/explicit-column-lists.yaml +83 -0
- truequery/standard/model/full-refresh-parity.yaml +49 -0
- truequery/standard/model/grain-preserved-joins.yaml +52 -0
- truequery/standard/model/idempotent-reexec.yaml +48 -0
- truequery/standard/model/incremental-lookback-window.yaml +53 -0
- truequery/standard/model/incremental-watermark-filter.yaml +48 -0
- truequery/standard/model/jinja-macro-determinism.yaml +46 -0
- truequery/standard/model/jinja-no-nested-curlies.yaml +43 -0
- truequery/standard/model/materialization-choice.yaml +51 -0
- truequery/standard/model/merge-source-deduped.yaml +57 -0
- truequery/standard/model/microbatch-event-time.yaml +39 -0
- truequery/standard/model/null-join-keys-excluded.yaml +45 -0
- truequery/standard/model/snapshot-scd2-validity.yaml +45 -0
- truequery/standard/model/snapshot-strategy-choice.yaml +41 -0
- truequery/standard/model/staging-no-business-logic.yaml +44 -0
- truequery/standard/model/surrogate-keys-stable.yaml +37 -0
- truequery/standard/model/unique-key-merge-key.yaml +55 -0
- truequery/standard/ops/README.md +98 -0
- truequery/standard/ops/breaking-change-categorization.yaml +35 -0
- truequery/standard/ops/ci-pr-checks-required.yaml +44 -0
- truequery/standard/ops/ci-slim-builds-only-changed.yaml +42 -0
- truequery/standard/ops/cost-estimate-before-run.yaml +41 -0
- truequery/standard/ops/cost-guardrail-confidence-levels.yaml +39 -0
- truequery/standard/ops/dev-environment-expiry.yaml +42 -0
- truequery/standard/ops/disposable-dev-environments.yaml +42 -0
- truequery/standard/ops/drift-detection-history-first.yaml +42 -0
- truequery/standard/ops/full-refresh-approval.yaml +76 -0
- truequery/standard/ops/model-versioning-breaking-changes.yaml +83 -0
- truequery/standard/ops/plan-preview-before-apply.yaml +39 -0
- truequery/standard/ops/post-deploy-data-validation.yaml +42 -0
- truequery/standard/ops/pre-destructive-snapshot.yaml +35 -0
- truequery/standard/ops/prod-credentials-isolation.yaml +96 -0
- truequery/standard/ops/run-artifact-retention.yaml +41 -0
- truequery/standard/ops/staged-upgrades-with-backup.yaml +39 -0
- truequery/standard/orch/README.md +74 -0
- truequery/standard/orch/concurrency-pools-and-limits.yaml +63 -0
- truequery/standard/orch/dag-ci-parse-and-unit-tests.yaml +70 -0
- truequery/standard/orch/data-aware-scheduling-over-polling.yaml +62 -0
- truequery/standard/orch/declared-asset-lineage.yaml +66 -0
- truequery/standard/orch/deferrable-sensors-with-timeout.yaml +75 -0
- truequery/standard/orch/explicit-catchup-policy.yaml +53 -0
- truequery/standard/orch/explicit-schedule-timezone.yaml +68 -0
- truequery/standard/orch/failure-alert-routing.yaml +100 -0
- truequery/standard/orch/idempotent-backfill-windowing.yaml +68 -0
- truequery/standard/orch/idempotent-retries-with-limits.yaml +79 -0
- truequery/standard/orch/light-dag-parse-no-heavy-top-level.yaml +75 -0
- truequery/standard/orch/no-circular-dependencies.yaml +67 -0
- truequery/standard/orch/partitioned-assets-windowing.yaml +66 -0
- truequery/standard/orch/sla-deadline-alerts.yaml +76 -0
- truequery/standard/orch/task-atomicity-no-partial-writes.yaml +62 -0
- truequery/standard/orch/task-timeout-execution-limit.yaml +70 -0
- truequery/standard/py/README.md +63 -0
- truequery/standard/py/airflow-isolate-task-dependencies.yaml +40 -0
- truequery/standard/py/dagster-asset-declared-dependencies.yaml +47 -0
- truequery/standard/py/dagster-io-manager-not-manual-io.yaml +47 -0
- truequery/standard/py/dagster-resources-for-external-clients.yaml +53 -0
- truequery/standard/py/dbt-python-model-contract.yaml +48 -0
- truequery/standard/py/deterministic-model-outputs.yaml +83 -0
- truequery/standard/py/hook-adapter-connection-lifecycle.yaml +59 -0
- truequery/standard/py/idempotent-task-logic.yaml +55 -0
- truequery/standard/py/no-n-plus-one-queries-in-loops.yaml +46 -0
- truequery/standard/py/pandas-filter-before-collect.yaml +44 -0
- truequery/standard/py/pandas-no-chained-assignment.yaml +41 -0
- truequery/standard/py/pandas-no-row-loop-mutations.yaml +44 -0
- truequery/standard/py/pin-pipeline-dependencies.yaml +82 -0
- truequery/standard/py/sqlmesh-audit-python-outputs.yaml +49 -0
- truequery/standard/py/sqlmesh-python-model-execution-context.yaml +52 -0
- truequery/standard/sem/README.md +97 -0
- truequery/standard/sem/aggregation-type-matches-additivity.yaml +61 -0
- truequery/standard/sem/conversion-metrics-window-bounds.yaml +54 -0
- truequery/standard/sem/count-distinct-uses-entity-key.yaml +52 -0
- truequery/standard/sem/cumulative-metrics-time-spine.yaml +53 -0
- truequery/standard/sem/derived-metrics-compose-from-governed.yaml +52 -0
- truequery/standard/sem/description-and-ownership-per-metric.yaml +50 -0
- truequery/standard/sem/dimension-naming-conventions.yaml +52 -0
- truequery/standard/sem/entity-primary-key-declared.yaml +54 -0
- truequery/standard/sem/join-paths-declared-via-entities.yaml +47 -0
- truequery/standard/sem/metric-filters-declared-not-hidden.yaml +57 -0
- truequery/standard/sem/metric-grain-declaration.yaml +52 -0
- truequery/standard/sem/metric-smoke-test-on-change.yaml +43 -0
- truequery/standard/sem/no-fanout-double-counting.yaml +52 -0
- truequery/standard/sem/no-metric-defined-off-staging.yaml +47 -0
- truequery/standard/sem/null-handling-in-aggregations.yaml +57 -0
- truequery/standard/sem/percentile-metrics-use-approximate.yaml +51 -0
- truequery/standard/sem/ratio-metrics-divide-after-aggregation.yaml +49 -0
- truequery/standard/sem/saved-query-canonical-slices.yaml +56 -0
- truequery/standard/sem/semantic-model-validates.yaml +50 -0
- truequery/standard/sem/single-governed-metric-definition.yaml +68 -0
- truequery/standard/sem/time-dimension-standardized.yaml +51 -0
- truequery/standard/sem/unit-consistency.yaml +54 -0
- truequery/standard/sql/README.md +126 -0
- truequery/standard/sql/accidental-cross-join.yaml +58 -0
- truequery/standard/sql/average-of-ratios.yaml +63 -0
- truequery/standard/sql/between-date-on-timestamp.yaml +74 -0
- truequery/standard/sql/case-without-else-in-aggregate.yaml +67 -0
- truequery/standard/sql/count-star-after-left-join.yaml +67 -0
- truequery/standard/sql/distinct-dedup-bandaid.yaml +59 -0
- truequery/standard/sql/explore-limit-is-not-a-sample.yaml +56 -0
- truequery/standard/sql/explore-profile-filter-columns.yaml +48 -0
- truequery/standard/sql/fanout-join-pre-aggregation.yaml +64 -0
- truequery/standard/sql/group-by-bare-column.yaml +75 -0
- truequery/standard/sql/insert-explicit-column-list.yaml +60 -0
- truequery/standard/sql/integer-division-truncation.yaml +94 -0
- truequery/standard/sql/keyset-pagination.yaml +70 -0
- truequery/standard/sql/left-join-null-rejecting-where.yaml +58 -0
- truequery/standard/sql/limit-requires-order-by.yaml +67 -0
- truequery/standard/sql/natural-join-explicit-keys.yaml +56 -0
- truequery/standard/sql/negative-filter-drops-nulls.yaml +70 -0
- truequery/standard/sql/nondeterministic-merge-keys.yaml +69 -0
- truequery/standard/sql/not-in-nullable-subquery.yaml +57 -0
- truequery/standard/sql/null-equality-comparison.yaml +65 -0
- truequery/standard/sql/random-sampling-no-sort.yaml +48 -0
- truequery/standard/sql/row-number-dedupe-tiebreaker.yaml +67 -0
- truequery/standard/sql/sargable-filter-bare-column.yaml +53 -0
- truequery/standard/sql/set-operation-column-position.yaml +59 -0
- truequery/standard/sql/unguarded-update-delete.yaml +63 -0
- truequery/standard/sql/union-all-default.yaml +70 -0
- truequery/standard/store/README.md +88 -0
- truequery/standard/store/cluster-on-frequent-filters-joins.yaml +44 -0
- truequery/standard/store/compact-small-files-optimize.yaml +44 -0
- truequery/standard/store/declare-not-null-check-constraints.yaml +43 -0
- truequery/standard/store/declare-sort-order-for-range-queries.yaml +44 -0
- truequery/standard/store/enable-change-feed-for-incremental-consumers.yaml +44 -0
- truequery/standard/store/evolve-partitions-by-metadata-not-rewrite.yaml +45 -0
- truequery/standard/store/explicit-physical-types-no-implicit-casts.yaml +43 -0
- truequery/standard/store/full-refresh-by-replace-not-delete-insert.yaml +44 -0
- truequery/standard/store/incremental-watermark-partition-alignment.yaml +40 -0
- truequery/standard/store/no-high-cardinality-partition-column.yaml +47 -0
- truequery/standard/store/partition-expiration-retention-policy.yaml +43 -0
- truequery/standard/store/partition-on-filter-column.yaml +43 -0
- truequery/standard/store/partition-predicate-required.yaml +41 -0
- truequery/standard/store/prefer-auto-liquid-clustering-over-manual-tune.yaml +37 -0
- truequery/standard/store/rewrite-manifests-and-stats-periodically.yaml +37 -0
- truequery/standard/store/single-open-format-per-medallion-layer.yaml +37 -0
- truequery/standard/store/time-travel-retention-explicit.yaml +46 -0
- truequery/standard/store/use-transient-temporary-for-ephemeral.yaml +35 -0
- truequery/standard/store/vacuum-expire-snapshots-with-retention-floor.yaml +42 -0
- truequery/std_registry.py +3310 -0
- truequery-0.1.0.dist-info/METADATA +52 -0
- truequery-0.1.0.dist-info/RECORD +254 -0
- truequery-0.1.0.dist-info/WHEEL +4 -0
- truequery-0.1.0.dist-info/entry_points.txt +3 -0
- truequery-0.1.0.dist-info/licenses/LICENSE +201 -0
- truequery-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Generic SQL analyzer: plain SQL, dbt models, and SQLMesh models.
|
|
2
|
+
|
|
3
|
+
Pipeline: Jinja/SQLMesh-tolerant preprocessing (line-preserving) ->
|
|
4
|
+
per-statement parse isolation -> registry-driven checks -> findings
|
|
5
|
+
with per-mode severity. Anything unparsable becomes a diagnostic, never
|
|
6
|
+
silence. Positions are anchor-located: the pinned sqlglot version
|
|
7
|
+
attaches no node positions, so each rule searches its statement text
|
|
8
|
+
for its own anchor (documented approximation, good to the statement
|
|
9
|
+
and usually the line).
|
|
10
|
+
"""
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
import sqlglot
|
|
14
|
+
|
|
15
|
+
from truequery import std_registry
|
|
16
|
+
from truequery.checks import sql as checks
|
|
17
|
+
|
|
18
|
+
JINJA_PH = "__tq_jinja"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# -- preprocessing (line-preserving) ------------------------------------------
|
|
22
|
+
def _skip_trivia(text, i):
|
|
23
|
+
while i < len(text):
|
|
24
|
+
if text[i].isspace():
|
|
25
|
+
i += 1
|
|
26
|
+
elif text.startswith("--", i):
|
|
27
|
+
end = text.find("\n", i)
|
|
28
|
+
i = len(text) if end == -1 else end + 1
|
|
29
|
+
elif text.startswith("/*", i):
|
|
30
|
+
end = text.find("*/", i + 2)
|
|
31
|
+
i = len(text) if end == -1 else end + 2
|
|
32
|
+
else:
|
|
33
|
+
break
|
|
34
|
+
return i
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strip_model_block(text):
|
|
38
|
+
i = _skip_trivia(text, 0)
|
|
39
|
+
m = re.match(r"(MODEL|AUDIT)\s*\(", text[i:], re.IGNORECASE)
|
|
40
|
+
if not m:
|
|
41
|
+
return text
|
|
42
|
+
depth, i, squote = 0, i + m.start(), False
|
|
43
|
+
while i < len(text):
|
|
44
|
+
ch = text[i]
|
|
45
|
+
if ch == "'" and not squote:
|
|
46
|
+
squote = True
|
|
47
|
+
elif ch == "'" and squote:
|
|
48
|
+
squote = False
|
|
49
|
+
elif not squote:
|
|
50
|
+
if ch == "(":
|
|
51
|
+
depth += 1
|
|
52
|
+
elif ch == ")":
|
|
53
|
+
depth -= 1
|
|
54
|
+
if depth == 0:
|
|
55
|
+
tail = text[i + 1:]
|
|
56
|
+
semi = tail.find(";")
|
|
57
|
+
cut = i + 1 + (semi + 1 if semi != -1 else 0)
|
|
58
|
+
return "".join("\n" if c == "\n" else " " for c in text[:cut]) + text[cut:]
|
|
59
|
+
i += 1
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_MACRO_CALL = re.compile(r"@[A-Za-z_]\w*\(")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def strip_sqlmesh_macros(text):
|
|
67
|
+
"""Blank SQLMesh macro calls (@name(...)) to a `1` placeholder,
|
|
68
|
+
keeping newlines. Linear: jumps from one `@` to the next."""
|
|
69
|
+
out, i = [], 0
|
|
70
|
+
while i < len(text):
|
|
71
|
+
at = text.find("@", i)
|
|
72
|
+
if at == -1:
|
|
73
|
+
out.append(text[i:])
|
|
74
|
+
break
|
|
75
|
+
if not _MACRO_CALL.match(text, at):
|
|
76
|
+
out.append(text[i:at + 1])
|
|
77
|
+
i = at + 1
|
|
78
|
+
continue
|
|
79
|
+
out.append(text[i:at])
|
|
80
|
+
i = at
|
|
81
|
+
depth, j, squote = 0, i, False
|
|
82
|
+
while j < len(text):
|
|
83
|
+
ch = text[j]
|
|
84
|
+
if ch == "'" and not squote:
|
|
85
|
+
squote = True
|
|
86
|
+
elif ch == "'" and squote:
|
|
87
|
+
squote = False
|
|
88
|
+
elif not squote:
|
|
89
|
+
if ch == "(":
|
|
90
|
+
depth += 1
|
|
91
|
+
elif ch == ")":
|
|
92
|
+
depth -= 1
|
|
93
|
+
if depth == 0:
|
|
94
|
+
break
|
|
95
|
+
j += 1
|
|
96
|
+
seg = text[i:j + 1]
|
|
97
|
+
out.append("1" + "".join("\n" if c == "\n" else " " for c in seg[1:]))
|
|
98
|
+
i = j + 1
|
|
99
|
+
return "".join(out)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_REF_RE = re.compile(r"\{\{\s*ref\(\s*['\"]([^'\"\n]+)['\"]\s*\)\s*\}\}")
|
|
103
|
+
_SOURCE_RE = re.compile(
|
|
104
|
+
r"\{\{\s*source\(\s*['\"]([^'\"\n]+)['\"]\s*,\s*['\"]([^'\"\n]+)['\"]\s*\)\s*\}\}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_dbt_refs(text):
|
|
108
|
+
"""Resolve dbt relation builders to plain names before parsing, so
|
|
109
|
+
table references (and contract matching) see real relations:
|
|
110
|
+
{{ ref('orders') }} -> orders, {{ source('shop','orders') }} ->
|
|
111
|
+
shop.orders. Single-line tags only, so line numbers never shift."""
|
|
112
|
+
|
|
113
|
+
def sub_source(match):
|
|
114
|
+
return f"{match.group(1)}.{match.group(2)}"
|
|
115
|
+
|
|
116
|
+
text = _SOURCE_RE.sub(sub_source, text)
|
|
117
|
+
return _REF_RE.sub(r"\1", text)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_JINJA_CLOSE = {"{{": "}}", "{%": "%}", "{#": "#}"}
|
|
121
|
+
_JINJA_MARKERS = ("JINJA_QUERY_BEGIN;", "JINJA_QUERY_BEGIN", "JINJA_END;",
|
|
122
|
+
"JINJA_END")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _jinja_spans(text):
|
|
126
|
+
"""(start, end, opener) for each Jinja tag. Quotes inside a tag are
|
|
127
|
+
respected, so `post_hook="{{ x }}"` doesn't close the outer tag."""
|
|
128
|
+
spans, i, n = [], 0, len(text)
|
|
129
|
+
while True:
|
|
130
|
+
i = text.find("{", i)
|
|
131
|
+
if i == -1 or i + 1 >= n:
|
|
132
|
+
return spans
|
|
133
|
+
opener = text[i:i + 2]
|
|
134
|
+
if opener not in _JINJA_CLOSE:
|
|
135
|
+
i += 1
|
|
136
|
+
continue
|
|
137
|
+
closer, j, quote = _JINJA_CLOSE[opener], i + 2, None
|
|
138
|
+
while j < n:
|
|
139
|
+
ch = text[j]
|
|
140
|
+
if quote:
|
|
141
|
+
if ch == "\\":
|
|
142
|
+
j += 1
|
|
143
|
+
elif ch == quote:
|
|
144
|
+
quote = None
|
|
145
|
+
elif ch in "'\"" and opener != "{#":
|
|
146
|
+
quote = ch
|
|
147
|
+
elif text.startswith(closer, j):
|
|
148
|
+
j += 2
|
|
149
|
+
break
|
|
150
|
+
j += 1
|
|
151
|
+
spans.append((i, min(j, n), opener))
|
|
152
|
+
i = j
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def strip_jinja(text):
|
|
156
|
+
"""Remove Jinja (and SQLMesh MODEL blocks / macros), keeping every
|
|
157
|
+
newline so line numbers still match the source. A line holding only
|
|
158
|
+
tags becomes blank; an inline `{{ ... }}` becomes a placeholder
|
|
159
|
+
identifier so the SQL around it still parses."""
|
|
160
|
+
text = strip_model_block(text)
|
|
161
|
+
text = strip_sqlmesh_macros(text)
|
|
162
|
+
text = _resolve_dbt_refs(text)
|
|
163
|
+
spans = _jinja_spans(text)
|
|
164
|
+
in_tag = bytearray(len(text))
|
|
165
|
+
for start, end, _ in spans:
|
|
166
|
+
in_tag[start:end] = b"\x01" * (end - start)
|
|
167
|
+
out = list(text)
|
|
168
|
+
# Lines whose only content (besides `;`) is Jinja are blanked whole.
|
|
169
|
+
pos = 0
|
|
170
|
+
for line in text.split("\n"):
|
|
171
|
+
end = pos + len(line)
|
|
172
|
+
rest = "".join(c for k, c in enumerate(line) if not in_tag[pos + k])
|
|
173
|
+
if line.strip() in _JINJA_MARKERS or (
|
|
174
|
+
rest.strip() in ("", ";") and line.strip()):
|
|
175
|
+
for k in range(pos, end):
|
|
176
|
+
out[k] = " "
|
|
177
|
+
pos = end + 1
|
|
178
|
+
for start, end, opener in spans:
|
|
179
|
+
if all(out[k] in " \n" for k in range(start, end)):
|
|
180
|
+
continue # already blanked with its line
|
|
181
|
+
# An expression tag becomes one placeholder token with no padding,
|
|
182
|
+
# so `is_{{ name }}_model` stays one identifier; statement and
|
|
183
|
+
# comment tags become spaces. Newlines are kept either way.
|
|
184
|
+
fill = "" if opener == "{{" else " "
|
|
185
|
+
for k in range(start, end):
|
|
186
|
+
if out[k] != "\n":
|
|
187
|
+
out[k] = fill
|
|
188
|
+
if opener == "{{":
|
|
189
|
+
out[start] = JINJA_PH
|
|
190
|
+
return "".join(out)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def strip_comments(text, dialect):
|
|
194
|
+
out, i, n = [], 0, len(text)
|
|
195
|
+
squote = False
|
|
196
|
+
while i < n:
|
|
197
|
+
ch = text[i]
|
|
198
|
+
two = text[i:i + 2]
|
|
199
|
+
if squote:
|
|
200
|
+
out.append(ch)
|
|
201
|
+
if ch == "\\" and i + 1 < n:
|
|
202
|
+
out.append(text[i + 1])
|
|
203
|
+
i += 2
|
|
204
|
+
continue
|
|
205
|
+
if ch == "'":
|
|
206
|
+
squote = False
|
|
207
|
+
i += 1
|
|
208
|
+
elif ch == "'":
|
|
209
|
+
squote = True
|
|
210
|
+
out.append(ch)
|
|
211
|
+
i += 1
|
|
212
|
+
elif two == "--":
|
|
213
|
+
while i < n and text[i] != "\n":
|
|
214
|
+
out.append(" ")
|
|
215
|
+
i += 1
|
|
216
|
+
elif ch == "#" and dialect in ("mysql", "bigquery"):
|
|
217
|
+
while i < n and text[i] != "\n":
|
|
218
|
+
out.append(" ")
|
|
219
|
+
i += 1
|
|
220
|
+
elif two == "/*":
|
|
221
|
+
end = text.find("*/", i + 2)
|
|
222
|
+
end = n if end == -1 else end + 2
|
|
223
|
+
for c in text[i:end]:
|
|
224
|
+
out.append("\n" if c == "\n" else " ")
|
|
225
|
+
i = end
|
|
226
|
+
else:
|
|
227
|
+
out.append(ch)
|
|
228
|
+
i += 1
|
|
229
|
+
return "".join(out)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def split_statements(cleaned):
|
|
233
|
+
parts, depth, cur, start, line = [], 0, [], 1, 1
|
|
234
|
+
squote = False
|
|
235
|
+
for ch in cleaned:
|
|
236
|
+
if ch == "'":
|
|
237
|
+
squote = not squote
|
|
238
|
+
if not squote:
|
|
239
|
+
if ch in "([":
|
|
240
|
+
depth += 1
|
|
241
|
+
elif ch in ")]":
|
|
242
|
+
depth -= 1
|
|
243
|
+
cur.append(ch)
|
|
244
|
+
if ch == "\n":
|
|
245
|
+
line += 1
|
|
246
|
+
if ch == ";" and depth == 0 and not squote:
|
|
247
|
+
parts.append(("".join(cur), start))
|
|
248
|
+
cur, start = [], line
|
|
249
|
+
tail = "".join(cur)
|
|
250
|
+
if tail.strip() and tail.strip().strip(";"):
|
|
251
|
+
parts.append((tail, start))
|
|
252
|
+
return parts
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
ANCHORS = {
|
|
256
|
+
"sql/not-in-nullable-subquery": [r"\bNOT\s+IN\s*\(", r"\bNOT\s*\("],
|
|
257
|
+
"sql/unguarded-update-delete": [r"\bDELETE\b", r"\bUPDATE\b"],
|
|
258
|
+
"sql/accidental-cross-join": [r"\bJOIN\b", ","],
|
|
259
|
+
"sql/left-join-null-rejecting-where": [r"\bWHERE\b"],
|
|
260
|
+
"sql/null-equality-comparison": [r"=\s*NULL", r"<>\s*NULL", r"!=\s*NULL"],
|
|
261
|
+
# Fallbacks for rules whose node is the whole statement.
|
|
262
|
+
"sql/union-all-default": [r"\bUNION\b"],
|
|
263
|
+
"sql/set-operation-column-position": [r"\bUNION\b", r"\bINTERSECT\b",
|
|
264
|
+
r"\bEXCEPT\b"],
|
|
265
|
+
"sql/insert-explicit-column-list": [r"\bINSERT\b"],
|
|
266
|
+
"sql/keyset-pagination": [r"\bOFFSET\b"],
|
|
267
|
+
"sql/limit-requires-order-by": [r"\bLIMIT\b"],
|
|
268
|
+
"sql/group-by-bare-column": [r"\bGROUP\s+BY\b"],
|
|
269
|
+
"sql/row-number-dedupe-tiebreaker": [r"\bROW_NUMBER\s*\("],
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _compact_map(text):
|
|
274
|
+
"""Lowercased whitespace-stripped text + original offsets per char."""
|
|
275
|
+
chars, idx = [], []
|
|
276
|
+
for i, ch in enumerate(text):
|
|
277
|
+
if not ch.isspace():
|
|
278
|
+
chars.append(ch.lower())
|
|
279
|
+
idx.append(i)
|
|
280
|
+
return "".join(chars), idx
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _usable_needle(needle, compact):
|
|
284
|
+
# Tiny needles match everywhere; a needle that IS the statement
|
|
285
|
+
# carries no position information. Both defer to anchors.
|
|
286
|
+
if len(needle) < 4 and not (
|
|
287
|
+
len(needle) == 3 and re.search(r"[^a-z0-9]", needle)):
|
|
288
|
+
return False
|
|
289
|
+
if needle and len(needle) > 0.8 * len(compact):
|
|
290
|
+
return False
|
|
291
|
+
return bool(needle)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class Locator:
|
|
295
|
+
def __init__(self, stmt_text, start_line):
|
|
296
|
+
self.text = stmt_text
|
|
297
|
+
self.start_line = start_line
|
|
298
|
+
# Rules append their slug here when a declared-key lookup
|
|
299
|
+
# suppresses a firing; check_text reports them.
|
|
300
|
+
self.suppressed = []
|
|
301
|
+
# Occurrence indexes are keyed per (rule, node text): the nth
|
|
302
|
+
# firing of the same rule on the same node text points at the
|
|
303
|
+
# nth textual occurrence. A per-rule counter misattributes when
|
|
304
|
+
# a clean earlier occurrence exists (e.g. an unjoined count(*)
|
|
305
|
+
# before the firing one).
|
|
306
|
+
self.used = {}
|
|
307
|
+
self._compact, self._index = _compact_map(stmt_text)
|
|
308
|
+
|
|
309
|
+
def locate(self, rule, node=None):
|
|
310
|
+
# Primary: the parser's own offset for the firing node. This
|
|
311
|
+
# points at the node itself even when an identical clean text
|
|
312
|
+
# occurs earlier (e.g. an unjoined count(*) before the firing
|
|
313
|
+
# one) — text search alone cannot tell those apart.
|
|
314
|
+
off = self._locate_meta(node)
|
|
315
|
+
if off is None:
|
|
316
|
+
sig = None
|
|
317
|
+
if node is not None:
|
|
318
|
+
try:
|
|
319
|
+
sig, _ = _compact_map(node.sql())
|
|
320
|
+
except Exception:
|
|
321
|
+
sig = None
|
|
322
|
+
if sig is not None and not _usable_needle(sig, self._compact):
|
|
323
|
+
sig = None
|
|
324
|
+
if sig is not None:
|
|
325
|
+
nth = self.used.get((rule, sig), 0)
|
|
326
|
+
off = self._locate_text(sig, nth)
|
|
327
|
+
self.used[(rule, sig)] = nth + 1
|
|
328
|
+
if off is None:
|
|
329
|
+
nth = self.used.get((rule, None), 0)
|
|
330
|
+
off = self._locate_anchor(rule, nth)
|
|
331
|
+
self.used[(rule, None)] = nth + 1
|
|
332
|
+
pre = self.text[:off]
|
|
333
|
+
return (self.start_line + pre.count("\n"),
|
|
334
|
+
off - (pre.rfind("\n") + 1) + 1)
|
|
335
|
+
|
|
336
|
+
def _locate_meta(self, node):
|
|
337
|
+
# sqlglot tags only some nodes (functions, leaves) with offsets;
|
|
338
|
+
# synthesized parents (NOT, predicates) often carry none. The
|
|
339
|
+
# minimum over the node and its descendants is the construct's
|
|
340
|
+
# own start — always inside the firing node, never at an
|
|
341
|
+
# earlier clean occurrence of similar text.
|
|
342
|
+
if node is None:
|
|
343
|
+
return None
|
|
344
|
+
try:
|
|
345
|
+
whole, _ = _compact_map(node.sql())
|
|
346
|
+
except Exception:
|
|
347
|
+
return None
|
|
348
|
+
if not _usable_needle(whole, self._compact):
|
|
349
|
+
# The node IS the statement (or is unrenderable): its
|
|
350
|
+
# offsets carry no position information. Defer to text.
|
|
351
|
+
return None
|
|
352
|
+
best, end = None, None
|
|
353
|
+
try:
|
|
354
|
+
nodes = [node] + list(node.walk())
|
|
355
|
+
except Exception:
|
|
356
|
+
nodes = [node]
|
|
357
|
+
for cand in nodes:
|
|
358
|
+
cand = cand[0] if isinstance(cand, tuple) else cand
|
|
359
|
+
meta = getattr(cand, "meta", None) or {}
|
|
360
|
+
start = meta.get("start")
|
|
361
|
+
stop = meta.get("end", start)
|
|
362
|
+
if isinstance(start, int) and 0 <= start < len(self.text):
|
|
363
|
+
best = start if best is None else min(best, start)
|
|
364
|
+
if isinstance(stop, int):
|
|
365
|
+
end = stop if end is None else max(end, stop)
|
|
366
|
+
if best is None:
|
|
367
|
+
return None
|
|
368
|
+
# A node spanning the statement (UNION of two branches, a whole
|
|
369
|
+
# DELETE) carries no position information by itself: defer to
|
|
370
|
+
# the text/anchor path, which handles those shapes.
|
|
371
|
+
if end is not None and end - best > 0.8 * len(self.text):
|
|
372
|
+
return None
|
|
373
|
+
return best
|
|
374
|
+
|
|
375
|
+
def _locate_text(self, needle, nth):
|
|
376
|
+
occ, start = [], 0
|
|
377
|
+
while True:
|
|
378
|
+
hit = self._compact.find(needle, start)
|
|
379
|
+
if hit == -1:
|
|
380
|
+
break
|
|
381
|
+
occ.append(self._index[hit])
|
|
382
|
+
start = hit + 1
|
|
383
|
+
if not occ:
|
|
384
|
+
return None
|
|
385
|
+
return occ[nth] if nth < len(occ) else occ[-1]
|
|
386
|
+
|
|
387
|
+
def _locate_anchor(self, rule, nth):
|
|
388
|
+
pats = ANCHORS.get(rule, [])
|
|
389
|
+
cands = []
|
|
390
|
+
for p in pats:
|
|
391
|
+
cands += [m.start() for m in re.finditer(p, self.text, re.IGNORECASE)]
|
|
392
|
+
cands.sort()
|
|
393
|
+
if nth < len(cands):
|
|
394
|
+
return cands[nth]
|
|
395
|
+
return cands[-1] if cands else 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _parse_diagnostic(start_line, chunk, exc):
|
|
402
|
+
"""One clean agent-facing line + full detail for --json/--verbose.
|
|
403
|
+
|
|
404
|
+
The line names no internals and no statement body; the path is
|
|
405
|
+
attached by the caller (which knows the display path).
|
|
406
|
+
"""
|
|
407
|
+
detail = _ANSI.sub("", str(exc)).replace("\n", " ")
|
|
408
|
+
detail = re.sub(r"\s+", " ", detail).strip()
|
|
409
|
+
message = f"line {start_line}: could not parse (not checked)"
|
|
410
|
+
if "`" in chunk:
|
|
411
|
+
message += (" - set `dialect` in the DATA.md tq-config "
|
|
412
|
+
"if identifiers need one")
|
|
413
|
+
return {"message": message, "detail": detail}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _intdiv_outside_strings(chunk):
|
|
417
|
+
"""Locate `//` outside single-quoted strings (comments and Jinja are
|
|
418
|
+
already stripped). `//` is integer division in every dialect that
|
|
419
|
+
accepts it, and a syntax error elsewhere — either way it needs a
|
|
420
|
+
human, so it reports as a finding, not silence."""
|
|
421
|
+
squote, i = False, 0
|
|
422
|
+
while i < len(chunk):
|
|
423
|
+
ch = chunk[i]
|
|
424
|
+
if ch == "'":
|
|
425
|
+
squote = not squote
|
|
426
|
+
elif not squote and chunk[i:i + 2] == "//":
|
|
427
|
+
pre = chunk[:i]
|
|
428
|
+
return (1 + pre.count("\n"), i - (pre.rfind("\n") + 1) + 1)
|
|
429
|
+
i += 1
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _source_snippet(chunk, start_line, line):
|
|
434
|
+
"""The user's own source line (placeholders named), not normalized SQL."""
|
|
435
|
+
rows = chunk.split("\n")
|
|
436
|
+
idx = line - start_line
|
|
437
|
+
text = rows[idx].strip() if 0 <= idx < len(rows) else ""
|
|
438
|
+
return text.replace(JINJA_PH, "<jinja>")[:160]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _severity(record, setting, mode):
|
|
442
|
+
return setting.get("severity") or record["severity"].get(mode, "warning")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _finding(slug, severity, mode, path, chunk, start_line, line, col,
|
|
446
|
+
why, fix):
|
|
447
|
+
return {"rule": slug, "severity": severity, "mode": mode,
|
|
448
|
+
"evidence": "file", "file": path, "line": line, "col": col,
|
|
449
|
+
"snippet": _source_snippet(chunk, start_line, line),
|
|
450
|
+
"why": why, "fix": fix}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _active_checks(registry, settings, tiers, include_disabled):
|
|
454
|
+
"""(slug, check, record, setting) for every check that runs."""
|
|
455
|
+
active = []
|
|
456
|
+
for slug, check in checks.CHECKS.items():
|
|
457
|
+
record = registry.RECORDS.get(slug)
|
|
458
|
+
if record is None or not record["implemented"]:
|
|
459
|
+
continue
|
|
460
|
+
setting = (settings or {}).get(slug, {})
|
|
461
|
+
if not include_disabled and not setting.get("enabled",
|
|
462
|
+
record["enabled"]):
|
|
463
|
+
continue
|
|
464
|
+
tier = setting.get("tier", record["tier"])
|
|
465
|
+
if tiers is not None and tier not in tiers:
|
|
466
|
+
continue
|
|
467
|
+
active.append((slug, check, record, setting))
|
|
468
|
+
return active
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
INTDIV = "sql/integer-division-truncation"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def check_text(text, dialect=None, path="<input>", mode="build", registry=None,
|
|
475
|
+
include_disabled=False, tiers=None, settings=None):
|
|
476
|
+
"""Run enabled file-level checks. Returns {path, findings,
|
|
477
|
+
diagnostics, verified, key_lookups}. Findings carry rule slug +
|
|
478
|
+
per-mode severity. ``tiers`` restricts to rule tiers (the hook's
|
|
479
|
+
immediate pass); ``settings`` ({slug: {enabled, tier, severity}}
|
|
480
|
+
from truequery.settings.effective) overrides the registry."""
|
|
481
|
+
from truequery import keylookup # imports project, which imports us
|
|
482
|
+
registry = registry or std_registry
|
|
483
|
+
cleaned = strip_comments(strip_jinja(text), dialect or "generic")
|
|
484
|
+
findings, diagnostics, key_lookups = [], [], []
|
|
485
|
+
chunks = split_statements(cleaned)
|
|
486
|
+
# A file with no statements at all (blank, comments or Jinja only)
|
|
487
|
+
# is verified. Anything else must parse: a typo or prose is "not
|
|
488
|
+
# checked", never "clean".
|
|
489
|
+
active = _active_checks(registry, settings, tiers, include_disabled)
|
|
490
|
+
ctx = checks.CheckContext(dialect=dialect, path=path, mode=mode,
|
|
491
|
+
keys=keylookup.declared_keys_for(path))
|
|
492
|
+
for chunk, start_line in chunks:
|
|
493
|
+
try:
|
|
494
|
+
stmts = sqlglot.parse(chunk, read=dialect or None)
|
|
495
|
+
except Exception as exc:
|
|
496
|
+
# A parse failure is never a silent pass: the chunk was not
|
|
497
|
+
# checked, so the file is unverified even when a `//`
|
|
498
|
+
# finding is salvaged from it.
|
|
499
|
+
intdiv = _intdiv_outside_strings(chunk)
|
|
500
|
+
salvage = [a for a in active if a[0] == INTDIV]
|
|
501
|
+
if intdiv is not None and salvage:
|
|
502
|
+
_, _, record, setting = salvage[0]
|
|
503
|
+
line = start_line + intdiv[0] - 1
|
|
504
|
+
findings.append(_finding(
|
|
505
|
+
INTDIV, _severity(record, setting, mode), mode, path,
|
|
506
|
+
chunk, start_line, line, intdiv[1],
|
|
507
|
+
"integer division truncates (5 // 2 = 2)",
|
|
508
|
+
"use / with a decimal operand if a fraction is meant"))
|
|
509
|
+
rows = chunk.split("\n")
|
|
510
|
+
first = next((i for i, row in enumerate(rows) if row.strip()), 0)
|
|
511
|
+
diagnostics.append(_parse_diagnostic(start_line + first, chunk,
|
|
512
|
+
exc))
|
|
513
|
+
continue
|
|
514
|
+
for stmt in stmts:
|
|
515
|
+
if stmt is None:
|
|
516
|
+
continue
|
|
517
|
+
loc = Locator(chunk, start_line)
|
|
518
|
+
for slug, check, record, setting in active:
|
|
519
|
+
for node, why, fix in check(stmt, loc, ctx):
|
|
520
|
+
line, col = loc.locate(slug, node)
|
|
521
|
+
findings.append(_finding(
|
|
522
|
+
slug, _severity(record, setting, mode), mode, path,
|
|
523
|
+
chunk, start_line, line, col, why, fix))
|
|
524
|
+
for suppressed in loc.suppressed:
|
|
525
|
+
if suppressed not in key_lookups:
|
|
526
|
+
key_lookups.append(suppressed)
|
|
527
|
+
findings.sort(key=lambda f: (f["line"], f["col"], f["rule"]))
|
|
528
|
+
return {"path": path, "findings": findings, "diagnostics": diagnostics,
|
|
529
|
+
"verified": not diagnostics, "key_lookups": key_lookups}
|
|
File without changes
|