DEV Community

Cover image for Round-Tripping TPC-DS and a Snowflake Feature Schema Through a Database Diagram: What Snowflake Lost and What We Fixed
Son Tran
Son Tran

Posted on Originally published at schemity.com

Round-Tripping TPC-DS and a Snowflake Feature Schema Through a Database Diagram: What Snowflake Lost and What We Fixed

Disclosure: I build Schemity, a desktop ERD tool - this post is from our blog and uses it for the examples.

TL;DR: We put two Snowflake schemas through Schemity, TPC-DS as Snowflake ships it and a hand-written schema using Snowflake's own features, and rebuilt each in a fresh schema from the SQL Schemity wrote. Before this round of fixes the second script stopped partway, because a table with an expression clustering key could not be read; every clustering key and TRANSIENT was dropped, an identity lost its start and step, and TPC-DS's 17 single-column surrogate keys came back as AUTOINCREMENT. After the fixes TPC-DS comes back with no catalog difference, the second schema differs only in two comments, and all 14 behaviour probes match the originals.

A database diagram that reads a Snowflake schema and writes it back should give you the same schema, and before this round of fixes Schemity did not. We took two schemas, TPC-DS as Snowflake ships it in its sample database (24 tables) and a hand-written schema built from the Snowflake features real projects use (6 tables), imported each from Snowflake's own GET_DDL, and rebuilt it in a fresh schema from the SQL Schemity wrote. Snowflake stopped the second script partway through, and the first came back with every clustering key gone and 17 keys numbering themselves. After the fixes, the SQL Schemity writes runs on Snowflake as it is, with no edit, TPC-DS comes back with no catalog difference, the feature schema differs only in two comments, and both behave like their originals in 14 behaviour probes.

Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. This is the fifth and last post in a series that tests the round trip on each database it supports, after Mastodon and Saleor on PostgreSQL, Invoice Ninja and Sylius on MySQL, Bitwarden and Smartstore on SQL Server and Campfire and Jellyfin on SQLite. Reading a schema you did not write is how you understand your schema before changing it, and Snowflake tested the part of a table the other four never needed: how the warehouse stores it, not only what its columns are.

Which two schemas, and which path?

There is no open-source application whose schema is built for Snowflake the way Mastodon's is built for PostgreSQL, so this post uses two kinds instead. TPC-DS is the TPC's decision-support benchmark, the schema analytics engines are measured on, and Snowflake ships it with its keys declared. The second is ours: a billing and events model written to use what Snowflake projects do and the other four databases never needed.

TPC-DS Feature schema
What it is The TPC's decision-support schema, from SNOWFLAKE_SAMPLE_DATA.TPCDS_SF10TCL Customers, products, orders, order items, raw events and a daily rollup
Tables / columns 24 / 425 6 / 37
Keys 24 primary keys, 105 foreign keys 5 primary keys and 2 unique keys, 2 of them composite, and 3 foreign keys
Clustering keys 24, on a column 2, one on an expression: CLUSTER BY (TO_DATE(ORDERED_AT))
Also has nothing else an identity with START 100 INCREMENT 5, a sequence default, COLLATE 'en-ci', a TRANSIENT table, OBJECT / VARIANT / ARRAY, TIMESTAMP_LTZ(9) / TIMESTAMP_TZ(3) / TIMESTAMP_NTZ(6), TIME(0), BINARY(16), two comments

Snowflake refuses GET_DDL on a shared database such as its sample data, so TPC-DS was copied into an empty schema with CREATE TABLE ... LIKE, which keeps the columns and the clustering keys, and the sample's keys were added from its own SHOW PRIMARY KEYS and SHOW IMPORTED KEYS. Everything ran on a Snowflake trial account, version 10.37, on an X-Small warehouse.

Part of the TPC-DS diagram in Schemity, arranged into groups with the minimap in the corner: the Catalog Channel group holds CATALOG_RETURNS, CALL_CENTER, CATALOG_SALES and CATALOG_PAGE, with CUSTOMER_ADDRESS, CUSTOMER_DEMOGRAPHICS and INCOME_BAND in the group beside it, and the status bar reads 24 entities and 105 relations

Unlike the other four databases, Schemity has no live connection to Snowflake: a Snowflake diagram is designed from a script and its SQL is run by you. So unlike the other four posts there is one path:

  • Import SQL: GET_DDL of each original schema, read by Schemity's SQL parser and mapped into a diagram the way the app maps it, with no connection.

Schemity's Import SQL summary for the feature schema's GET_DDL script on an empty Snowflake diagram: CUSTOMERS, DAILY_REVENUE, ORDERS, ORDER_ITEMS, PRODUCTS and RAW_EVENTS each marked New with 8, 4, 7, 6, 6 and 6 fields, the note that the CREATE SEQUENCE for ORDER_SEQ was kept because a column default draws on it, one skipped statement that is not a table, and Infer virtual relations from column names ticked

The diagram's SQL, as View SQL writes it, then ran in a fresh schema on the same account. The runs used Schemity's own code from a development build, called from scripts rather than through the app's window, and include the step the app takes when it turns a parsed table into a diagram. The earlier posts' scripts skipped it, and that step reordered every table's columns until the fix described in the SQLite post. The fixes described below are in that build and ship in the next release; until then, the released app has the losses in the "before" column.

How do you prove a rebuilt Snowflake schema is the same?

The same two checks as the earlier posts, with one change Snowflake forces on the second.

The catalog diff. A script lists one line per fact for the original schema and for the copy, from INFORMATION_SCHEMA and Snowflake's SHOW commands: every table with whether it is transient, its clustering key and its comment; every column with its position, type, length or precision, nullability, default, identity start, step and order, collation and comment; every primary, unique and foreign key by its columns; every sequence with its start and step. The two lists are diffed line by line.

The behaviour probes. A probe is a few statements in one transaction that is rolled back, ending in a SELECT of what was stored or in Snowflake's error. On standard tables Snowflake records primary, unique and foreign keys but does not enforce them; it enforces NOT NULL and CHECK. So a probe here cannot ask which duplicate a copy refuses, as the earlier posts did. It asks what a value becomes: the ids an identity hands out, whether a case-insensitive collation finds a match, what a NUMBER(12,2) stores for 10.005, what a TIMESTAMP_TZ(3) keeps of 10:00:00.123456 +02:00, and whether a row that leaves out a key is refused.

Check What it compares What it catches
Catalog diff Tables, columns, keys and sequences as Snowflake reports them Anything missing, added or changed in the schema
Probes What each schema stores, numbers and refuses An identity, default, collation or type that behaves differently

There are 14 probes: 12 for the feature schema and 2 for TPC-DS. An identity or a sequence hands out its numbers even inside a rolled-back transaction, so each copy of the feature schema was rebuilt fresh before its probes ran; TPC-DS has neither.

What failed before the fixes?

Everything below is from Schemity's code just before these fixes (commit 3360cb61).

Snowflake stopped the feature schema's script partway. It created the first three tables and stopped at ORDER_ITEMS with:

002003 (42S02): SQL compilation error: Table 'SPIKE_DB.FEATURES_BEFORE.ORDERS' does not exist or not authorized.
Enter fullscreen mode Exit fullscreen mode

Import SQL had not read ORDERS at all, so ORDER_ITEMS's foreign key named a table the script never created. With that one key removed by hand the script ran, and the copy could be compared.

TPC-DS Feature schema
Script ran yes stopped at the fourth table, error 002003; ran once patched
Tables lost 0 1, ORDERS
Clustering keys lost 24 of 24 2 of 2
TRANSIENT dropped - 1 of 1
Keys given an identity they do not have 17 0
Identity start and step lost - 1
Collation dropped - 1
Catalog lines that differ 82 22
Probes that match the original 0 of 2 5 of 12

The probes that failed are the ones that matter to the data:

Probe Original Before the fixes After
Feature schema: the first two customer ids 100, 105 1, 2 100, 105
Feature schema: 'A@X.COM' finds 'a@x.com' 1 row 0 rows 1 row
Feature schema: the 5 probes on ORDERS stored or refused as declared refused, no table stored or refused as declared
TPC-DS: a row in CALL_CENTER or DATE_DIM without its key refused, error 100072 accepted, key 1 refused

In the tables that were read, NOT NULL, every type, length, precision and scale, defaults, VARIANT, OBJECT and ARRAY, TIMESTAMP_LTZ(9) and TIMESTAMP_NTZ(6) with their precision, TIME(0), BINARY(16), every primary, unique and foreign key and the column order all survived; ORDERS, with its TIMESTAMP_TZ(3), its keys and its sequence default, was lost whole. Each failure had its own cause.

Why did a table with a clustering key disappear?

Because the clause was in a place the parser did not expect it. GET_DDL writes a table's clustering key between its name and its column list, create or replace TABLE ORDERS cluster by (TO_DATE(ORDERED_AT))(, and Snowflake allows an expression there, not only column names. The SQL parser Schemity uses reads only column names in that clause, so it refused the statement, and Import SQL listed ORDERS as a table it could not read. TPC-DS's clustering keys are plain columns, so its tables were read, but the clause was then dropped with nothing to hold it. Import SQL now takes CLUSTER BY (...) out of the statement wherever it stands, keeps its text exactly as written, since Snowflake stores the key's text and spacing, and the writer puts it back after the column list.

Why did a staging table become permanent?

Because TRANSIENT was read and then dropped. A transient table has no Fail-safe period and at most one day of Time Travel, so it costs less to keep and cannot be recovered once its Time Travel period of at most a day has passed; it is what raw and staging data is usually loaded into. The copy's RAW_EVENTS came back as a permanent table, with Fail-safe storage the original never paid for. The diagram now keeps TRANSIENT with the table's clustering key and writes CREATE TRANSIENT TABLE.

View SQL of RAW_EVENTS in Schemity: CREATE TRANSIENT TABLE RAW_EVENTS with EVENT_ID, CUSTOMER_ID, PAYLOAD VARIANT, TAGS ARRAY, SOURCE with DEFAULT 'web' and LOADED_AT TIMESTAMP_NTZ(6) in the script's order, the primary key, and CLUSTER BY (LOADED_AT) after the column list, with no foreign key for CUSTOMER_ID

Why did TPC-DS's keys start numbering themselves?

Because a Snowflake diagram did not record identity, and the writer guessed. It gave AUTOINCREMENT to every single-column whole-number primary key, which is right for a table drawn in the editor and wrong for 17 of TPC-DS's 24 tables, whose surrogate keys come with the data being loaded; the other 7, the sales, returns and inventory tables, have composite keys. A row that leaves CC_CALL_CENTER_SK out is refused by the original:

100072 (22000): DML operation to table CALL_CENTER failed on column CC_CALL_CENTER_SK with error: NULL result in a non-nullable column
Enter fullscreen mode Exit fullscreen mode

The copy accepted it and gave it key 1, which the next load's real key 1 would then duplicate, and Snowflake would not refuse that either. The same missing record lost the feature schema's ID NUMBER(38,0) autoincrement start 100 increment 5 noorder: written back as plain AUTOINCREMENT, which starts at 1 and steps by 1, it numbered the first customers 1 and 2 where the original numbered them 100 and 105 (a NOORDER identity does not promise consecutive values, but it starts where it is told). The diagram now records a Snowflake column's identity as declared, its start, step and ORDER or NOORDER, and records that a whole-number column has none, as it already did for PostgreSQL and SQL Server. Editing such a field in the editor keeps it.

View SQL of TPC-DS's CALL_CENTER in Schemity, the first 32 of its 36 lines: CREATE TABLE CALL_CENTER with CC_CALL_CENTER_SK NUMBER(38,0) NOT NULL and no AUTOINCREMENT, followed by the rest of its columns in their declared order

Why did a case-insensitive email stop matching?

Because the column's collation was read and not written. EMAIL VARCHAR(255) COLLATE 'en-ci' compares without regard to case, so a lookup for 'A@X.COM' finds 'a@x.com'; the copy's plain VARCHAR(255) compared case and found nothing, which is the difference between a login that works and one that does not. The writer now writes a Snowflake column's COLLATE as declared.

View SQL of CUSTOMERS in Schemity: ID NUMBER(38,0) AUTOINCREMENT START 100 INCREMENT 5 NOORDER NOT NULL, EMAIL VARCHAR(255) COLLATE 'en-ci' NOT NULL, the defaults 'US', 0, true and CURRENT_TIMESTAMP(), the primary key CUSTOMERS_PK and the unique key CUSTOMERS_EMAIL_UK, with no comments

What happened to the sequence?

It was skipped, and the default that drew on it had two more problems. ORDERS.ID defaults to ORDER_SEQ.NEXTVAL, a sequence that starts at 1000 and steps by 10, and GET_DDL writes that default with the original's database and schema, SPIKE_DB.FEATURES_ORIG.ORDER_SEQ.NEXTVAL, so a copy elsewhere would draw on the original's sequence. And the writer, which ORDERS never reached here, wrote such a default in quotes, as the text 'ORDER_SEQ.NEXTVAL'; the core now has a test for it. Import SQL now keeps each sequence the script creates as setup SQL, which the diagram's SQL runs first as CREATE SEQUENCE IF NOT EXISTS with the script's own start and step; a default reads as the sequence alone; and the writer leaves a NEXTVAL call unquoted. The copy's first order is 1000, as the original's.

View SQL of ORDERS in Schemity: CREATE SEQUENCE IF NOT EXISTS ORDER_SEQ start with 1000 increment by 10 noorder, then CREATE TABLE ORDERS with ID NUMBER(38,0) NOT NULL DEFAULT ORDER_SEQ.NEXTVAL, the columns in the script's order, the primary key, the foreign key to CUSTOMERS, and CLUSTER BY (TO_DATE(ORDERED_AT)) after the column list

What does the round trip give you now?

The same schema, for both, apart from two comments, from SQL that ran on Snowflake as Schemity wrote it. Neither script needed an edit or raised an error: TPC-DS's 24 CREATE TABLE statements built all 24 tables, 425 columns, 24 primary keys and 105 foreign keys, and the feature schema's one CREATE SEQUENCE and six CREATE TABLE statements built its sequence, all 6 tables and every key.

TPC-DS Feature schema
Catalog differences none the table's comment and a column's comment
Probes that match the original 2 of 2 12 of 12

All 24 of TPC-DS's clustering keys and its 17 surrogate keys without an identity come back as they were, as do the feature schema's expression clustering key, its transient table, its identity starting at 100 by 5, its case-insensitive collation and its sequence starting at 1000 by 10. The catalog includes each column's position, and every table comes back in its declared column order.

How do you see relationships in a Snowflake schema with no foreign keys?

By reading them from the column names. Snowflake does not enforce a FOREIGN KEY on a standard table, as the probes above rely on, so nothing stops a row that points nowhere, and plenty of Snowflake schemas declare no foreign keys at all. Raw landing tables and models built by ELT tools are the usual cases. Imported as written, such a schema is a grid of tables with no lines between them.

The feature schema has one: RAW_EVENTS.CUSTOMER_ID holds a customer's id, but the transient landing table declares no key for it. With Infer virtual relations from column names ticked in Import SQL, Schemity matched CUSTOMER_ID to CUSTOMERS and its primary key ID, and drew a dashed relation, zero-or-one at the customer end because the column allows NULL. A virtual relation lives only in the diagram. The SQL Schemity writes leaves it out, so the round trip above is unchanged, and the diagram still shows where the data joins.

The feature schema's diagram in Schemity: CUSTOMERS, PRODUCTS, ORDERS, ORDER_ITEMS, RAW_EVENTS and DAILY_REVENUE with their columns and defaults, solid relations for the three declared foreign keys, and a dashed relation from CUSTOMERS to RAW_EVENTS inferred from RAW_EVENTS.CUSTOMER_ID; the status bar reads 6 entities and 4 relations

What still differs, and why?

Comments. The feature schema's table comment and its column comment on LIFETIME_VALUE are read into the diagram as descriptions, and Schemity does not write descriptions to any database: they are the diagram's own notes, on Snowflake as on the other four. A copy carries the columns and keys, not the documentation, which stays in the diagram file.

What this benchmark does not show

  • The numbers through the app's window. The catalog diffs and probes came from scripts calling Schemity's import, mapping and writer code on a development build. The screenshots are that build's app, importing the same GET_DDL scripts, and the SQL file it exported for each diagram also ran by hand in a Snowflake worksheet with no error, building all 24 TPC-DS tables and all 6 of the feature schema's.
  • A live connection. Schemity has none to Snowflake; a schema comes in through Import SQL of GET_DDL, and the diagram's SQL is run by you.
  • Data. The originals are empty apart from the probes' rolled-back rows. A schema that matches is the precondition for loading data, not proof that a load is safe.
  • CHECK constraints, dynamic tables, views and stages. Neither schema has a CHECK; Snowflake enforces them, so they deserve their own test. Dynamic tables and views are not tables a diagram designs.
  • Other databases. The other four have their own posts, linked above, and this is the last in the series.

How to rerun it

Build each original in an empty schema, take SELECT GET_DDL('schema', '<db>.<schema>') as the script, put it through Schemity's Import SQL, take the diagram's SQL from View SQL and run it in another empty schema. Then list both catalogs with this query, a shorter form of the benchmark's, and diff the output. It leaves out keys, sequences and comments, which the benchmark reads from SHOW PRIMARY KEYS, SHOW IMPORTED KEYS and SHOW SEQUENCES:

SELECT line FROM (
  SELECT 'table | ' || table_name || ' | ' || is_transient || ' | ' || coalesce(clustering_key, 'no clustering key') AS line
    FROM information_schema.tables
   WHERE table_schema = CURRENT_SCHEMA() AND table_type = 'BASE TABLE'
  UNION ALL
  SELECT 'column | ' || table_name || ' | ' || ordinal_position || ' | ' || column_name || ' | ' || data_type
         || ' | ' || coalesce(to_varchar(character_maximum_length), to_varchar(numeric_precision) || ',' || to_varchar(numeric_scale), to_varchar(datetime_precision), '')
         || ' | ' || is_nullable
         -- A default drawing on a sequence names the schema it is in.
         || ' | ' || coalesce(replace(column_default, current_database() || '.' || current_schema() || '.', ''), 'no default')
         || ' | identity ' || coalesce(identity_start || ',' || identity_increment || ',' || identity_ordered, 'none')
         || ' | ' || coalesce(collation_name, 'no collation')
    FROM information_schema.columns
   WHERE table_schema = CURRENT_SCHEMA()
) ORDER BY line;
Enter fullscreen mode Exit fullscreen mode

For behaviour, run each probe between BEGIN and ROLLBACK, end it with a SELECT of what was stored, and rebuild the schema before running the probes again: an identity and a sequence keep the numbers a rolled-back transaction drew.

Top comments (0)