Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions converters/snowflake/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ keywords = [
]
dependencies = [
"PyYAML>=5.0",
"sqlglot>=30.12.0",
]

[project.scripts]
Expand Down
1 change: 1 addition & 0 deletions converters/snowflake/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@
# under the License.

PyYAML>=5.0
sqlglot>=30.12.0
43 changes: 36 additions & 7 deletions converters/snowflake/src/ossie_snowflake/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@
"""

import argparse
import re
import sys
import warnings

import yaml
from sqlglot import tokenize
from sqlglot.errors import TokenError
from sqlglot.tokens import TokenType


SUPPORTED_VERSION = "0.2.0.dev0"
Expand Down Expand Up @@ -421,6 +425,30 @@ def _normalize_identifier(identifier):
return stripped
return stripped.upper()

_UNQUOTED_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
_QUOTED_IDENTIFIER = re.compile(r'^"(?:[^"]|"")+"$')


def _is_query_source(source_stripped):
"""Recognize SELECT/WITH sources without requiring a full SQL parse."""
# Use Snowflake's comment and identifier rules, including `$` in names.
# Full parsing could reject newer Snowflake syntax that should pass through.
try:
tokens = tokenize(source_stripped, read="snowflake")
except TokenError:
return False

for token in tokens:
if token.token_type != TokenType.L_PAREN:
return token.token_type in (TokenType.SELECT, TokenType.WITH)
return False


def _is_identifier(part):
"""True if `part` is a valid quoted or unquoted Snowflake identifier."""
return bool(_UNQUOTED_IDENTIFIER.match(part) or _QUOTED_IDENTIFIER.match(part))


def _split_identifiers(source_str):
"""Split a dot-separated identifier string while respecting double quotes."""
parts = []
Expand Down Expand Up @@ -451,15 +479,15 @@ def _parse_source(source):
if not source_stripped:
return None

# Detect subqueries — require whitespace after the keyword to avoid false
# positives on table names like WITH_TABLE or SELECT_RESULTS.
upper = source_stripped.upper()
if upper.startswith(("SELECT ", "SELECT\n", "SELECT\t",
"WITH ", "WITH\n", "WITH\t")):
# Preserve query text, including comments, after trimming outer whitespace.
if _is_query_source(source_stripped):
return {"definition": source_stripped}

# Anything else must be a relation name whose three parts are real
# identifiers; otherwise SQL text that was not recognised as a query would
# silently become a bogus database/schema/table.
parts = _split_identifiers(source_stripped)
if len(parts) == 3:
if len(parts) == 3 and all(_is_identifier(part) for part in parts):
# Only uppercase unquoted identifiers; preserve quoted ones as-is.
return {
"database": _normalize_identifier(parts[0]),
Expand All @@ -468,7 +496,8 @@ def _parse_source(source):
}

raise OssieConversionError(
f"Source '{source}' must be a fully qualified db.schema.table or a subquery"
f"Source '{source}' must be a fully qualified db.schema.table "
"(quoted or unquoted identifiers) or a SELECT/WITH query"
)


Expand Down
130 changes: 130 additions & 0 deletions converters/snowflake/tests/test_ossie_to_snowflake_yaml_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,104 @@ def test_table_starting_with_select_not_subquery(self):
with pytest.raises(OssieConversionError, match="fully qualified"):
_parse_source("SELECT_RESULTS")

@pytest.mark.parametrize("source, database", [
("select_results.public.t", "SELECT_RESULTS"),
("select$archive.public.t", "SELECT$ARCHIVE"),
("with$archive.public.t", "WITH$ARCHIVE"),
("select1.public.t", "SELECT1"),
("SELECT$.public.t", "SELECT$"),
])
def test_table_named_like_keyword_prefix_is_a_relation(self, source, database):
# Snowflake allows `_`, digits and `$` after the first character of an
# unquoted identifier, so these are tables, not queries.
assert _parse_source(source) == {"database": database, "schema": "PUBLIC", "table": "T"}

@pytest.mark.parametrize("source", [
"-- revenue source\nSELECT amount FROM db.schema.orders",
"/* revenue source */ SELECT amount FROM db.schema.orders",
"// revenue source\nSELECT amount FROM db.schema.orders",
"-- first\n -- second\n/* third */\nWITH c AS (SELECT 1) SELECT * FROM c",
"SELECT\r\namount FROM db.schema.orders",
"WITH\r\nc AS (SELECT 1 AS amount) SELECT amount FROM c",
"SELECT*FROM db.schema.orders",
"SELECT/*c*/ amount FROM db.schema.orders",
"(SELECT amount FROM db.schema.orders)",
"( -- inner\n select amount from db.schema.orders )",
"select amount from db.schema.orders",
])
def test_query_text_is_preserved_verbatim_as_definition(self, source):
# Leading comments, CRLF, missing whitespace after the keyword, and
# parentheses must not turn a query into a physical table reference.
assert _parse_source(source) == {"definition": source}

def test_query_with_leading_comment_and_fewer_dots_is_still_a_query(self):
source = "-- revenue\nSELECT 1 AS amount"
assert _parse_source(source) == {"definition": source}

@pytest.mark.parametrize("comment", ["-- revenue", "// revenue"])
@pytest.mark.parametrize("newline", ["\n", "\r\n", "\r"])
@pytest.mark.parametrize("query", [
"SELECT 1 AS amount",
"WITH c AS (SELECT 1 AS amount) SELECT amount FROM c",
])
def test_line_comments_with_supported_line_endings(self, comment, newline, query):
source = comment + newline + query
assert _parse_source(source) == {"definition": source}

@pytest.mark.parametrize("source", [
"/* first */ ( // second\r\n ( -- third\n SELECT 1 ))",
"SELECT '-- not a comment' AS amount",
"SELECT $$// literal\n/* still literal */$$ AS amount",
"SELECT 1 AS amount; -- trailing comment",
"(SELECT 1 AS amount) UNION ALL (SELECT 2 AS amount)",
"SELECT * FROM weather RESAMPLE(USING observed_at INCREMENT BY INTERVAL '1 day')",
])
def test_query_contents_do_not_require_parsing_or_rewriting(self, source):
assert _parse_source(source) == {"definition": source}

def test_query_trims_only_outer_whitespace(self):
source = " \n// revenue\r\nSELECT 'Mixed Case' AS amount;\n\t"
assert _parse_source(source) == {
"definition": "// revenue\r\nSELECT 'Mixed Case' AS amount;"
}

@pytest.mark.parametrize("database", [
'"select"',
'"my"".db"',
'"/*db*/"',
])
def test_quoted_database_stays_a_relation(self, database):
assert _parse_source(f"{database}.public.orders") == {
"database": database, "schema": "PUBLIC", "table": "ORDERS"
}

@pytest.mark.parametrize("source", [
"-- comment only",
"// comment only",
"/* comment only */",
"( /* comment only */ )",
"/* unclosed comment SELECT * FROM db.schema.orders",
"SELECT 'unterminated FROM db.schema.orders",
'SELECT "unterminated FROM db.schema.orders',
"SELECT $$unterminated FROM db.schema.orders",
])
def test_unrecognizable_or_untokenizable_source_raises_conversion_error(self, source):
with pytest.raises(OssieConversionError, match="fully qualified"):
_parse_source(source)

@pytest.mark.parametrize("source", [
"-- c\nSELEC amount FROM db.schema.orders", # typo: neither query nor relation
"foo bar.schema.table", # whitespace inside an unquoted part
"db.schema.table;", # trailing statement terminator
"1db.schema.table", # unquoted identifier cannot start with a digit
])
def test_relation_shaped_garbage_is_rejected_not_uppercased(self, source):
with pytest.raises(OssieConversionError, match="fully qualified"):
_parse_source(source)

def test_dollar_sign_allowed_in_unquoted_identifier(self):
assert _parse_source("db$1.sch_2.t$") == {"database": "DB$1", "schema": "SCH_2", "table": "T$"}


# ---------------------------------------------------------------------------
# _extract_synonyms
Expand Down Expand Up @@ -732,6 +830,38 @@ def test_subquery_source(self):
result = yaml.safe_load(convert_ossie_to_snowflake(_wrap_ossie(model)))
assert "definition" in result["tables"][0]["base_table"]

@pytest.mark.parametrize("source", [
"-- revenue source\nSELECT * FROM db.s.t WHERE active = 1",
"// revenue source\rSELECT * FROM db.s.t WHERE active = 1",
"/* weather */ SELECT * FROM db.s.t RESAMPLE(USING observed_at INCREMENT BY INTERVAL '1 day')",
])
def test_subquery_source_with_leading_comment_keeps_definition(self, source):
model = {
"name": "m",
"datasets": [
{
"name": "t",
"source": source,
"fields": [
{
"name": "c",
"expression": {
"dialects": [
{"dialect": "ANSI_SQL", "expression": "c"}
]
},
"dimension": {"is_time": False},
}
],
}
],
}
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = yaml.safe_load(convert_ossie_to_snowflake(_wrap_ossie(model)))
assert result["tables"][0]["base_table"] == {"definition": source}
assert not [w for w in caught if "source" in str(w.message).lower()]


# ---------------------------------------------------------------------------
# _warn_dropped_fields (Ossie concepts with no Snowflake counterpart)
Expand Down
15 changes: 14 additions & 1 deletion converters/snowflake/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.