Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
249586f
fix(types): refuse a non-boolean unsigned when the type is rendered
vistart Oct 11, 2026
cf6b05f
refactor(snowflake): read expression fields directly instead of probi…
vistart Oct 11, 2026
e23e55b
refactor(snowflake): follow the statement and expression renames
vistart Oct 14, 2026
cf2f9a6
refactor(snowflake): migrate constraints onto the two-level branch nodes
vistart Oct 14, 2026
a54ee52
fix(snowflake): converge the constraint switches onto the (self) prob…
vistart Oct 14, 2026
6d93472
refactor(snowflake): split the wildcard by grammar position
vistart Oct 14, 2026
76a2581
ci: point testsuite at the matching branch
vistart Oct 14, 2026
28ca1fb
docs: follow the value-family rename into the coding-standard example
vistart Oct 14, 2026
e42e1f9
docs(snowflake): carry the renames into the README and docs
vistart Oct 14, 2026
59f9731
refactor(expression): follow the core node and formatter renames
vistart Oct 16, 2026
8e27ba4
refactor(serialization): register expression classes through the inde…
vistart Oct 16, 2026
bddbeb1
refactor(snowflake): read declared fields instead of probing with get…
vistart Oct 17, 2026
23c689c
refactor(snowflake): follow the kind whitelist that replaced the clas…
vistart Oct 18, 2026
0c50df8
feat(snowflake): adopt the typed value nodes with Snowflake literal s…
vistart Oct 18, 2026
4bcb67a
fix(snowflake): follow the classed function entry points and the bare…
vistart Oct 18, 2026
11e971f
fix(snowflake): name the base options the MV statement forwards
vistart Oct 18, 2026
8a392a1
test(snowflake): reconcile the roundtrip matrix with the value and fu…
vistart Oct 18, 2026
c220c56
refactor(snowflake): follow the literal architecture the core settled
vistart Oct 20, 2026
a1f5a2e
refactor(snowflake): audit the kinds this backend overrides and owns
vistart Oct 20, 2026
a3d339f
refactor(snowflake): follow the rules the org settled for kinds and o…
vistart Oct 20, 2026
faee4ef
test(snowflake): move live-connection checks out of expression tests
vistart Oct 20, 2026
5c1b0de
test(snowflake): reconcile the roundtrip matrix with the plain-JSON a…
vistart Oct 20, 2026
3e6f679
refactor(snowflake): let the node name the sequence value and the opt…
vistart Oct 20, 2026
b788b68
fix(snowflake): follow the chain-free nodes and the vendored parse sweep
vistart Oct 20, 2026
8a9d386
fix(snowflake): resolve the expression plugin from the path CI arranges
vistart Oct 20, 2026
0b9d423
fix(ci): put tests on PYTHONPATH so the local plugin resolves on 3.8
vistart Oct 20, 2026
6ed3888
test(snowflake): drop the codec pins the core fix retired
vistart Oct 20, 2026
44c1f81
chore: ignore .worktree/, the branch toolchain directories
vistart Oct 21, 2026
5a0b17b
fix(snowflake): escape a table comment with the dialect's own policy
vistart Oct 21, 2026
706b380
fix(snowflake): the dialect surface, and the type positions it decides
vistart Oct 22, 2026
4715dd7
refactor(expression)!: follow core's statement rename
vistart Oct 22, 2026
9528d8a
refactor(expression)!: name statement nodes, kinds and formatters alike
vistart Oct 23, 2026
fe77fd7
refactor(backend)!: call the core helpers by their public names
vistart Oct 23, 2026
527ec7c
fix(introspection): take the cache from the manager, not the mixin
vistart Oct 23, 2026
044c45f
feat(introspection)!: account objects as expression statements
vistart Oct 23, 2026
0c59b38
docs(cli): say what introspect can ask about
vistart Oct 24, 2026
e8409e4
feat(introspection): declare SQL Server's standard catalogue capabili…
vistart Oct 24, 2026
cc04295
refactor(cli): drop available_introspect_types and --introspect-schema
vistart Oct 24, 2026
cdaa8eb
refactor(snowflake)!: drop the underscores from the introspection con…
vistart Oct 25, 2026
879b4f7
feat(cli)!: the introspect table aggregate is gone
vistart Oct 26, 2026
c7043b3
test(cli): pin every introspect type and serialise each output
vistart Oct 27, 2026
8c49645
fix(snowflake): keep the table kind the catalogue named
vistart Oct 27, 2026
d0b2a60
feat(cli): one table's storage facts, asked as one table's storage facts
vistart Oct 30, 2026
8d0befd
feat(cli): a catalogue family takes an optional name
vistart Oct 30, 2026
befe247
docs(snowflake): why there is no status command
vistart Oct 30, 2026
f5d1ad1
ci: install core from the release branch now that #127 has merged
vistart Oct 30, 2026
4129e41
docs(changelog): the fragments for #7
vistart Oct 31, 2026
5c31cec
docs(changelog): the carried fragments belong to this pull request
vistart Oct 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/code_style.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ class DefaultCustomTimestampMixin(CustomTimestampMixin):

1. **Proper Inheritance**:
- All expression classes must inherit from `BaseExpression`
- Value expressions inherit from `SQLValueExpression`
- Value expressions inherit from `SQLValue`
- Predicate expressions inherit from `SQLPredicate`
- Use appropriate mixins for functionality (e.g., `ArithmeticMixin`, `ComparisonMixin`)

Expand Down Expand Up @@ -691,7 +691,7 @@ from . import bases, mixins
if TYPE_CHECKING: # pragma: no cover
from ..dialect import SQLDialectBase

class MyExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValueExpression):
class MyExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValue):
"""Represents a custom SQL expression."""
def __init__(self, dialect: "SQLDialectBase", value: str):
super().__init__(dialect)
Expand Down
15 changes: 12 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,21 @@ jobs:
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord
pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite
pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates#egg=rhosocial-activerecord-testsuite

- name: Install package
run: |
pip install -e . --no-deps

- name: Run tests with coverage (unit only, no Snowflake instance)
# PYTHONPATH=src:tests puts the repository-local plugin
# (``-p plugins.expression_xdist``, registered in pyproject.toml) and
# the scenario providers on the path. pytest resolves ``-p`` before the
# ``pythonpath`` ini option is applied on older 8.x releases, so the
# environment variable -- which is how the sibling backends do it --
# is the reliable spelling rather than a second path mechanism.
run: |
export PYTHONPATH=src:tests
pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow --tb=short
coverage run --source=src -m pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow --tb=short
coverage xml
Expand Down Expand Up @@ -78,14 +85,15 @@ jobs:
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord
pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite
pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates#egg=rhosocial-activerecord-testsuite

- name: Install package
run: |
pip install -e . --no-deps

- name: Run tests (unit only, no Snowflake instance)
run: |
export PYTHONPATH=src:tests
pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow --tb=short

test-free-threaded:
Expand All @@ -110,12 +118,13 @@ jobs:
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord
pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite
pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates#egg=rhosocial-activerecord-testsuite

- name: Install package
run: |
pip install -e . --no-deps

- name: Run tests (unit only, no Snowflake instance)
run: |
export PYTHONPATH=src:tests
pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow --tb=short
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,8 @@ tests/config/snowflake_scenarios.yml

# Integration tests (contain local development paths and credentials)
tests/rhosocial/activerecord_snowflake_test/feature/backend/test_backend_integration.py
# Worktrees for feature/* and fix/* branches. Never tracked: a `git add -A`
# from the repo root would commit each branch's whole toolchain -- the venv, the
# build directory and the test artifacts -- and the directory disappears when the
# branch merges, so the history would keep a second copy of the source tree.
.worktree/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ This project is under active development. Key features implemented:
- **pyformat placeholders**: `%s` style (snowflake-connector-python uses pyformat)
- **PAT authentication**: Programmatic Access Tokens (JWT) for CI/automation
- **Warehouse lifecycle**: Virtual warehouses auto-suspend; consider `client_session_keep_alive=True`
- **No `status` subcommand**: this backend has no status introspector (no `AbstractStatusIntrospector` implementation under `introspection/`), so the CLI command list has no `status` — that is an accurate reflection of the library, not a missing wire-up. `info` covers the version and the protocol/capability matrix. If the backends are ever aligned on `status`, the library implementation comes first.

## Version Control and Changelog

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ from typing import ClassVar, Optional
from rhosocial.activerecord.base import FieldProxy
from rhosocial.activerecord.backend.impl.snowflake.backend import SnowflakeBackend
from rhosocial.activerecord.backend.impl.snowflake.config import SnowflakeConnectionConfig
from rhosocial.activerecord.backend.expression import CreateTableExpression
from rhosocial.activerecord.backend.expression import CreateTableStatement
from rhosocial.activerecord.backend.expression.objects import Table
from rhosocial.activerecord.backend.expression.statements import (
ColumnConstraint,
ColumnConstraintType,
ColumnDefinition,
ColumnPrimaryKeyConstraint,
)
from rhosocial.activerecord.backend.expression.types import VarCharType
from rhosocial.activerecord.model import ActiveRecord
Expand Down Expand Up @@ -87,13 +87,15 @@ User.configure(
)

dialect = User.__backend__.dialect
User.__backend__.execute(*CreateTableExpression(
User.__backend__.execute(*CreateTableStatement(
dialect,
Table(dialect, "users"),
columns=[
ColumnDefinition(
dialect, "id", VarCharType(dialect, 36),
constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)],
constraints=[ColumnConstraint(
dialect, branch=ColumnPrimaryKeyConstraint(dialect),
)],
),
ColumnDefinition(dialect, "name", VarCharType(dialect, 100)),
ColumnDefinition(dialect, "email", VarCharType(dialect, 255)),
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/7.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
`table-storage-metrics` on the introspect CLI, with `views NAME`; the type list derives from `_TYPE_CAPABILITIES`. Account objects (databases, schemas, warehouses) are declared as expression statements.

Account-object introspection for the Snowflake backend: `list_databases()`, `list_schemas(database=None)`, `list_warehouses()` and `get_table_storage_metrics(table_name, schema=None)` on both the sync and async introspectors, with `databases` / `schemas` / `warehouses` added to the `introspect` CLI subcommand. Every statement is built as an expression tree (`SnowflakeDatabaseListQueryStatement`, `SnowflakeSchemaListQueryStatement`, `SnowflakeWarehouseListQueryStatement`, `SnowflakeTableStorageMetricsQueryStatement`) rendered by the dialect's `format_*_query_statement` methods, so no SQL text is assembled by concatenation. `SHOW WAREHOUSES` is used because no `INFORMATION_SCHEMA.WAREHOUSES` view exists. See `examples/introspection_account_objects.py` for a runnable walkthrough.
1 change: 1 addition & 0 deletions changelog.d/7.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The table kind is the one the catalogue named, `include_views` no longer hides managed tables, and the catalogue's spelling is preserved rather than normalised.
1 change: 1 addition & 0 deletions changelog.d/7.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A managed table is no longer dropped from a `views` read, and the storage metrics distinguish "no row for this table" from "this role cannot see the view".
1 change: 1 addition & 0 deletions changelog.d/7.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The CLI tests pin every declared type and serialise each output, and CI installs core from `release/v1.0.0.dev30` now that core #127 has merged.
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ namespaces = true

[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q"
addopts = "-ra -q -p plugins.expression_xdist"
# The plugin is addressed as ``plugins.expression_xdist`` because CI puts
# ``tests`` itself on ``PYTHONPATH`` (``export PYTHONPATH=src:tests``), which is
# also what makes the package layout under ``tests/`` importable. Adding
# ``tests`` here as well would arrange a *different* root, and the plugin would
# not resolve at all -- which is what Python 3.8's unit-only leg reported.
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ async def _process_result_set(self, cursor, is_select, column_adapters=None, col
``cursor.description`` returns them as such. Python model
field names are lowercase by convention, so we normalise
column names to lowercase before building row dicts so that
``_map_columns_to_fields()`` and ``_remap_row_columns()`` can
``_map_columns_to_fields()`` and ``remap_row_columns()`` can
match them correctly.
"""
if not is_select:
Expand All @@ -255,8 +255,8 @@ async def _process_result_set(self, cursor, is_select, column_adapters=None, col
mapping = column_mapping or {}
for row in rows:
row_dict = dict(zip(column_names, row))
adapted_row = self._adapt_row_types(row_dict, adapters)
final_row = self._remap_row_columns(adapted_row, mapping)
adapted_row = self.adapt_row_types(row_dict, adapters)
final_row = self.remap_row_columns(adapted_row, mapping)
final_results.append(final_row)
return final_results
except Exception as e:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ def _process_result_set(self, cursor, is_select, column_adapters=None, column_ma
``cursor.description`` returns them as such. Python model
field names are lowercase by convention, so we normalise
column names to lowercase before building row dicts so that
``_map_columns_to_fields()`` and ``_remap_row_columns()`` can
``_map_columns_to_fields()`` and ``remap_row_columns()`` can
match them correctly.
"""
if not is_select:
Expand All @@ -291,8 +291,8 @@ def _process_result_set(self, cursor, is_select, column_adapters=None, column_ma
mapping = column_mapping or {}
for row in rows:
row_dict = dict(zip(column_names, row))
adapted_row = self._adapt_row_types(row_dict, adapters)
final_row = self._remap_row_columns(adapted_row, mapping)
adapted_row = self.adapt_row_types(row_dict, adapters)
final_row = self.remap_row_columns(adapted_row, mapping)
final_results.append(final_row)
return final_results
except Exception as e:
Expand Down
105 changes: 105 additions & 0 deletions src/rhosocial/activerecord/backend/impl/snowflake/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# `introspect` — Snowflake metadata, from the command line

The `introspect` subcommand asks a live Snowflake account what it holds, and
prints the answer. Every question runs through the backend's introspector, so
what the CLI reports is what the library sees — the same objects, the same
types, the same parsing — rather than a second implementation that can drift
from it.

```bash
python -m rhosocial.activerecord.backend.impl.snowflake introspect <TYPE> [NAME] [OPTIONS]
```

## What it can ask about

| Type | `NAME` | Reads | Notes |
| :--- | :--- | :--- | :--- |
| `tables` | — | `information_schema.tables` | Tables, with the kind the catalogue reports and their comment. |
| `views` | view, optional | `information_schema.views` | Views, with their definitions; one view when named. |
| `columns` | table, required | `information_schema.columns` | One table's columns: name, type, nullability, ordinal position, default. |
| `indexes` | table, required | `information_schema.table_constraints`, `key_column_usage` | Constraints and the columns each side names. |
| `foreign-keys` | table, required | `information_schema.referential_constraints` | Referential constraints. |
| `table-storage-metrics` | table, required | `information_schema.table_storage_metrics`, `information_schema.tables` | One table's billed storage: active, time travel, failsafe and clone-retained bytes, plus the row count, clustering key and kind `tables` reports. |
| `databases` | — | `information_schema.databases` | The databases in the account, with owner and transient flag. |
| `schemas` | — | `information_schema.schemata` | The schemas in a database — Snowflake's namespace level above a table. `--database` names the database to enumerate. |
| `warehouses` | — | `SHOW WAREHOUSES` | Warehouses with size, state and auto-resume. This is `SHOW`, not an `information_schema` view, because no such view exists. |
| `database` | — | `CURRENT_DATABASE()`, `CURRENT_VERSION()` | The database this session is in and the account's version. |

`NAME` is required for `columns`, `indexes`, `foreign-keys` and
`table-storage-metrics`. For `views` it is optional: a name asks for that one
view, and a name the catalogue does not have exits 1. The list types —
`tables`, `database`, `databases`, `schemas` and `warehouses` — take no name,
and a stray one is refused (exit 2) rather than ignored: this backend has no
`get_*_info` method that could answer for a single database, schema or
warehouse. `schemas --database analytics` is unaffected — `--database` says
which database's schemas to enumerate, not which schema to fetch.

Exit codes follow that split: **1** is a runtime failure (connection, query,
unexpected) and also "the named view or table is not there"; **2** is a request
this backend cannot serve — a type whose capability switch is off, or a `NAME`
on a list type.

Two absences worth naming:

* **No `triggers` type.** Snowflake has no triggers, so there is no object to
ask about. That is an engine fact, not a gap in the CLI.
* **No `partition-metadata` type.** The dialect carries a formatter for it,
but no introspector method reads it, so there is no answer behind the type.

`table-storage-metrics` is a type of its own rather than a column of `tables`
for two reasons. Its statement joins two views, so folding it into the listing
would charge every list for that join. And `TABLE_STORAGE_METRICS` returns no
rows for roles other than `ACCOUNTADMIN`, so an empty answer means "no metrics
for this role" — a `None` the command can state out loud, which a column on the
listing could not. An exit of 1 on this type is therefore **not** "the table
does not exist".

## Options

| Option | Default | Description |
| :--- | :--- | :--- |
| `-o, --output` | `table` | `table` (rich), `json`, `csv`, `tsv`. |
| `--account` | `SNOWFLAKE_ACCOUNT` | Account identifier. |
| `--host`, `--port` | _None_ | Connection endpoint, when not using the account locator. |
| `--database` | `SNOWFLAKE_DATABASE` | Database to connect to. |
| `--schema` | `SNOWFLAKE_SCHEMA` | Schema to connect to; introspection reads it. Defaults to `PUBLIC`. |
| `--warehouse` | `SNOWFLAKE_WAREHOUSE` | Warehouse to run queries on. |
| `--role` | `SNOWFLAKE_ROLE` | Role to assume. |
| `--user`, `--password` | env | Credentials. |
| `--authenticator` | _None_ | How the user is authenticated. |
| `--use-async` | off | Drive the asynchronous backend. |

## Notes

* **Read-only.** `introspect` never writes. It does not create, drop, or
alter anything, so it is safe to run against an account whose schema
matters.
* **The warehouse matters.** Snowflake needs one to run a query. If the role's
default warehouse is not set, pass `--warehouse`; a missing warehouse is a
connection failure, not an empty answer.
* **`SHOW WAREHOUSES` has no `information_schema` counterpart.** It also
caps its output, and its column names come back lower case — both are
recorded next to the query in the source rather than assumed here.
* **Nothing here was run against a live account.** The statements are written
from the official documentation (cited beside each one in the source).
Verify the first run against your account before relying on the shapes.
* **One question per invocation.** A CLI process is one connection that
opens, answers, and closes — there is no cache carried between runs.
* **No `status` subcommand.** This backend implements no status
introspector — there is no `AbstractStatusIntrospector` implementation
under `introspection/` — so the CLI has no `status` command to wire up.
That is a missing library capability, not a forgotten command: `info`
reports the version and the protocol/capability matrix instead. Aligning
the backends on `status` starts in the library, not here.

## Examples

```bash
# Account-level objects, which Snowflake has and most engines do not
python -m rhosocial.activerecord.backend.impl.snowflake introspect databases
python -m rhosocial.activerecord.backend.impl.snowflake introspect schemas --database analytics
python -m rhosocial.activerecord.backend.impl.snowflake introspect warehouses -o json

# One table's storage facts
python -m rhosocial.activerecord.backend.impl.snowflake introspect table-storage-metrics orders --schema public --database analytics
```
Loading
Loading