Skip to content

#139: Snowflake projection-subquery sample shape - #142

Merged
wjduenow merged 10 commits into
devfrom
feature/139-snowflake-sample-shape
May 27, 2026
Merged

#139: Snowflake projection-subquery sample shape#142
wjduenow merged 10 commits into
devfrom
feature/139-snowflake-sample-shape

Conversation

@wjduenow

@wjduenow wjduenow commented May 27, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #139 — Snowflake sample-mode prune emitted MOD(ABS(HASH(*)), n) / ORDER BY ABS(HASH(*)), which Snowflake rejects (002079: HASH(*) is valid only in the SELECT projection). Until now Snowflake prune worked only with prune.scope: full.

Changes

  • Dialect gains two structural fields — sample_hash_in_projection: bool (BigQuery False, Snowflake True) and sample_hash_alias: str — so the sample-SQL shape is dialect-driven, never name-branched.
  • warehouse/_sample_sql.render_sample_select (new shared helper) renders the inline form (BigQuery, byte-identical to before) or the projection-subquery form for Snowflake:
    SELECT * EXCLUDE (_sf_sample_hash) FROM (SELECT t.*, ABS(HASH(*)) AS _sf_sample_hash FROM <src> AS t) WHERE MOD(_sf_sample_hash, b) < 1 [AND <pf>] [ORDER BY _sf_sample_hash] LIMIT n
  • Wired into the prune compiler sample CTE and SnowflakeAdapter.sample_rows / materialise_sample. SELECT * EXCLUDE keeps the hash column out of returned rows and the materialised temp table.
  • BigQuery's adapter is untouched; the 11 BigQuery compiled-SQL snapshot fixtures stay byte-identical (regression gate). The 5 Snowflake *_sample.sql snapshots were regenerated.

Testing

  • Offline: 2394 passed, pyright 0 errors, ruff/ruff format clean; offline -m snowflake 33 passed / 4 live-skipped (snapshots + sqlglot parse guards on the new EXCLUDE form).
  • Live Snowflake (certification / merge gate): PASSEDtest_snowflake_prune_live.py::test_prune_drops_always_passes_not_null_live_materialised_sample ran green against a real warehouse (scope=sample + materialised), dropping the always-passes test. This certifies DEC-006 and resolves DEC-004 in favour of the primary form (Snowflake accepts ORDER BY of a SELECT * EXCLUDE-d column — no fallback needed).

Compounding Update

  • .claude/rules/warehouse-adapters.md + prune-engine.md: documented the two new Dialect fields + the projection-subquery shape; marked the live-harness finding bd_1-scaffolding-cdp FIXED; captured the generalised lesson (a single inline SQL-fragment string can't express a dialect's clause-POSITION constraint — add a structural Dialect field + shared renderer; only a live-gated test certifies vendor acceptance).
  • docs/warehouse-adapter-ops.md: "Known limitations" updated — HASH(*) shape bug fixed, materialised sample-mode works; oneshot row-count seam (bd_1-scaffolding-tft) noted as a separate open bug.

Plan: plans/super/139-snowflake-sample-shape.md. Beads epic bd_1-scaffolding-kay (closed).

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically identifies the primary change: implementing a projection-subquery SQL shape for Snowflake sample mode (issue #139).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@wjduenow
wjduenow marked this pull request as ready for review May 27, 2026 16:55
wjduenow and others added 8 commits May 27, 2026 09:56
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…der_sample_select helper

Add sample_hash_in_projection / sample_hash_alias to the Dialect frozen
dataclass (BigQuery/Postgres keep inline defaults; SNOWFLAKE_DIALECT sets
projection=True). New stateless warehouse-layer helper
render_sample_select switches inline-vs-projection on the boolean flag
only (never dialect.name): inline reproduces the prune compiler's current
sample-CTE body byte-for-byte; projection-subquery computes HASH(*) in an
inner projection and references the alias in WHERE/ORDER BY with
SELECT * EXCLUDE.

Traces to DEC-001/002/003/004.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r_sample_select + regen Snowflake snapshots

Replace _render_sample_cte's inline hash-mod string-building with a call to
the shared signalforge.warehouse._sample_sql.render_sample_select helper
(order_by_hash=False; the compiler CTE has no ORDER BY). Partition predicate
stays rendered by the compiler's own _render_partition_filter and is passed as
extra_where. Switches on the boolean Dialect.sample_hash_in_projection — no
dialect.name branch, no warehouse-SDK import (import-guard green).

BigQuery (inline) output is byte-identical — top-level compiled_sql/*.sql
fixtures unchanged (the regression gate). The five Snowflake *_sample.sql
fixtures regenerated to the projection-subquery form
(SELECT * EXCLUDE (_sf_sample_hash) FROM (SELECT t.*, ABS(HASH(*)) AS
_sf_sample_hash ...) WHERE MOD(_sf_sample_hash, n) < 1) so HASH(*) is computed
in the projection, never a predicate. sqlglot snowflake-dialect parse guard
passes on the new form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…aterialise_sample use projection-subquery shape

Replace the inline MOD(ABS(HASH(*)), n) < 1 + ORDER BY ABS(HASH(*)) building
in SnowflakeAdapter.sample_rows and materialise_sample with the shared
render_sample_select(..., order_by_hash=True) helper (US-001). Snowflake's
HASH(*) is invalid in a WHERE/ORDER BY predicate (002079); the helper's
projection-subquery branch computes the hash once in an inner
SELECT t.*, ABS(HASH(*)) AS _sf_sample_hash and the outer clauses reference
the alias, with SELECT * EXCLUDE (_sf_sample_hash) stripping the helper column
so returned rows / the materialised temp table carry only source columns.

Partition filters stay rendered by the adapter's own _render_partition_filter
and pass to the helper as extra_where (no name branch, no hard-coded HASH(*)).
Update the fakesnow/sqlglot adapter guards plus the test_snowflake_sampling
and test_snowflake_materialise unit assertions to the new shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… e2e at scope=sample + 5-surface docs

Flip test_snowflake_prune_live.py to scope=sample + sample_strategy=materialised
(exercising the #139 projection-subquery CTAS); rename to
test_prune_drops_always_passes_not_null_live_materialised_sample. Two-gate
discipline (marker + runtime _skip_reason) preserved; self-skips cleanly offline.
DEC-004 primary/fallback note added to the module docstring. 5-surface graduation:
warehouse-adapter-ops.md (remove HASH(*)-in-predicate limitation; materialised
sample-mode now works), .claude/rules/warehouse-adapters.md (new Dialect fields +
mark bd_1-scaffolding-cdp FIXED), .claude/rules/prune-engine.md (compiler dialect
field list + render_sample_select delegation).

Live certification still pending (maintainer-run with SF_RUN_SNOWFLAKE=1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- harden the name-agnostic helper test with a symmetric inline-direction
  assertion (pins both branches against a dialect.name-based dispatch)
- correct stale docstring/comment in test_e2e_snowflake_smoke.py: the
  HASH(*)-in-WHERE/ORDER-BY shape bug is FIXED by #139 for both sample
  strategies; the remaining scope=full requirement here is the read-only
  SNOWFLAKE_SAMPLE_DATA share (materialised) + oneshot's open row-count
  seam (bd_1-scaffolding-tft), not the shape bug

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ure dialect-shape lesson

- prune-engine.md § "Adding a new vendor dialect": capture the generalised
  #139 lesson (a single inline SQL-fragment string can't express a clause-
  POSITION constraint; when the SQL *shape* differs, add a structural Dialect
  field + shared renderer; sqlglot parses but cannot certify warehouse
  acceptance, so the live-gated test is the real merge gate)
- plans/super/139: add Outcome note (6 stories landed; QG 4 passes fixed
  E501 + stale e2e docstring; offline green 2394 passed / pyright 0 /
  -m snowflake 33 passed,4 live-skipped; DEC-006 live cert PENDING/maintainer)
- verified US-004's rule/doc graduation already complete + coherent (no
  re-edit needed there)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… form confirmed)

test_prune_drops_always_passes_not_null_live_materialised_sample passed
against a real Snowflake warehouse (1 passed in 14.78s) — the materialised
scope=sample path executes the SELECT * EXCLUDE projection-subquery CTAS and
drops the always-passes test. Snowflake accepts ORDER BY of an EXCLUDE-d
column, so the primary form stands (no DEC-004 fallback needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements the Snowflake deterministic sampling fix for issue #139 by moving HASH(*) into a projection subquery and sharing that sample SQL shape between prune compilation and Snowflake adapter sampling paths.

Changes:

  • Adds dialect-level sample shape fields and a shared render_sample_select helper.
  • Wires Snowflake sample_rows, materialise_sample, and prune sample CTE generation to the new projection-subquery form.
  • Updates Snowflake SQL fixtures, tests, live-test docs, and operational guidance for the fixed materialised sample path.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/signalforge/warehouse/_sample_sql.py Adds shared deterministic sample SELECT renderer.
src/signalforge/warehouse/models.py Adds dialect fields for projection-based sample hashing.
src/signalforge/warehouse/adapters/snowflake.py Uses shared renderer for Snowflake sample queries and CTAS.
src/signalforge/prune/compiler.py Uses shared renderer for sample CTE bodies.
tests/warehouse/test_sample_sql.py Adds unit tests for sample SQL rendering shapes.
tests/warehouse/test_models.py Pins new dialect field defaults and Snowflake values.
tests/warehouse/test_snowflake_sampling.py Updates Snowflake sample SQL expectations.
tests/warehouse/test_snowflake_materialise.py Updates materialised CTAS SQL expectations.
tests/warehouse/test_snowflake_adapter_fakesnow.py Updates fakesnow/sqlglot parse guards.
tests/warehouse/test_snowflake_prune_live.py Switches live prune e2e to materialised sample mode.
tests/prune/test_compiler.py Updates Snowflake compiler guards for projection-subquery fixtures.
tests/fixtures/prune/compiled_sql/snowflake/accepted_values_sample.sql Regenerates Snowflake sample fixture.
tests/fixtures/prune/compiled_sql/snowflake/custom_sql_sample.sql Regenerates Snowflake sample fixture.
tests/fixtures/prune/compiled_sql/snowflake/not_null_sample.sql Regenerates Snowflake sample fixture.
tests/fixtures/prune/compiled_sql/snowflake/relationships_sample.sql Regenerates Snowflake sample fixture.
tests/fixtures/prune/compiled_sql/snowflake/unique_sample.sql Regenerates Snowflake sample fixture.
tests/cli/test_e2e_snowflake_smoke.py Updates Snowflake smoke-test rationale.
docs/warehouse-adapter-ops.md Updates Snowflake sampling operations guidance.
.claude/rules/warehouse-adapters.md Records adapter convention changes.
.claude/rules/prune-engine.md Records compiler/dialect convention changes.
plans/super/139-snowflake-sample-shape.md Adds implementation plan and outcome record.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/warehouse/test_snowflake_prune_live.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
src/signalforge/warehouse/_sample_sql.py (1)

71-89: ⚡ Quick win

Fail fast on invalid sample_bucket/sample_size inputs.

sample_bucket <= 0 can produce invalid MOD(..., 0) SQL, and non-positive sample_size yields invalid/useless LIMIT values. Guard early with a clear exception.

Proposed patch
 def render_sample_select(
@@
 ) -> str:
@@
-    expr = dialect.sample_row_hash_expr
+    if sample_bucket <= 0:
+        raise ValueError("sample_bucket must be > 0")
+    if sample_size <= 0:
+        raise ValueError("sample_size must be > 0")
+
+    expr = dialect.sample_row_hash_expr
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/signalforge/warehouse/_sample_sql.py` around lines 71 - 89, Add early
validation for the sampling inputs: before computing expr =
dialect.sample_row_hash_expr (i.e., at the start of the function that builds
this SQL), check that sample_bucket > 0 and sample_size > 0 and raise a
ValueError with a clear message if not; this prevents generating invalid SQL
like MOD(..., 0) or nonsensical LIMIT values when sample_bucket or sample_size
are non-positive and keeps the rest of the logic (the branches using
dialect.sample_hash_in_projection, alias, where_sql, order_sql, and the final
RETURNs) unchanged.
src/signalforge/warehouse/adapters/snowflake.py (1)

76-76: ⚡ Quick win

Use the warehouse public import surface for the new helper.

Line 76 imports from a private module path. Please import render_sample_select via the signalforge.warehouse public API and re-export it there if needed.

Suggested change
-from signalforge.warehouse._sample_sql import render_sample_select
+from signalforge.warehouse import render_sample_select

As per coding guidelines: "Package imports: import from the public API surface (re-exported names from subpackage __init__.py files) rather than private submodule paths."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/signalforge/warehouse/adapters/snowflake.py` at line 76, The import in
signalforge.warehouse.adapters.snowflake.py uses a private module path for
render_sample_select; change it to import render_sample_select from the package
public API (from signalforge.warehouse import render_sample_select) and if
render_sample_select is not already re-exported, add it to
signalforge.warehouse.__init__.py's exports so the symbol is available from the
public surface; update any references in snowflake.py to use the imported
render_sample_select.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/warehouse-adapter-ops.md`:
- Around line 614-616: The paragraph contains a false heading token "`#139`"
that triggers markdownlint MD018; change the literal "`#139 projection-subquery
shape`" to plain prose such as "issue `#139` projection-subquery shape" (or "issue
`#139`" + the rest) so the text no longer begins with an ATX heading marker;
update the occurrence near the `_sf_sample_hash` example in the
materialised-sample CTAS description.

In `@plans/super/139-snowflake-sample-shape.md`:
- Around line 121-127: Markdown fenced code blocks for the two SQL examples are
missing language identifiers (triggering markdownlint MD040); update the opening
backtick fences for the block starting with "SELECT * FROM <table_sql> AS t
WHERE MOD(<hash_expr>..." and the block starting with "SELECT * EXCLUDE
(<alias>) FROM (SELECT t.*, <hash_expr> AS <alias>..." to include "sql" (i.e.,
change ``` to ```sql) so both blocks are properly tagged as SQL.

In `@src/signalforge/prune/compiler.py`:
- Line 74: The import is reaching into a private module; change the import of
render_sample_select to come from the package public API (e.g., import
render_sample_select from signalforge.warehouse) and, if that symbol is not
currently re-exported, add render_sample_select to the warehouse package
__init__.py exports so the name is available from the public surface; update the
import in src/signalforge/prune/compiler.py to use that public import path
referencing render_sample_select.

In `@src/signalforge/warehouse/_sample_sql.py`:
- Line 41: The import currently uses a private submodule path; change the import
to use the package public API by importing Dialect from the warehouse package
(i.e., replace the existing "from signalforge.warehouse.models import Dialect"
with an import from "signalforge.warehouse" so the symbol Dialect is imported
from the package's public surface), ensuring other references to Dialect in this
module remain unchanged.

In `@tests/warehouse/test_sample_sql.py`:
- Around line 19-24: The test imports are using private module paths; change
them to import the public re-exports from the subpackage root by replacing
imports of render_sample_select, BIGQUERY_DIALECT, SNOWFLAKE_DIALECT and Dialect
from signalforge.warehouse._sample_sql and signalforge.warehouse.models to
import those same symbols directly from signalforge.warehouse so the test uses
the package public API surface (reference symbols: render_sample_select,
BIGQUERY_DIALECT, SNOWFLAKE_DIALECT, Dialect).

---

Nitpick comments:
In `@src/signalforge/warehouse/_sample_sql.py`:
- Around line 71-89: Add early validation for the sampling inputs: before
computing expr = dialect.sample_row_hash_expr (i.e., at the start of the
function that builds this SQL), check that sample_bucket > 0 and sample_size > 0
and raise a ValueError with a clear message if not; this prevents generating
invalid SQL like MOD(..., 0) or nonsensical LIMIT values when sample_bucket or
sample_size are non-positive and keeps the rest of the logic (the branches using
dialect.sample_hash_in_projection, alias, where_sql, order_sql, and the final
RETURNs) unchanged.

In `@src/signalforge/warehouse/adapters/snowflake.py`:
- Line 76: The import in signalforge.warehouse.adapters.snowflake.py uses a
private module path for render_sample_select; change it to import
render_sample_select from the package public API (from signalforge.warehouse
import render_sample_select) and if render_sample_select is not already
re-exported, add it to signalforge.warehouse.__init__.py's exports so the symbol
is available from the public surface; update any references in snowflake.py to
use the imported render_sample_select.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0799e9c7-9667-4d24-a7ab-7f0288c4ba5d

📥 Commits

Reviewing files that changed from the base of the PR and between 1eef99c and 09ac8cc.

📒 Files selected for processing (21)
  • .claude/rules/prune-engine.md
  • .claude/rules/warehouse-adapters.md
  • docs/warehouse-adapter-ops.md
  • plans/super/139-snowflake-sample-shape.md
  • src/signalforge/prune/compiler.py
  • src/signalforge/warehouse/_sample_sql.py
  • src/signalforge/warehouse/adapters/snowflake.py
  • src/signalforge/warehouse/models.py
  • tests/cli/test_e2e_snowflake_smoke.py
  • tests/fixtures/prune/compiled_sql/snowflake/accepted_values_sample.sql
  • tests/fixtures/prune/compiled_sql/snowflake/custom_sql_sample.sql
  • tests/fixtures/prune/compiled_sql/snowflake/not_null_sample.sql
  • tests/fixtures/prune/compiled_sql/snowflake/relationships_sample.sql
  • tests/fixtures/prune/compiled_sql/snowflake/unique_sample.sql
  • tests/prune/test_compiler.py
  • tests/warehouse/test_models.py
  • tests/warehouse/test_sample_sql.py
  • tests/warehouse/test_snowflake_adapter_fakesnow.py
  • tests/warehouse/test_snowflake_materialise.py
  • tests/warehouse/test_snowflake_prune_live.py
  • tests/warehouse/test_snowflake_sampling.py

Comment thread docs/warehouse-adapter-ops.md Outdated
Comment thread plans/super/139-snowflake-sample-shape.md Outdated
Comment thread src/signalforge/prune/compiler.py
Comment thread src/signalforge/warehouse/_sample_sql.py
Comment thread tests/warehouse/test_sample_sql.py
- test_snowflake_prune_live.py: DEC-004 note now records the live cert
  RESOLVED the ORDER-BY-of-EXCLUDE-d-column question in favour of the
  primary form (Copilot: stale "unresolved decision point" docstring)
- docs/warehouse-adapter-ops.md: reword so the line no longer starts with
  "#139" (CodeRabbit/markdownlint MD heading false-trigger)
- plans/super/139: add `sql` language tags to two fenced blocks (CodeRabbit)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wjduenow

Copy link
Copy Markdown
Owner Author

PR Review Summary

Live Snowflake certification passed (test_prune_drops_always_passes_not_null_live_materialised_sample, 1 passed in 14.78s against a real warehouse), so DEC-004 resolved in favour of the primary ORDER BY <EXCLUDE-d col> form and DEC-006 is certified. Review feedback addressed below.

Fixed (3 items)

File Issue Commit
tests/warehouse/test_snowflake_prune_live.py Stale docstring framed the ORDER BY/EXCLUDE interaction as an unresolved live-run decision point; now records it RESOLVED (primary form live-certified) 2bdc6f4
docs/warehouse-adapter-ops.md Paragraph line began with #139, which markdownlint reads as a heading — reworded to "projection-subquery shape from #139" 2bdc6f4
plans/super/139-snowflake-sample-shape.md Two fenced code blocks lacked a language tag — added sql (all fences in the doc now tagged) 2bdc6f4

False Positives (3 items)

File Suggestion Why it's correct as-is
src/signalforge/prune/compiler.py:74 Import render_sample_select from the public API surface The compiler already imports the private warehouse._sql_safety module (:75, :607) and Dialect from warehouse.models (:86). _sample_sql is a deliberately _-prefixed internal helper (CLAUDE.md: internals are not public contract), peer to _sql_safety. Promoting it to __all__ would commit an internal helper to the public API for no caller benefit and diverge from precedent.
src/signalforge/warehouse/_sample_sql.py:41 Import Dialect from the subpackage public API Dialect is defined in warehouse.models; the whole codebase (incl. the compiler at :86) imports it from there. Matching the definition module keeps this consistent with its consumers.
tests/warehouse/test_sample_sql.py:24 Switch test imports to the public API surface Matches the peer warehouse-test convention (test_models.py, test_compiler.py import from warehouse.models).

All 6 review threads resolved.

@wjduenow wjduenow changed the title #139: Snowflake projection-subquery sample shape (plan) #139: Snowflake projection-subquery sample shape May 27, 2026
@wjduenow
wjduenow merged commit d8db3f2 into dev May 27, 2026
6 checks passed
@wjduenow
wjduenow deleted the feature/139-snowflake-sample-shape branch May 27, 2026 18:18
@coderabbitai coderabbitai Bot mentioned this pull request May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants