Skip to content

feat(snowflake): add read-only Snowflake datasource gem#292

Open
bexchauveto wants to merge 5 commits intomainfrom
feat/snowflake-datasource
Open

feat(snowflake): add read-only Snowflake datasource gem#292
bexchauveto wants to merge 5 commits intomainfrom
feat/snowflake-datasource

Conversation

@bexchauveto
Copy link
Copy Markdown
Member

@bexchauveto bexchauveto commented Apr 28, 2026

Summary

Introduces forest_admin_datasource_snowflake — a native Forest Admin datasource backed by Snowflake via ODBC, with no ActiveRecord involvement.

Read-only first pass. Covers list/aggregate query translation, schema introspection, connection pooling, retry-on-disconnect, optional FK auto-discovery, and optional per-session statement timeout. Wired into build.yml lint/test matrices with a unixodbc-dev apt step.

Highlights

Type translation

  • ODBC SQL types → Forest types via Parser::Column
  • Snowflake-native types (VARIANT/OBJECT/ARRAY/BINARY/VARBINARY) detected via INFORMATION_SCHEMA.COLUMNS and override the generic ODBC mapping — without this, the Snowflake ODBC driver labels them all SQL_VARCHAR
  • ODBC::Date / ODBC::TimeStamp / ODBC::Time coerced to native Ruby Date/Time (otherwise they JSON-serialize as {})
  • JSON-typed columns parsed into Ruby objects so the UI receives structured data
  • Session-level TIMEZONE='UTC' so TIMESTAMP_NTZ/LTZ/TZ all serialize consistently

Operational

  • connection_pool-backed pool, sized via pool_size: (default 5)
  • with_connection retries the block once on connection-lost ODBC errors and cycles the pool to drop stale handles
  • Optional statement_timeout: (in seconds) issues ALTER SESSION SET STATEMENT_TIMEOUT_IN_SECONDS
  • Optional introspect_relations: true discovers Snowflake-internal FKs via SHOW IMPORTED KEYS IN SCHEMA (Snowflake doesn't ship the full ANSI INFORMATION_SCHEMA, so the join-style query isn't usable)

Read-only enforcement

  • Every column is emitted with is_read_only: true, so Forest's schema emitter computes collection-level isReadOnly: true automatically and the UI hides create/edit/delete affordances
  • create/update/delete raise ForestException with an explicit read-only message as a defence-in-depth guard

Coverage

77 specs across:

  • Parser::Column — ODBC and Snowflake-native type mapping (24)
  • Utils::Identifier — case-preserving quoting (5)
  • Utils::Query — condition tree translation, IN/LIKE/ILIKE, sort/page (16)
  • Datasource — introspection, pool, session settings, retry, FK discovery, native-type fetch (17)
  • Collection — schema introspection, list/aggregate, JSON parsing, write guards (15)

Caveats / follow-ups

  • No real-Snowflake integration test in CI — coverage is fully mocked at the ODBC boundary. A follow-up could add an env-gated smoke test (skipped unless SNOWFLAKE_* env vars are present).
  • Read-only by design for v1. Write support is a future direction; the read-only guards in Collection are explicit so a later subclass can override safely.
  • Cross-datasource FKs (e.g. Snowflake BILLING_USAGE.CUSTOMER_ID → Postgres customers.id) cannot be auto-discovered — Snowflake only knows about its own metadata. These need to be wired manually with add_many_to_one_relation / add_one_to_many_relation at the agent layer.

Test plan

  • cd packages/forest_admin_datasource_snowflake && BUNDLE_GEMFILE=Gemfile-test bundle install && BUNDLE_GEMFILE=Gemfile-test bundle exec rspec passes (77 examples, 0 failures)
  • bundle exec rubocop packages/forest_admin_datasource_snowflake passes (clean)
  • bundle exec rubocop repo-wide passes (still clean after .rubocop.yml exclusion additions)
  • Live Forest UI smoke test against a Snowflake account: list, count, filter, aggregate, FK auto-discovery on intra-Snowflake relations, type rendering for VARIANT/OBJECT/ARRAY/BINARY/TIMESTAMP variants

Note

Add read-only Snowflake datasource gem backed by ODBC

  • Introduces a new forest_admin_datasource_snowflake gem that connects Forest Admin to Snowflake via ODBC using a pooled connection (connection_pool).
  • Datasource discovers visible tables as read-only collections, applies UTC session settings, and optionally introspects foreign keys via SHOW IMPORTED KEYS to add ManyToOne relations.
  • Collection introspects column schemas at init, maps ODBC/Snowflake native types (including VARIANT/OBJECT/ARRAYJson) to Forest types, and coerces ODBC date/timestamp values on read. Write operations (create, update, delete) raise a read-only error.
  • Utils::Query translates Forest condition trees into parameterized Snowflake SQL, supporting WHERE, ORDER BY, LIMIT/OFFSET, and aggregations.
  • Transient connection-loss errors trigger a single automatic pool reset and retry; unrelated errors are re-raised immediately.
  • CI, RuboCop, and release automation are updated to include the new package.

Macroscope summarized 6334231.

@qltysh
Copy link
Copy Markdown

qltysh Bot commented Apr 28, 2026

9 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 7): fetch_fields 6
qlty Structure Function with many parameters (count = 4): aggregate 3

execute_to_hashes(sql, binds, projection.to_a)
end

def aggregate(_caller, filter, aggregation, limit = nil)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with many parameters (count = 4): aggregate [qlty:function-parameters]

)
@primary_keys << column_name if field.is_primary_key
add_field(column_name, field)
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 7): fetch_fields [qlty:function-complexity]

attr_reader :pool

def initialize(conn_str:, tables: nil, schema: nil,
pool_size: DEFAULT_POOL_SIZE, pool_timeout: DEFAULT_POOL_TIMEOUT,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with many parameters (count = 7): initialize [qlty:function-parameters]

source.add_field(relation_name, relation)
end
rescue ::ODBC::Error => e
warn "[forest_admin_datasource_snowflake] FK introspection skipped: #{e.message}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 5): discover_relations [qlty:function-complexity]

result += equality + orderables if %w[Date Dateonly Time Number].include?(type)
result += equality + orderables + strings if type == 'String'

result
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 7): operators_for_column_type [qlty:function-complexity]

ConditionTreeBranch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch
ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf

def initialize(collection, projection: nil, filter: nil, aggregation: nil, limit: nil)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with many parameters (count = 5): initialize [qlty:function-parameters]

sql << " OFFSET #{Integer(offset)}" if offset
end

[sql, @binds]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 12): to_sql [qlty:function-complexity]

translate_leaf(node)
else
raise ForestAdminDatasourceSnowflake::Error, "Unsupported condition tree node: #{node.class}"
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 8): build_where_clause [qlty:function-complexity]

@bexchauveto bexchauveto force-pushed the feat/snowflake-datasource branch from 1ab8be1 to 465613a Compare April 28, 2026 15:50
Introduces forest_admin_datasource_snowflake — a native Forest Admin
datasource backed by Snowflake via ODBC, with no ActiveRecord involvement.
Read-only first pass: list/aggregate query translation, schema introspection,
connection pooling, retry-on-disconnect, optional FK auto-discovery, and
optional per-session statement timeout.

Type translation:
- ODBC SQL types → Forest types via Parser::Column
- Snowflake-native types (VARIANT/OBJECT/ARRAY/BINARY/VARBINARY) detected
  via INFORMATION_SCHEMA.COLUMNS and override the ODBC mapping
- ODBC::Date / ODBC::TimeStamp / ODBC::Time coerced to native Ruby
  Date/Time so JSON serialization works (otherwise they emit `{}`)
- JSON-typed columns parsed into Ruby objects
- Session-level TIMEZONE='UTC' so TIMESTAMP_NTZ/LTZ/TZ all serialize
  consistently as UTC

CI integration: added the package to lint/test matrices in build.yml
with a unixODBC apt step. 77 specs cover the introspection, query
translator, identifier quoting, type mapping, and connection retry paths.
@bexchauveto bexchauveto force-pushed the feat/snowflake-datasource branch from 465613a to e6253b4 Compare April 28, 2026 15:54
Previously when Aggregation.field was nil, build_aggregation_expression
emitted invalid SQL like SUM() because Identifier.quote(nil) returns an
empty string. Raise an explicit ForestAdminDatasourceSnowflake::Error so
the failure surfaces at translation time instead of as a Snowflake
syntax error at execution. COUNT keeps its existing nil-as-* fallback.
"#{op}(#{q(field)})"
else
raise ForestAdminDatasourceSnowflake::Error, "Unsupported aggregation operation: #{op}"
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 7): build_aggregation_expression [qlty:function-complexity]

visible_tables and Collection#fetch_fields called stmt.drop after
stmt.fetch_all without an ensure block, so a fetch failure would leak the
statement handle. Wrap the fetch in begin/ensure to match the pattern
already used by fetch_snowflake_native_types and run_session_statement.
@matthv
Copy link
Copy Markdown
Member

matthv commented Apr 29, 2026

The PR is missing changes to .releaserc.js. The new forest_admin_datasource_snowflake package needs to be wired into 3 sections so it gets published on the next release:

  • prepareCmd
  • successCmd
  • assets

Without this, the gem won't be published to RubyGems even after the PR is merged.

…ipeline

Adds the new package to the three .releaserc.js sections so semantic-release
will: bump its VERSION constant on release (prepareCmd), build and push the
gem to RubyGems (successCmd), and commit the version bump back to the repo
(@semantic-release/git assets). Without this the gem stays at 0.0.x and
never reaches RubyGems.
Snowflake's session token has a finite TTL; once it expires the driver
returns "08001 (390114) Authentication token has expired." This message
didn't match any of CONNECTION_LOST_PATTERNS, so the gem propagated the
error to Forest instead of cycling the pool. Cycling forces a fresh
drvconnect, which re-authenticates with the credentials in conn_str.

Adds matchers for the exact Snowflake string and a generic "token
expired" fallback so similar phrasings from other drivers also recover.
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.

2 participants