Overview#
Snowflake is a cloud-native, columnar, analytical
data warehouse. Storage and compute are separated: table data lives in
automatically managed micro-partitions in cloud object storage, while
queries run on virtual warehouses — elastic compute clusters you size,
suspend, and resume independently of the data.| Property | Value |
|---|
| Dialect name | snowflake |
| Extends | nothing — implements Dialect directly |
| Driver | snowflake-sdk |
| Cloud SDK | none |
| Default port | none — account identifier |
| Local runtime | none — hosted service only |
That architecture shapes almost everything about this ORM's SnowflakeDialect
(src/dialects/snowflake/index.ts). It talks to Snowflake through the official
snowflake-sdk Node driver, and several concepts that are first-class in
row-oriented OLTP databases simply do not exist here:No traditional B-tree indexes. Micro-partitioning and clustering keys
replace them. All index-shaped methods (addIndex, createIndex,
removeIndex, dropIndex, createFulltextIndex, createSpatialIndex) are
documented no-ops that emit a console warning rather than throwing, so
generic ORM code that calls them after createTable keeps working.
No native RETURNING. INSERT/UPDATE do not return the affected rows;
re-SELECT if you need them.
No user-facing table partitioning. createPartitionedTable and friends
throw a clear error pointing you at clustering keys instead.
Semi-structured data uses VARIANT/OBJECT/ARRAY with PARSE_JSON()
and :-path access — not JSON/JSONB.
Upserts use standard-SQL MERGE INTO.
Key facts (from src/dialects/snowflake/index.ts):Dialect name is 'snowflake'; the driver library is 'snowflake-sdk'.
Identifiers are double-quoted (escapeId('my_col') → "my_col"), with
embedded quotes doubled.
Pagination is standard LIMIT/OFFSET (unlike Oracle/MSSQL's
OFFSET...FETCH-only style).
Quick start#
Install#
$ npm install ts-prorm-orm snowflake-sdk
$ pnpm add ts-prorm-orm snowflake-sdk
$ yarn add ts-prorm-orm snowflake-sdk
$ bun add ts-prorm-orm snowflake-sdk
Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'snowflake',
database: 'ANALYTICS',
username: 'ANALYST',
password: process.env.SNOWFLAKE_PASSWORD,
dialectOptions: {
account: 'myorg-myaccount',
warehouse: 'COMPUTE_WH',
schema: 'PUBLIC',
},
});
Define a model#
import { DataTypes } from 'ts-prorm-orm';
const User = db.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
name: { type: DataTypes.STRING(100) },
});
await db.sync(); // CREATE TABLE — see Generated SQL below
Query#
import { Op } from 'ts-prorm-orm';
await User.create({ email: 'ada@example.com', name: 'Ada' });
const page = await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Generated SQL#
Each block is the statement the model call above it hands the dialect's query(), captured with the driver stubbed out. The model layer inlines the values.Create table#
db.sync() for the model in the Quick start:CREATE TABLE
Insert, update and delete#
await User.create({ email: 'ada@example.com', name: 'Ada' });
await User.update({ name: 'Grace' }, { where: { email: 'ada@example.com' } });
await User.destroy({ where: { id: 1 } });
Upsert#
await User.upsert({ id: 1, email: 'ada@example.com', name: 'Ada Lovelace' });
MERGE statement
upsert() also reads the row by primary key before and after the write, to return the instance.await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Transaction#
await db.transaction(async (t) => {
await User.create({ email: 'grace@example.com', name: 'Grace' }, { transaction: t });
await User.update({ name: 'Grace Hopper' }, { where: { email: 'grace@example.com' }, transaction: t });
});
[!WARNING] No savepoints
Nested transactions need savepoints, and snowflake has none. Run the work in a single transaction, or restructure it so nothing nests.
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → NUMBER(38,0), BOOLEAN → BOOLEAN, DATE → TIMESTAMP_NTZ, UUID → VARCHAR(36), JSON → VARIANT. The type comparison lines the major engines up side by side.Connection#
Snowflake connections are keyed by an account identifier rather than a
host/port, and typically name a warehouse, database, and schema.import { Prorm } from 'ts-prorm-orm';
const db = new Prorm({
dialect: 'snowflake',
username: 'analyst',
password: 's3cret',
database: 'ANALYTICS',
schema: 'PUBLIC',
dialectOptions: {
account: 'xy12345.us-east-1', // Snowflake account identifier
warehouse: 'COMPUTE_WH', // compute cluster to activate on connect
role: 'TRANSFORMER', // optional default role
},
});
await db.connect();
If warehouse is set, the dialect issues USE WAREHOUSE "COMPUTE_WH"
immediately after the connection is established — a warehouse must be active
(and resumed) for queries to run.
If schema is configured, quoteTable('users') qualifies unqualified table
names with it: "PUBLIC"."users".
Key-pair authentication is supported instead of password: pass
privateKey (PEM contents) or privateKeyPath, optionally with
privateKeyPass for an encrypted key. authenticator, application, and
clientSessionKeepAlive are also forwarded to the driver.
Connection retries#
query() wraps calls in a retry loop tuned for Snowflake's compute lifecycle.
The default match list retries connection errors plus transient warehouse
states — warehouse is currently suspended, warehouse is resuming,
warehouse is being resized — but deliberately not the bare word
warehouse, so permanent errors like "warehouse does not exist" fail fast
instead of wasting retries. Override with the retry option.Data types#
getDataTypeSql maps the ORM's type descriptors to Snowflake types. Raw string
types are passed through verbatim. Verified in
tests/dialects/snowflake.test.ts:| ORM type descriptor | Generated SQL |
|---|
{ key: 'STRING' } | VARCHAR(255) |
{ key: 'STRING', length: 100 } | VARCHAR(100) |
{ key: 'TEXT' } | VARCHAR |
{ key: 'CHAR', length: 4 } | CHAR(4) |
{ key: 'INTEGER' } | NUMBER(38,0) |
{ key: 'BIGINT' } | NUMBER(38,0) |
{ key: 'DECIMAL', precision: 12, scale: 4 } | NUMBER(12,4) |
{ key: 'FLOAT' } | FLOAT |
{ key: 'DOUBLE' } | DOUBLE |
{ key: 'BOOLEAN' } | BOOLEAN |
{ key: 'DATE' } | TIMESTAMP_NTZ |
{ key: 'DATEONLY' } | DATE |
{ key: 'TIME' } | TIME |
{ key: 'JSON' } / { key: 'JSONB' } | VARIANT |
{ key: 'ARRAY' } | ARRAY |
{ key: 'UUID' } | VARCHAR(36) |
{ key: 'BLOB' } | BINARY |
{ key: 'GEOMETRY' } / { key: 'GEOGRAPHY' } | GEOGRAPHY |
Two notable approximations reflecting real Snowflake gaps:INTEGER/BIGINT both become NUMBER(38,0) — Snowflake has a single
fixed-point numeric type; the aliases INT, INTEGER, BIGINT, etc. are all
synonyms for NUMBER(38,0).
ENUM becomes VARCHAR — there is no native ENUM; add a CHECK
constraint at the table level if you need value restriction.
Semi-structured data (VARIANT)#
JSON/JSONB map to VARIANT. When you escape() a plain object or array, it
is wrapped in PARSE_JSON(...) so it lands as semi-structured data:db.dialect.escape({ tags: ['a', 'b'], active: true });
// PARSE_JSON('{"tags":["a","b"],"active":true}')
Query into a VARIANT column with buildJsonQuery, which uses Snowflake's
:-path access and :: casts instead of JSON_EXTRACT:db.dialect.buildJsonQuery('data', 'active', true);
// { sql: '"data":active::STRING = ?', values: [true] }
Dotted paths become nested accessors ('address.city' →
"data":address.city::STRING).Upsert — MERGE INTO#
Upserts compile to standard-SQL MERGE INTO, which Snowflake fully supports.
Unlike the MySQL-family dialects, conflictFields is required: it forms the
ON clause that identifies an existing row. Omitting it would match on all
columns and never find a row to update, so every upsert would insert a
duplicate — the dialect throws instead of silently doing that.db.dialect.buildUpsertQuery(
'users',
{ id: 1, name: 'Alice', email: 'a@x.io' },
{ conflictFields: ['id'] },
);
updateOnDuplicate restricts which columns the WHEN MATCHED branch updates:db.dialect.buildUpsertQuery(
'users',
{ id: 1, name: 'Alice', email: 'a@x.io' },
{ conflictFields: ['id'], updateOnDuplicate: ['name'] },
);
// ... WHEN MATCHED THEN UPDATE SET "name" = source."name" ...
CRUD#
Standard CRUD builders produce parameterized SQL with ? placeholders and
double-quoted identifiers. Verified in tests/dialects/snowflake.test.ts:buildWhereClause supports the usual operator set ($eq, $ne, $gt,
$gte, $lt, $lte, $in, $notIn, $between, $like, $startsWith,
$endsWith, $substring, $isNull, and logical $and/$or/$not). Snowflake
case-insensitive matching uses its ILIKE(...) function form, so $iLike
emits ILIKE("col", ?) rather than a plain ILIKE operator.buildUpdateQuery/buildDeleteQuery accept a limit option for
interface-compatibility but warn and ignore it — Snowflake's UPDATE/
DELETE do not support LIMIT.buildSelectQuery accepts Snowflake-friendly analytical extensions:QUALIFY — filter on window-function results without a wrapping
subquery, passed as a raw expression:db.dialect.buildSelectQuery({
tableName: 'sales',
attributes: [
'dept',
'ROW_NUMBER() OVER (PARTITION BY dept ORDER BY amount DESC) AS rn',
],
qualify: 'ROW_NUMBER() OVER (PARTITION BY dept ORDER BY amount DESC) = 1',
});
// SELECT dept, ROW_NUMBER() OVER (...) AS rn FROM "PUBLIC"."sales" QUALIFY ROW_NUMBER() OVER (...) = 1
Attributes containing whitespace or parentheses (window functions,
expr AS alias) are passed through unescaped so analytical SQL is not
mangled by identifier quoting.cte — WITH [RECURSIVE] ... prefixes; if any entry is recursive the
whole clause is emitted as WITH RECURSIVE.
Time Travel — atTimestamp, atOffset, atStatement, or
beforeStatement append an AT(...)/BEFORE(...) clause after the table
reference, e.g. FROM "T" AT(OFFSET => -60).
Storage, clustering, and Snowflake-native objects#
Since there are no indexes, use a clustering key to influence
micro-partition pruning on large tables:await db.dialect.setClusteringKey('events', ['event_date', 'customer_id']);
// ALTER TABLE "PUBLIC"."events" CLUSTER BY ("event_date", "customer_id")
await db.dialect.dropClusteringKey('events');
// ALTER TABLE "PUBLIC"."events" DROP CLUSTERING KEY
The dialect also wraps a broad set of Snowflake-native DDL. These are exposed
as dedicated methods (not standard-SQL shapes) because they have no analogue in
other dialects:Zero-copy clone — cloneTable, cloneSchema, cloneDatabase
(CREATE ... CLONE ...), optionally combined with Time Travel.
Bulk load/unload — createFileFormat, createStage, copyInto
(COPY INTO TARGET_TABLE FROM @stage), unloadTo (COPY INTO @stage FROM
TARGET_TABLE).
Warehouses — createWarehouse, alterWarehouse, dropWarehouse,
suspendWarehouse, resumeWarehouse.
Resource monitors — createResourceMonitor/alterResourceMonitor to cap
credit usage with NOTIFY/SUSPEND/SUSPEND_IMMEDIATE triggers.
Streams & tasks — createStream (CREATE STREAM ... ON TABLE) and
createTask for change tracking and scheduled DAGs.
Sequences — createSequence/nextSequenceValue via CREATE SEQUENCE /
NEXTVAL (note: MINVALUE/MAXVALUE/CYCLE are warned-and-ignored — not
supported by Snowflake sequences).
Stored procedures — createStoredProcedure emits
CREATE PROCEDURE ... AS $$ ... $$ with a configurable LANGUAGE
(SQL/JavaScript/Python/etc.); dropStoredProcedure targets a specific
overload via paramTypes.
Auto-increment columns use Snowflake's AUTOINCREMENT property
(describeTable also recognizes IDENTITY).Not supported / throws#
These interface methods throw a clear error rather than emitting invalid SQL:Extensions (createExtension, etc.) — PostgreSQL-only.
Foreign Data Wrappers (servers, user mappings, foreign tables) —
PostgreSQL-only; use external tables/stages/integrations instead.
Table partitioning — micro-partitioning is automatic; use a clustering
key.
User / role / privilege management builders — Snowflake has real
user/role management, but it is not implemented in this dialect, so these
throw "not implemented" rather than emitting incorrect SQL.
Row-level security (createSecurityPolicy) — use Snowflake row access
policies directly.
Savepoints are also unsupported: createSavepointSQL /
rollbackToSavepointSQL return inert SQL comments (and warn) rather than real
savepoint statements — use COMMIT/ROLLBACK for transactional control.
startTransaction/commitTransaction/rollbackTransaction emit plain
BEGIN/COMMIT/ROLLBACK.Caveats / not-yet-verified#
SQL generation is unit-tested; the live-execution tests use a mocked
driver. The build* builders and their SQL strings are asserted directly
in tests/dialects/snowflake.test.ts, and the "executes against a real
table" tests run against a stubbed snowflake-sdk connection, not a live
Snowflake account. End-to-end behavior against a real warehouse (that
MERGE, COPY INTO, CLONE, clustering keys, streams/tasks, and Time
Travel behave as intended) has not been exercised in this suite.
Index/fulltext/spatial methods are no-ops. They only warn; nothing is
created. Reach for a clustering key (or SEARCH OPTIMIZATION / Cortex Search
in real Snowflake) instead.
No RETURNING. After an insert/upsert, re-SELECT the row if you need
the stored values back.
ENUM is emulated as VARCHAR with no automatic CHECK constraint.
Capabilities#
Computed from the code — capabilitiesOf() (src/transaction-support.ts),
whereSupportOf() (src/query-builders/expression-render.ts),
sqlObjectCapabilitiesOf() (src/sql-objects/capabilities.ts) and the
builders — by the scratch test described in the
capability matrix.| Capability | snowflake |
|---|
| Transactions atomic | yes |
| Savepoints | no — a nested transaction() throws |
| Isolation | up to READ COMMITTED |
| Transaction modes | read-only no; deferrable no; constraint deferral no |
| RETURNING | not emitted; returning is accepted and ignored |
| Upsert | MERGE INTO … WHEN MATCHED / WHEN NOT MATCHED |
| JSON column | VARIANT (JSONB → VARIANT) |
| Vector column | VECTOR(FLOAT, 3) |
| Array column | ARRAY |
| WHERE builder | 38 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family snowflake: triggers no; procedures yes; functions expressionOnly; materialized views native; sequences yes; enums check; RLS no |
| Streaming | queryStream() present; LISTEN absent; createExtension() present |
Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16