fakesnow 0.9.20__tar.gz → 0.9.21__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fakesnow-0.9.20 → fakesnow-0.9.21}/PKG-INFO +8 -3
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/__init__.py +8 -14
- fakesnow-0.9.21/fakesnow/arrow.py +32 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/fakes.py +12 -5
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/info_schema.py +4 -4
- fakesnow-0.9.21/fakesnow/instance.py +92 -0
- fakesnow-0.9.21/fakesnow/server.py +108 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/transforms.py +20 -7
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/variables.py +19 -6
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow.egg-info/PKG-INFO +8 -3
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow.egg-info/SOURCES.txt +5 -1
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow.egg-info/requires.txt +8 -2
- {fakesnow-0.9.20 → fakesnow-0.9.21}/pyproject.toml +12 -3
- fakesnow-0.9.21/tests/test_arrow.py +53 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_connect.py +9 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_fakes.py +7 -4
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_info_schema.py +19 -0
- fakesnow-0.9.21/tests/test_server.py +53 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_transforms.py +12 -4
- fakesnow-0.9.20/fakesnow/global_database.py +0 -46
- {fakesnow-0.9.20 → fakesnow-0.9.21}/LICENSE +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/README.md +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/__main__.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/checks.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/cli.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/expr.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/fixtures.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/macros.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow/py.typed +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow.egg-info/dependency_links.txt +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow.egg-info/entry_points.txt +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/fakesnow.egg-info/top_level.txt +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/setup.cfg +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_checks.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_cli.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_expr.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_patch.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_sqlalchemy.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_users.py +0 -0
- {fakesnow-0.9.20 → fakesnow-0.9.21}/tests/test_write_pandas.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: fakesnow
|
3
|
-
Version: 0.9.
|
3
|
+
Version: 0.9.21
|
4
4
|
Summary: Fake Snowflake Connector for Python. Run, mock and test Snowflake DB locally.
|
5
5
|
License: Apache License
|
6
6
|
Version 2.0, January 2004
|
@@ -213,20 +213,25 @@ License-File: LICENSE
|
|
213
213
|
Requires-Dist: duckdb~=1.0.0
|
214
214
|
Requires-Dist: pyarrow
|
215
215
|
Requires-Dist: snowflake-connector-python
|
216
|
-
Requires-Dist: sqlglot~=25.
|
216
|
+
Requires-Dist: sqlglot~=25.5.1
|
217
217
|
Provides-Extra: dev
|
218
218
|
Requires-Dist: build~=1.0; extra == "dev"
|
219
219
|
Requires-Dist: pandas-stubs; extra == "dev"
|
220
220
|
Requires-Dist: snowflake-connector-python[pandas,secure-local-storage]; extra == "dev"
|
221
221
|
Requires-Dist: pre-commit~=3.4; extra == "dev"
|
222
|
+
Requires-Dist: pyarrow-stubs; extra == "dev"
|
222
223
|
Requires-Dist: pytest~=8.0; extra == "dev"
|
223
|
-
Requires-Dist:
|
224
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
225
|
+
Requires-Dist: ruff~=0.5.1; extra == "dev"
|
224
226
|
Requires-Dist: twine~=5.0; extra == "dev"
|
225
227
|
Requires-Dist: snowflake-sqlalchemy~=1.5.0; extra == "dev"
|
226
228
|
Provides-Extra: notebook
|
227
229
|
Requires-Dist: duckdb-engine; extra == "notebook"
|
228
230
|
Requires-Dist: ipykernel; extra == "notebook"
|
229
231
|
Requires-Dist: jupysql; extra == "notebook"
|
232
|
+
Provides-Extra: server
|
233
|
+
Requires-Dist: starlette; extra == "server"
|
234
|
+
Requires-Dist: uvicorn; extra == "server"
|
230
235
|
|
231
236
|
# fakesnow ❄️
|
232
237
|
|
@@ -8,12 +8,11 @@ import unittest.mock as mock
|
|
8
8
|
from collections.abc import Iterator, Sequence
|
9
9
|
from contextlib import contextmanager
|
10
10
|
|
11
|
-
import duckdb
|
12
11
|
import snowflake.connector
|
13
12
|
import snowflake.connector.pandas_tools
|
14
13
|
|
15
14
|
import fakesnow.fakes as fakes
|
16
|
-
from fakesnow.
|
15
|
+
from fakesnow.instance import FakeSnow
|
17
16
|
|
18
17
|
|
19
18
|
@contextmanager
|
@@ -52,20 +51,15 @@ def patch(
|
|
52
51
|
# won't be able to patch extra targets
|
53
52
|
assert not isinstance(snowflake.connector.connect, mock.MagicMock), "Snowflake connector is already patched"
|
54
53
|
|
55
|
-
|
56
|
-
|
54
|
+
fs = FakeSnow(
|
55
|
+
create_database_on_connect=create_database_on_connect,
|
56
|
+
create_schema_on_connect=create_schema_on_connect,
|
57
|
+
db_path=db_path,
|
58
|
+
nop_regexes=nop_regexes,
|
59
|
+
)
|
57
60
|
|
58
61
|
fake_fns = {
|
59
|
-
|
60
|
-
# schema setting, see https://duckdb.org/docs/api/python/overview.html#startup--shutdown
|
61
|
-
snowflake.connector.connect: lambda **kwargs: fakes.FakeSnowflakeConnection(
|
62
|
-
duck_conn.cursor(),
|
63
|
-
create_database=create_database_on_connect,
|
64
|
-
create_schema=create_schema_on_connect,
|
65
|
-
db_path=db_path,
|
66
|
-
nop_regexes=nop_regexes,
|
67
|
-
**kwargs,
|
68
|
-
),
|
62
|
+
snowflake.connector.connect: fs.connect,
|
69
63
|
snowflake.connector.pandas_tools.write_pandas: fakes.write_pandas,
|
70
64
|
}
|
71
65
|
|
@@ -0,0 +1,32 @@
|
|
1
|
+
import pyarrow as pa
|
2
|
+
|
3
|
+
|
4
|
+
def with_sf_metadata(schema: pa.Schema) -> pa.Schema:
|
5
|
+
# see https://github.com/snowflakedb/snowflake-connector-python/blob/e9393a6/src/snowflake/connector/nanoarrow_cpp/ArrowIterator/CArrowTableIterator.cpp#L32
|
6
|
+
# and https://github.com/snowflakedb/snowflake-connector-python/blob/e9393a6/src/snowflake/connector/nanoarrow_cpp/ArrowIterator/SnowflakeType.cpp#L10
|
7
|
+
fms = []
|
8
|
+
for i, t in enumerate(schema.types):
|
9
|
+
f = schema.field(i)
|
10
|
+
|
11
|
+
if isinstance(t, pa.Decimal128Type):
|
12
|
+
fm = f.with_metadata({"logicalType": "FIXED", "precision": str(t.precision), "scale": str(t.scale)})
|
13
|
+
elif t == pa.string():
|
14
|
+
fm = f.with_metadata({"logicalType": "TEXT"})
|
15
|
+
else:
|
16
|
+
raise NotImplementedError(f"Unsupported Arrow type: {t}")
|
17
|
+
fms.append(fm)
|
18
|
+
return pa.schema(fms)
|
19
|
+
|
20
|
+
|
21
|
+
def to_ipc(table: pa.Table) -> pa.Buffer:
|
22
|
+
batches = table.to_batches()
|
23
|
+
if len(batches) != 1:
|
24
|
+
raise NotImplementedError(f"{len(batches)} batches")
|
25
|
+
batch = batches[0]
|
26
|
+
|
27
|
+
sink = pa.BufferOutputStream()
|
28
|
+
|
29
|
+
with pa.ipc.new_stream(sink, with_sf_metadata(batch.schema)) as writer:
|
30
|
+
writer.write_batch(batch)
|
31
|
+
|
32
|
+
return sink.getvalue()
|
@@ -203,6 +203,7 @@ class FakeSnowflakeCursor:
|
|
203
203
|
.transform(transforms.sha256)
|
204
204
|
.transform(transforms.create_clone)
|
205
205
|
.transform(transforms.alias_in_join)
|
206
|
+
.transform(transforms.alter_table_strip_cluster_by)
|
206
207
|
)
|
207
208
|
|
208
209
|
def _execute(
|
@@ -522,9 +523,14 @@ class FakeSnowflakeConnection:
|
|
522
523
|
):
|
523
524
|
self._duck_conn = duck_conn
|
524
525
|
# upper case database and schema like snowflake unquoted identifiers
|
525
|
-
#
|
526
|
+
# so they appear as upper-cased in information_schema
|
527
|
+
# catalog and schema names are not actually case-sensitive in duckdb even though
|
528
|
+
# they are as cased in information_schema.schemata, so when selecting from
|
529
|
+
# information_schema.schemata below we use upper-case to match any existing duckdb
|
530
|
+
# catalog or schemas like "information_schema"
|
526
531
|
self.database = database and database.upper()
|
527
532
|
self.schema = schema and schema.upper()
|
533
|
+
|
528
534
|
self.database_set = False
|
529
535
|
self.schema_set = False
|
530
536
|
self.db_path = Path(db_path) if db_path else None
|
@@ -538,7 +544,7 @@ class FakeSnowflakeConnection:
|
|
538
544
|
and self.database
|
539
545
|
and not duck_conn.execute(
|
540
546
|
f"""select * from information_schema.schemata
|
541
|
-
where catalog_name = '{self.database}'"""
|
547
|
+
where upper(catalog_name) = '{self.database}'"""
|
542
548
|
).fetchone()
|
543
549
|
):
|
544
550
|
db_file = f"{self.db_path/self.database}.db" if self.db_path else ":memory:"
|
@@ -553,7 +559,7 @@ class FakeSnowflakeConnection:
|
|
553
559
|
and self.schema
|
554
560
|
and not duck_conn.execute(
|
555
561
|
f"""select * from information_schema.schemata
|
556
|
-
where catalog_name = '{self.database}' and schema_name = '{self.schema}'"""
|
562
|
+
where upper(catalog_name) = '{self.database}' and upper(schema_name) = '{self.schema}'"""
|
557
563
|
).fetchone()
|
558
564
|
):
|
559
565
|
duck_conn.execute(f"CREATE SCHEMA {self.database}.{self.schema}")
|
@@ -564,7 +570,7 @@ class FakeSnowflakeConnection:
|
|
564
570
|
and self.schema
|
565
571
|
and duck_conn.execute(
|
566
572
|
f"""select * from information_schema.schemata
|
567
|
-
where catalog_name = '{self.database}' and schema_name = '{self.schema}'"""
|
573
|
+
where upper(catalog_name) = '{self.database}' and upper(schema_name) = '{self.schema}'"""
|
568
574
|
).fetchone()
|
569
575
|
):
|
570
576
|
duck_conn.execute(f"SET schema='{self.database}.{self.schema}'")
|
@@ -575,7 +581,7 @@ class FakeSnowflakeConnection:
|
|
575
581
|
self.database
|
576
582
|
and duck_conn.execute(
|
577
583
|
f"""select * from information_schema.schemata
|
578
|
-
where catalog_name = '{self.database}'"""
|
584
|
+
where upper(catalog_name) = '{self.database}'"""
|
579
585
|
).fetchone()
|
580
586
|
):
|
581
587
|
duck_conn.execute(f"SET schema='{self.database}.main'")
|
@@ -602,6 +608,7 @@ class FakeSnowflakeConnection:
|
|
602
608
|
self.cursor().execute("COMMIT")
|
603
609
|
|
604
610
|
def cursor(self, cursor_class: type[SnowflakeCursor] = SnowflakeCursor) -> FakeSnowflakeCursor:
|
611
|
+
# TODO: use duck_conn cursor for thread-safety
|
605
612
|
return FakeSnowflakeCursor(conn=self, duck_conn=self._duck_conn, use_dict_result=cursor_class == DictCursor)
|
606
613
|
|
607
614
|
def execute_string(
|
@@ -62,8 +62,8 @@ case when columns.data_type='BIGINT' then 10
|
|
62
62
|
case when columns.data_type='DOUBLE' then NULL else columns.numeric_scale end as numeric_scale,
|
63
63
|
collation_name, is_identity, identity_generation, identity_cycle,
|
64
64
|
ddb_columns.comment as comment,
|
65
|
-
null as identity_start,
|
66
|
-
null as identity_increment,
|
65
|
+
null::VARCHAR as identity_start,
|
66
|
+
null::VARCHAR as identity_increment,
|
67
67
|
from ${catalog}.information_schema.columns columns
|
68
68
|
left join ${catalog}.information_schema._fs_columns_ext ext
|
69
69
|
on ext_table_catalog = columns.table_catalog
|
@@ -86,7 +86,7 @@ select
|
|
86
86
|
catalog_name as database_name,
|
87
87
|
'SYSADMIN' as database_owner,
|
88
88
|
'NO' as is_transient,
|
89
|
-
null as comment,
|
89
|
+
null::VARCHAR as comment,
|
90
90
|
to_timestamp(0)::timestamptz as created,
|
91
91
|
to_timestamp(0)::timestamptz as last_altered,
|
92
92
|
1 as retention_time,
|
@@ -116,7 +116,7 @@ select
|
|
116
116
|
to_timestamp(0)::timestamptz as last_altered,
|
117
117
|
to_timestamp(0)::timestamptz as last_ddl,
|
118
118
|
'SYSADMIN' as last_ddl_by,
|
119
|
-
null as comment
|
119
|
+
null::VARCHAR as comment
|
120
120
|
from duckdb_views
|
121
121
|
where database_name = '${catalog}'
|
122
122
|
and schema_name != 'information_schema'
|
@@ -0,0 +1,92 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import os
|
4
|
+
from typing import Any
|
5
|
+
|
6
|
+
import duckdb
|
7
|
+
|
8
|
+
import fakesnow.fakes as fakes
|
9
|
+
|
10
|
+
GLOBAL_DATABASE_NAME = "_fs_global"
|
11
|
+
USERS_TABLE_FQ_NAME = f"{GLOBAL_DATABASE_NAME}._fs_users_ext"
|
12
|
+
|
13
|
+
# replicates the output structure of https://docs.snowflake.com/en/sql-reference/sql/show-users
|
14
|
+
SQL_CREATE_INFORMATION_SCHEMA_USERS_TABLE_EXT = f"""
|
15
|
+
create table if not exists {USERS_TABLE_FQ_NAME} (
|
16
|
+
name varchar,
|
17
|
+
created_on TIMESTAMPTZ,
|
18
|
+
login_name varchar,
|
19
|
+
display_name varchar,
|
20
|
+
first_name varchar,
|
21
|
+
last_name varchar,
|
22
|
+
email varchar,
|
23
|
+
mins_to_unlock varchar,
|
24
|
+
days_to_expiry varchar,
|
25
|
+
comment varchar,
|
26
|
+
disabled varchar,
|
27
|
+
must_change_password varchar,
|
28
|
+
snowflake_lock varchar,
|
29
|
+
default_warehouse varchar,
|
30
|
+
default_namespace varchar,
|
31
|
+
default_role varchar,
|
32
|
+
default_secondary_roles varchar,
|
33
|
+
ext_authn_duo varchar,
|
34
|
+
ext_authn_uid varchar,
|
35
|
+
mins_to_bypass_mfa varchar,
|
36
|
+
owner varchar,
|
37
|
+
last_success_login TIMESTAMPTZ,
|
38
|
+
expires_at_time TIMESTAMPTZ,
|
39
|
+
locked_until_time TIMESTAMPTZ,
|
40
|
+
has_password varchar,
|
41
|
+
has_rsa_public_key varchar,
|
42
|
+
)
|
43
|
+
"""
|
44
|
+
|
45
|
+
|
46
|
+
def create_global_database(conn: duckdb.DuckDBPyConnection) -> None:
|
47
|
+
"""Create a "global" database for storing objects which span databases.
|
48
|
+
|
49
|
+
Including (but not limited to):
|
50
|
+
- Users
|
51
|
+
"""
|
52
|
+
conn.execute(f"ATTACH IF NOT EXISTS ':memory:' AS {GLOBAL_DATABASE_NAME}")
|
53
|
+
conn.execute(SQL_CREATE_INFORMATION_SCHEMA_USERS_TABLE_EXT)
|
54
|
+
|
55
|
+
|
56
|
+
class FakeSnow:
|
57
|
+
def __init__(
|
58
|
+
self,
|
59
|
+
create_database_on_connect: bool = True,
|
60
|
+
create_schema_on_connect: bool = True,
|
61
|
+
db_path: str | os.PathLike | None = None,
|
62
|
+
nop_regexes: list[str] | None = None,
|
63
|
+
):
|
64
|
+
self.create_database_on_connect = create_database_on_connect
|
65
|
+
self.create_schema_on_connect = create_schema_on_connect
|
66
|
+
self.db_path = db_path
|
67
|
+
self.nop_regexes = nop_regexes
|
68
|
+
|
69
|
+
self.duck_conn = duckdb.connect(database=":memory:")
|
70
|
+
|
71
|
+
# create a "global" database for storing objects which span databases.
|
72
|
+
self.duck_conn.execute(f"ATTACH IF NOT EXISTS ':memory:' AS {GLOBAL_DATABASE_NAME}")
|
73
|
+
self.duck_conn.execute(SQL_CREATE_INFORMATION_SCHEMA_USERS_TABLE_EXT)
|
74
|
+
|
75
|
+
def connect(
|
76
|
+
self, database: str | None = None, schema: str | None = None, **kwargs: Any
|
77
|
+
) -> fakes.FakeSnowflakeConnection:
|
78
|
+
# every time we connect, create a new cursor (ie: connection) so we can isolate each connection's
|
79
|
+
# schema setting see
|
80
|
+
# https://github.com/duckdb/duckdb/blob/18254ec/tools/pythonpkg/src/pyconnection.cpp#L1440
|
81
|
+
# and to make connections thread-safe see
|
82
|
+
# https://duckdb.org/docs/api/python/overview.html#using-connections-in-parallel-python-programs
|
83
|
+
return fakes.FakeSnowflakeConnection(
|
84
|
+
self.duck_conn.cursor(),
|
85
|
+
database,
|
86
|
+
schema,
|
87
|
+
create_database=self.create_database_on_connect,
|
88
|
+
create_schema=self.create_schema_on_connect,
|
89
|
+
db_path=self.db_path,
|
90
|
+
nop_regexes=self.nop_regexes,
|
91
|
+
**kwargs,
|
92
|
+
)
|
@@ -0,0 +1,108 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import gzip
|
4
|
+
import json
|
5
|
+
import secrets
|
6
|
+
from base64 import b64encode
|
7
|
+
from dataclasses import dataclass
|
8
|
+
|
9
|
+
from starlette.applications import Starlette
|
10
|
+
from starlette.concurrency import run_in_threadpool
|
11
|
+
from starlette.requests import Request
|
12
|
+
from starlette.responses import JSONResponse
|
13
|
+
from starlette.routing import Route
|
14
|
+
|
15
|
+
from fakesnow.arrow import to_ipc
|
16
|
+
from fakesnow.fakes import FakeSnowflakeConnection
|
17
|
+
from fakesnow.instance import FakeSnow
|
18
|
+
|
19
|
+
fs = FakeSnow()
|
20
|
+
sessions = {}
|
21
|
+
|
22
|
+
|
23
|
+
@dataclass
|
24
|
+
class ServerError(Exception):
|
25
|
+
status_code: int
|
26
|
+
code: str
|
27
|
+
message: str
|
28
|
+
|
29
|
+
|
30
|
+
def login_request(request: Request) -> JSONResponse:
|
31
|
+
database = request.query_params.get("databaseName")
|
32
|
+
schema = request.query_params.get("schemaName")
|
33
|
+
token = secrets.token_urlsafe(32)
|
34
|
+
sessions[token] = fs.connect(database, schema)
|
35
|
+
return JSONResponse({"data": {"token": token}, "success": True})
|
36
|
+
|
37
|
+
|
38
|
+
async def query_request(request: Request) -> JSONResponse:
|
39
|
+
try:
|
40
|
+
conn = to_conn(request)
|
41
|
+
|
42
|
+
body = await request.body()
|
43
|
+
body_json = json.loads(gzip.decompress(body))
|
44
|
+
|
45
|
+
sql_text = body_json["sqlText"]
|
46
|
+
|
47
|
+
# only a single sql statement is sent at a time by the python snowflake connector
|
48
|
+
cur = await run_in_threadpool(conn.cursor().execute, sql_text)
|
49
|
+
|
50
|
+
assert cur._arrow_table, "No result set" # noqa: SLF001
|
51
|
+
|
52
|
+
batch_bytes = to_ipc(cur._arrow_table) # noqa: SLF001
|
53
|
+
rowset_b64 = b64encode(batch_bytes).decode("utf-8")
|
54
|
+
|
55
|
+
return JSONResponse(
|
56
|
+
{
|
57
|
+
"data": {
|
58
|
+
"rowtype": [
|
59
|
+
{
|
60
|
+
"name": "'HELLO WORLD'",
|
61
|
+
"nullable": False,
|
62
|
+
"type": "text",
|
63
|
+
"length": 11,
|
64
|
+
"scale": None,
|
65
|
+
"precision": None,
|
66
|
+
}
|
67
|
+
],
|
68
|
+
"rowsetBase64": rowset_b64,
|
69
|
+
"total": 1,
|
70
|
+
"queryResultFormat": "arrow",
|
71
|
+
},
|
72
|
+
"success": True,
|
73
|
+
}
|
74
|
+
)
|
75
|
+
|
76
|
+
except ServerError as e:
|
77
|
+
return JSONResponse(
|
78
|
+
{"data": None, "code": e.code, "message": e.message, "success": False, "headers": None},
|
79
|
+
status_code=e.status_code,
|
80
|
+
)
|
81
|
+
|
82
|
+
|
83
|
+
def to_conn(request: Request) -> FakeSnowflakeConnection:
|
84
|
+
if not (auth := request.headers.get("Authorization")):
|
85
|
+
raise ServerError(status_code=401, code="390103", message="Session token not found in the request data.")
|
86
|
+
|
87
|
+
token = auth[17:-1]
|
88
|
+
|
89
|
+
if not (conn := sessions.get(token)):
|
90
|
+
raise ServerError(status_code=401, code="390104", message="User must login again to access the service.")
|
91
|
+
|
92
|
+
return conn
|
93
|
+
|
94
|
+
|
95
|
+
routes = [
|
96
|
+
Route(
|
97
|
+
"/session/v1/login-request",
|
98
|
+
login_request,
|
99
|
+
methods=["POST"],
|
100
|
+
),
|
101
|
+
Route(
|
102
|
+
"/queries/v1/query-request",
|
103
|
+
query_request,
|
104
|
+
methods=["POST"],
|
105
|
+
),
|
106
|
+
]
|
107
|
+
|
108
|
+
app = Starlette(debug=True, routes=routes)
|
@@ -7,11 +7,11 @@ from typing import ClassVar, Literal, cast
|
|
7
7
|
import sqlglot
|
8
8
|
from sqlglot import exp
|
9
9
|
|
10
|
-
from fakesnow.
|
10
|
+
from fakesnow.instance import USERS_TABLE_FQ_NAME
|
11
11
|
from fakesnow.variables import Variables
|
12
12
|
|
13
13
|
MISSING_DATABASE = "missing_database"
|
14
|
-
SUCCESS_NOP = sqlglot.parse_one("SELECT 'Statement executed successfully.'")
|
14
|
+
SUCCESS_NOP = sqlglot.parse_one("SELECT 'Statement executed successfully.' as status")
|
15
15
|
|
16
16
|
|
17
17
|
def alias_in_join(expression: exp.Expression) -> exp.Expression:
|
@@ -33,6 +33,18 @@ def alias_in_join(expression: exp.Expression) -> exp.Expression:
|
|
33
33
|
return expression
|
34
34
|
|
35
35
|
|
36
|
+
def alter_table_strip_cluster_by(expression: exp.Expression) -> exp.Expression:
|
37
|
+
"""Turn alter table cluster by into a no-op"""
|
38
|
+
if (
|
39
|
+
isinstance(expression, exp.AlterTable)
|
40
|
+
and (actions := expression.args.get("actions"))
|
41
|
+
and len(actions) == 1
|
42
|
+
and (isinstance(actions[0], exp.Cluster))
|
43
|
+
):
|
44
|
+
return SUCCESS_NOP
|
45
|
+
return expression
|
46
|
+
|
47
|
+
|
36
48
|
def array_size(expression: exp.Expression) -> exp.Expression:
|
37
49
|
if isinstance(expression, exp.ArraySize):
|
38
50
|
# case is used to convert 0 to null, because null is returned by duckdb when no case matches
|
@@ -551,12 +563,13 @@ def information_schema_fs_columns_snowflake(expression: exp.Expression) -> exp.E
|
|
551
563
|
"""
|
552
564
|
|
553
565
|
if (
|
554
|
-
isinstance(expression, exp.
|
555
|
-
and
|
556
|
-
and
|
557
|
-
and
|
566
|
+
isinstance(expression, exp.Table)
|
567
|
+
and expression.db
|
568
|
+
and expression.db.upper() == "INFORMATION_SCHEMA"
|
569
|
+
and expression.name
|
570
|
+
and expression.name.upper() == "COLUMNS"
|
558
571
|
):
|
559
|
-
|
572
|
+
expression.set("this", exp.Identifier(this="_FS_COLUMNS_SNOWFLAKE", quoted=False))
|
560
573
|
|
561
574
|
return expression
|
562
575
|
|
@@ -5,10 +5,23 @@ from sqlglot import exp
|
|
5
5
|
|
6
6
|
|
7
7
|
# Implements snowflake variables: https://docs.snowflake.com/en/sql-reference/session-variables#using-variables-in-sql
|
8
|
+
# [ ] Add support for setting multiple variables in a single statement
|
8
9
|
class Variables:
|
9
10
|
@classmethod
|
10
11
|
def is_variable_modifier(cls, expr: exp.Expression) -> bool:
|
11
|
-
return
|
12
|
+
return cls._is_set_expression(expr) or cls._is_unset_expression(expr)
|
13
|
+
|
14
|
+
@classmethod
|
15
|
+
def _is_set_expression(cls, expr: exp.Expression) -> bool:
|
16
|
+
if isinstance(expr, exp.Set):
|
17
|
+
is_set = not expr.args.get("unset")
|
18
|
+
if is_set: # SET varname = value;
|
19
|
+
set_expressions = expr.args.get("expressions")
|
20
|
+
assert set_expressions, "SET without values in expression(s) is unexpected."
|
21
|
+
# Avoids mistakenly setting variables for statements that use SET in a different context.
|
22
|
+
# (eg. WHEN MATCHED THEN UPDATE SET x=7)
|
23
|
+
return isinstance(set_expressions[0], exp.SetItem)
|
24
|
+
return False
|
12
25
|
|
13
26
|
@classmethod
|
14
27
|
def _is_unset_expression(cls, expr: exp.Expression) -> bool:
|
@@ -22,11 +35,11 @@ class Variables:
|
|
22
35
|
|
23
36
|
def update_variables(self, expr: exp.Expression) -> None:
|
24
37
|
if isinstance(expr, exp.Set):
|
25
|
-
|
26
|
-
if
|
27
|
-
|
28
|
-
assert
|
29
|
-
eq =
|
38
|
+
is_set = not expr.args.get("unset")
|
39
|
+
if is_set: # SET varname = value;
|
40
|
+
set_expressions = expr.args.get("expressions")
|
41
|
+
assert set_expressions, "SET without values in expression(s) is unexpected."
|
42
|
+
eq = set_expressions[0].this
|
30
43
|
name = eq.this.sql()
|
31
44
|
value = eq.args.get("expression").sql()
|
32
45
|
self._set(name, value)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: fakesnow
|
3
|
-
Version: 0.9.
|
3
|
+
Version: 0.9.21
|
4
4
|
Summary: Fake Snowflake Connector for Python. Run, mock and test Snowflake DB locally.
|
5
5
|
License: Apache License
|
6
6
|
Version 2.0, January 2004
|
@@ -213,20 +213,25 @@ License-File: LICENSE
|
|
213
213
|
Requires-Dist: duckdb~=1.0.0
|
214
214
|
Requires-Dist: pyarrow
|
215
215
|
Requires-Dist: snowflake-connector-python
|
216
|
-
Requires-Dist: sqlglot~=25.
|
216
|
+
Requires-Dist: sqlglot~=25.5.1
|
217
217
|
Provides-Extra: dev
|
218
218
|
Requires-Dist: build~=1.0; extra == "dev"
|
219
219
|
Requires-Dist: pandas-stubs; extra == "dev"
|
220
220
|
Requires-Dist: snowflake-connector-python[pandas,secure-local-storage]; extra == "dev"
|
221
221
|
Requires-Dist: pre-commit~=3.4; extra == "dev"
|
222
|
+
Requires-Dist: pyarrow-stubs; extra == "dev"
|
222
223
|
Requires-Dist: pytest~=8.0; extra == "dev"
|
223
|
-
Requires-Dist:
|
224
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
225
|
+
Requires-Dist: ruff~=0.5.1; extra == "dev"
|
224
226
|
Requires-Dist: twine~=5.0; extra == "dev"
|
225
227
|
Requires-Dist: snowflake-sqlalchemy~=1.5.0; extra == "dev"
|
226
228
|
Provides-Extra: notebook
|
227
229
|
Requires-Dist: duckdb-engine; extra == "notebook"
|
228
230
|
Requires-Dist: ipykernel; extra == "notebook"
|
229
231
|
Requires-Dist: jupysql; extra == "notebook"
|
232
|
+
Provides-Extra: server
|
233
|
+
Requires-Dist: starlette; extra == "server"
|
234
|
+
Requires-Dist: uvicorn; extra == "server"
|
230
235
|
|
231
236
|
# fakesnow ❄️
|
232
237
|
|
@@ -3,15 +3,17 @@ README.md
|
|
3
3
|
pyproject.toml
|
4
4
|
fakesnow/__init__.py
|
5
5
|
fakesnow/__main__.py
|
6
|
+
fakesnow/arrow.py
|
6
7
|
fakesnow/checks.py
|
7
8
|
fakesnow/cli.py
|
8
9
|
fakesnow/expr.py
|
9
10
|
fakesnow/fakes.py
|
10
11
|
fakesnow/fixtures.py
|
11
|
-
fakesnow/global_database.py
|
12
12
|
fakesnow/info_schema.py
|
13
|
+
fakesnow/instance.py
|
13
14
|
fakesnow/macros.py
|
14
15
|
fakesnow/py.typed
|
16
|
+
fakesnow/server.py
|
15
17
|
fakesnow/transforms.py
|
16
18
|
fakesnow/variables.py
|
17
19
|
fakesnow.egg-info/PKG-INFO
|
@@ -20,6 +22,7 @@ fakesnow.egg-info/dependency_links.txt
|
|
20
22
|
fakesnow.egg-info/entry_points.txt
|
21
23
|
fakesnow.egg-info/requires.txt
|
22
24
|
fakesnow.egg-info/top_level.txt
|
25
|
+
tests/test_arrow.py
|
23
26
|
tests/test_checks.py
|
24
27
|
tests/test_cli.py
|
25
28
|
tests/test_connect.py
|
@@ -27,6 +30,7 @@ tests/test_expr.py
|
|
27
30
|
tests/test_fakes.py
|
28
31
|
tests/test_info_schema.py
|
29
32
|
tests/test_patch.py
|
33
|
+
tests/test_server.py
|
30
34
|
tests/test_sqlalchemy.py
|
31
35
|
tests/test_transforms.py
|
32
36
|
tests/test_users.py
|
@@ -1,15 +1,17 @@
|
|
1
1
|
duckdb~=1.0.0
|
2
2
|
pyarrow
|
3
3
|
snowflake-connector-python
|
4
|
-
sqlglot~=25.
|
4
|
+
sqlglot~=25.5.1
|
5
5
|
|
6
6
|
[dev]
|
7
7
|
build~=1.0
|
8
8
|
pandas-stubs
|
9
9
|
snowflake-connector-python[pandas,secure-local-storage]
|
10
10
|
pre-commit~=3.4
|
11
|
+
pyarrow-stubs
|
11
12
|
pytest~=8.0
|
12
|
-
|
13
|
+
pytest-asyncio
|
14
|
+
ruff~=0.5.1
|
13
15
|
twine~=5.0
|
14
16
|
snowflake-sqlalchemy~=1.5.0
|
15
17
|
|
@@ -17,3 +19,7 @@ snowflake-sqlalchemy~=1.5.0
|
|
17
19
|
duckdb-engine
|
18
20
|
ipykernel
|
19
21
|
jupysql
|
22
|
+
|
23
|
+
[server]
|
24
|
+
starlette
|
25
|
+
uvicorn
|
@@ -1,7 +1,7 @@
|
|
1
1
|
[project]
|
2
2
|
name = "fakesnow"
|
3
3
|
description = "Fake Snowflake Connector for Python. Run, mock and test Snowflake DB locally."
|
4
|
-
version = "0.9.
|
4
|
+
version = "0.9.21"
|
5
5
|
readme = "README.md"
|
6
6
|
license = { file = "LICENSE" }
|
7
7
|
classifiers = ["License :: OSI Approved :: MIT License"]
|
@@ -11,7 +11,7 @@ dependencies = [
|
|
11
11
|
"duckdb~=1.0.0",
|
12
12
|
"pyarrow",
|
13
13
|
"snowflake-connector-python",
|
14
|
-
"sqlglot~=25.
|
14
|
+
"sqlglot~=25.5.1",
|
15
15
|
]
|
16
16
|
|
17
17
|
[project.urls]
|
@@ -28,13 +28,17 @@ dev = [
|
|
28
28
|
# include compatible version of pandas, and secure-local-storage for token caching
|
29
29
|
"snowflake-connector-python[pandas, secure-local-storage]",
|
30
30
|
"pre-commit~=3.4",
|
31
|
+
"pyarrow-stubs",
|
31
32
|
"pytest~=8.0",
|
32
|
-
"
|
33
|
+
"pytest-asyncio",
|
34
|
+
"ruff~=0.5.1",
|
33
35
|
"twine~=5.0",
|
34
36
|
"snowflake-sqlalchemy~=1.5.0",
|
35
37
|
]
|
36
38
|
# for debugging, see https://duckdb.org/docs/guides/python/jupyter.html
|
37
39
|
notebook = ["duckdb-engine", "ipykernel", "jupysql"]
|
40
|
+
# for the standalone server
|
41
|
+
server = ["starlette", "uvicorn"]
|
38
42
|
|
39
43
|
[build-system]
|
40
44
|
requires = ["setuptools~=69.1", "wheel~=0.42"]
|
@@ -51,9 +55,14 @@ strictListInference = true
|
|
51
55
|
strictDictionaryInference = true
|
52
56
|
strictParameterNoneValue = true
|
53
57
|
reportTypedDictNotRequiredAccess = false
|
58
|
+
reportIncompatibleVariableOverride = true
|
54
59
|
reportIncompatibleMethodOverride = true
|
60
|
+
reportMatchNotExhaustive = true
|
55
61
|
reportUnnecessaryTypeIgnoreComment = true
|
56
62
|
|
63
|
+
[tool.pytest.ini_options]
|
64
|
+
asyncio_mode = "auto"
|
65
|
+
|
57
66
|
[tool.ruff]
|
58
67
|
line-length = 120
|
59
68
|
# first-party imports for sorting
|
@@ -0,0 +1,53 @@
|
|
1
|
+
from base64 import b64decode
|
2
|
+
|
3
|
+
import pandas as pd
|
4
|
+
import pyarrow as pa
|
5
|
+
|
6
|
+
from fakesnow.arrow import to_ipc, with_sf_metadata
|
7
|
+
|
8
|
+
|
9
|
+
def test_with_sf_metadata() -> None:
|
10
|
+
# see https://arrow.apache.org/docs/python/api/datatypes.html
|
11
|
+
def f(t: pa.DataType) -> dict:
|
12
|
+
return with_sf_metadata(pa.schema([pa.field(str(t), t)])).field(0).metadata
|
13
|
+
|
14
|
+
assert f(pa.string()) == {b"logicalType": b"TEXT"}
|
15
|
+
assert f(pa.decimal128(10, 2)) == {b"logicalType": b"FIXED", b"precision": b"10", b"scale": b"2"}
|
16
|
+
|
17
|
+
|
18
|
+
def test_ipc_writes_sf_metadata() -> None:
|
19
|
+
df = pd.DataFrame.from_dict(
|
20
|
+
{
|
21
|
+
"'HELLO WORLD'": ["hello world"],
|
22
|
+
}
|
23
|
+
)
|
24
|
+
|
25
|
+
table = pa.Table.from_pandas(df)
|
26
|
+
table_bytes = to_ipc(table)
|
27
|
+
|
28
|
+
batch = next(iter(pa.ipc.open_stream(table_bytes)))
|
29
|
+
|
30
|
+
# field and schema metadata is ignored
|
31
|
+
assert pa.table(batch) == table
|
32
|
+
assert batch.schema.field(0).metadata == {b"logicalType": b"TEXT"}, "Missing Snowflake field metadata"
|
33
|
+
|
34
|
+
|
35
|
+
def test_read_base64() -> None:
|
36
|
+
# select to_decimal('12.3456', 10,2)
|
37
|
+
rowset_b64 = "/////5gBAAAQAAAAAAAKAAwABgAFAAgACgAAAAABBAAMAAAACAAIAAAABAAIAAAABAAAAAEAAAAYAAAAAAASABgACAAAAAcADAAAABAAFAASAAAAAAAAAhQAAAA0AQAACAAAACgAAAAAAAAAGwAAAFRPX0RFQ0lNQUwoJzEyLjM0NTYnLCAxMCwyKQAGAAAA0AAAAKAAAAB8AAAAVAAAACwAAAAEAAAAUP///xAAAAAEAAAAAQAAAFQAAAAJAAAAZmluYWxUeXBlAAAAdP///xAAAAAEAAAAAQAAADIAAAAKAAAAYnl0ZUxlbmd0aAAAmP///xAAAAAEAAAAAQAAADAAAAAKAAAAY2hhckxlbmd0aAAAvP///xAAAAAEAAAAAQAAADIAAAAFAAAAc2NhbGUAAADc////EAAAAAQAAAACAAAAMTAAAAkAAABwcmVjaXNpb24AAAAIAAwABAAIAAgAAAAUAAAABAAAAAUAAABGSVhFRAAAAAsAAABsb2dpY2FsVHlwZQAIAAwACAAHAAgAAAAAAAABEAAAAAAAAAD/////iAAAABQAAAAAAAAADAAWAAYABQAIAAwADAAAAAADBAAYAAAACAAAAAAAAAAAAAoAGAAMAAQACAAKAAAAPAAAABAAAAABAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAADTBAAAAAAAAA==" # noqa: E501
|
38
|
+
|
39
|
+
f = b64decode(rowset_b64)
|
40
|
+
reader = pa.ipc.open_stream(f)
|
41
|
+
|
42
|
+
batch = next(reader)
|
43
|
+
|
44
|
+
field = batch.schema.field(0)
|
45
|
+
assert field == pa.field(name="TO_DECIMAL('12.3456', 10,2)", type=pa.int16(), nullable=False)
|
46
|
+
assert field.metadata == {
|
47
|
+
b"logicalType": b"FIXED",
|
48
|
+
b"precision": b"10",
|
49
|
+
b"scale": b"2",
|
50
|
+
b"charLength": b"0",
|
51
|
+
b"byteLength": b"2",
|
52
|
+
b"finalType": b"T",
|
53
|
+
}
|
@@ -83,6 +83,15 @@ def test_connect_db_path_reuse():
|
|
83
83
|
assert cur.execute("select * from example").fetchall() == [(420,)]
|
84
84
|
|
85
85
|
|
86
|
+
def test_connect_information_schema():
|
87
|
+
with fakesnow.patch(create_schema_on_connect=False):
|
88
|
+
conn = snowflake.connector.connect(database="db1", schema="information_schema")
|
89
|
+
assert conn.schema == "INFORMATION_SCHEMA"
|
90
|
+
with conn, conn.cursor() as cur:
|
91
|
+
# shouldn't fail
|
92
|
+
cur.execute("SELECT * FROM databases")
|
93
|
+
|
94
|
+
|
86
95
|
def test_connect_without_database(_fakesnow_no_auto_create: None):
|
87
96
|
with snowflake.connector.connect() as conn, conn.cursor() as cur:
|
88
97
|
with pytest.raises(snowflake.connector.errors.ProgrammingError) as excinfo:
|
@@ -41,10 +41,13 @@ def test_alias_on_join(conn: snowflake.connector.SnowflakeConnection):
|
|
41
41
|
assert cur.fetchall() == [("VARCHAR1", "CHAR1", "JOIN"), ("VARCHAR2", "CHAR2", None)]
|
42
42
|
|
43
43
|
|
44
|
-
def test_alter_table(
|
45
|
-
|
46
|
-
|
47
|
-
|
44
|
+
def test_alter_table(dcur: snowflake.connector.cursor.SnowflakeCursor):
|
45
|
+
dcur.execute("create table table1 (id int)")
|
46
|
+
dcur.execute("alter table table1 add column name varchar(20)")
|
47
|
+
dcur.execute("select name from table1")
|
48
|
+
assert dcur.execute("alter table table1 cluster by (name)").fetchall() == [
|
49
|
+
{"status": "Statement executed successfully."}
|
50
|
+
]
|
48
51
|
|
49
52
|
|
50
53
|
def test_array_size(cur: snowflake.connector.cursor.SnowflakeCursor):
|
@@ -38,6 +38,12 @@ def test_info_schema_columns_describe(cur: snowflake.connector.cursor.SnowflakeC
|
|
38
38
|
|
39
39
|
assert cur.description == expected_metadata
|
40
40
|
|
41
|
+
# should contain snowflake-specific columns (from _FS_COLUMNS_SNOWFLAKE)
|
42
|
+
cur.execute("describe view information_schema.columns")
|
43
|
+
result = cur.fetchall()
|
44
|
+
names = [name for (name, *_) in result]
|
45
|
+
assert "comment" in names
|
46
|
+
|
41
47
|
|
42
48
|
def test_info_schema_columns_numeric(cur: snowflake.connector.cursor.SnowflakeCursor):
|
43
49
|
# see https://docs.snowflake.com/en/sql-reference/data-types-numeric
|
@@ -211,3 +217,16 @@ def test_info_schema_show_primary_keys_from_table(cur: snowflake.connector.curso
|
|
211
217
|
|
212
218
|
pk_columns = [result[4] for result in pk_result]
|
213
219
|
assert pk_columns == ["ID", "VERSION"]
|
220
|
+
|
221
|
+
|
222
|
+
def test_type_column_is_not_null(cur: snowflake.connector.cursor.SnowflakeCursor) -> None:
|
223
|
+
for table in [
|
224
|
+
"information_schema.databases",
|
225
|
+
"information_schema.views",
|
226
|
+
"information_schema.columns",
|
227
|
+
]:
|
228
|
+
cur.execute(f"DESCRIBE VIEW {table}")
|
229
|
+
result = cur.fetchall()
|
230
|
+
data_types = [dt for (_, dt, *_) in result]
|
231
|
+
nulls = [dt for dt in data_types if "NULL" in dt]
|
232
|
+
assert not nulls
|
@@ -0,0 +1,53 @@
|
|
1
|
+
import threading
|
2
|
+
from collections.abc import Iterator
|
3
|
+
from decimal import Decimal
|
4
|
+
from time import sleep
|
5
|
+
from typing import Callable
|
6
|
+
|
7
|
+
import pytest
|
8
|
+
import snowflake.connector
|
9
|
+
import uvicorn
|
10
|
+
|
11
|
+
import fakesnow.server
|
12
|
+
|
13
|
+
|
14
|
+
@pytest.fixture(scope="session")
|
15
|
+
def unused_port(unused_tcp_port_factory: Callable[[], int]) -> int:
|
16
|
+
# unused_tcp_port_factory is from pytest-asyncio
|
17
|
+
return unused_tcp_port_factory()
|
18
|
+
|
19
|
+
|
20
|
+
@pytest.fixture(scope="session")
|
21
|
+
def server(unused_tcp_port_factory: Callable[[], int]) -> Iterator[int]:
|
22
|
+
port = unused_tcp_port_factory()
|
23
|
+
server = uvicorn.Server(uvicorn.Config(fakesnow.server.app, port=port, log_level="info"))
|
24
|
+
thread = threading.Thread(target=server.run, name="Server", daemon=True)
|
25
|
+
thread.start()
|
26
|
+
|
27
|
+
while not server.started:
|
28
|
+
sleep(0.1)
|
29
|
+
yield port
|
30
|
+
|
31
|
+
server.should_exit = True
|
32
|
+
# wait for server thread to end
|
33
|
+
thread.join()
|
34
|
+
|
35
|
+
|
36
|
+
def test_server_connect(server: int) -> None:
|
37
|
+
with (
|
38
|
+
snowflake.connector.connect(
|
39
|
+
user="fake",
|
40
|
+
password="snow",
|
41
|
+
account="fakesnow",
|
42
|
+
host="localhost",
|
43
|
+
port=server,
|
44
|
+
protocol="http",
|
45
|
+
# disable telemetry
|
46
|
+
session_parameters={"CLIENT_OUT_OF_BAND_TELEMETRY_ENABLED": False},
|
47
|
+
# disable infinite retries on error
|
48
|
+
network_timeout=0,
|
49
|
+
) as conn1,
|
50
|
+
conn1.cursor() as cur,
|
51
|
+
):
|
52
|
+
cur.execute("select 'hello', to_decimal('12.3456', 10,2)")
|
53
|
+
assert cur.fetchall() == [("hello", Decimal("12.35"))]
|
@@ -8,6 +8,7 @@ from fakesnow.transforms import (
|
|
8
8
|
SUCCESS_NOP,
|
9
9
|
_get_to_number_args,
|
10
10
|
alias_in_join,
|
11
|
+
alter_table_strip_cluster_by,
|
11
12
|
array_agg,
|
12
13
|
array_agg_within_group,
|
13
14
|
array_size,
|
@@ -74,6 +75,13 @@ def test_alias_in_join() -> None:
|
|
74
75
|
)
|
75
76
|
|
76
77
|
|
78
|
+
def test_alter_table_strip_cluster_by() -> None:
|
79
|
+
assert (
|
80
|
+
sqlglot.parse_one("alter table table1 cluster by (name)").transform(alter_table_strip_cluster_by).sql()
|
81
|
+
== "SELECT 'Statement executed successfully.' AS status"
|
82
|
+
)
|
83
|
+
|
84
|
+
|
77
85
|
def test_array_size() -> None:
|
78
86
|
assert (
|
79
87
|
sqlglot.parse_one("""select array_size(parse_json('["a","b"]'))""").transform(array_size).sql(dialect="duckdb")
|
@@ -342,7 +350,7 @@ def test_extract_comment_on_columns() -> None:
|
|
342
350
|
e = sqlglot.parse_one("ALTER TABLE ingredients ALTER amount COMMENT 'tablespoons'").transform(
|
343
351
|
extract_comment_on_columns
|
344
352
|
)
|
345
|
-
assert e.sql() == "SELECT 'Statement executed successfully.'"
|
353
|
+
assert e.sql() == "SELECT 'Statement executed successfully.' AS status"
|
346
354
|
assert e.args["col_comments"] == [("amount", "tablespoons")]
|
347
355
|
|
348
356
|
# TODO
|
@@ -365,19 +373,19 @@ def test_extract_comment_on_table() -> None:
|
|
365
373
|
assert e.args["table_comment"] == (table1, "foobar")
|
366
374
|
|
367
375
|
e = sqlglot.parse_one("COMMENT ON TABLE table1 IS 'comment1'").transform(extract_comment_on_table)
|
368
|
-
assert e.sql() == "SELECT 'Statement executed successfully.'"
|
376
|
+
assert e.sql() == "SELECT 'Statement executed successfully.' AS status"
|
369
377
|
assert e.args["table_comment"] == (table1, "comment1")
|
370
378
|
|
371
379
|
e = sqlglot.parse_one("COMMENT ON TABLE table1 IS $$comment2$$", read="snowflake").transform(
|
372
380
|
extract_comment_on_table
|
373
381
|
)
|
374
|
-
assert e.sql() == "SELECT 'Statement executed successfully.'"
|
382
|
+
assert e.sql() == "SELECT 'Statement executed successfully.' AS status"
|
375
383
|
assert e.args["table_comment"] == (table1, "comment2")
|
376
384
|
|
377
385
|
e = sqlglot.parse_one("ALTER TABLE table1 SET COMMENT = 'comment1'", read="snowflake").transform(
|
378
386
|
extract_comment_on_table
|
379
387
|
)
|
380
|
-
assert e.sql() == "SELECT 'Statement executed successfully.'"
|
388
|
+
assert e.sql() == "SELECT 'Statement executed successfully.' AS status"
|
381
389
|
assert e.args["table_comment"] == (table1, "comment1")
|
382
390
|
|
383
391
|
|
@@ -1,46 +0,0 @@
|
|
1
|
-
from duckdb import DuckDBPyConnection
|
2
|
-
|
3
|
-
GLOBAL_DATABASE_NAME = "_fs_global"
|
4
|
-
USERS_TABLE_FQ_NAME = f"{GLOBAL_DATABASE_NAME}._fs_users_ext"
|
5
|
-
|
6
|
-
# replicates the output structure of https://docs.snowflake.com/en/sql-reference/sql/show-users
|
7
|
-
SQL_CREATE_INFORMATION_SCHEMA_USERS_TABLE_EXT = f"""
|
8
|
-
create table if not exists {USERS_TABLE_FQ_NAME} (
|
9
|
-
name varchar,
|
10
|
-
created_on TIMESTAMPTZ,
|
11
|
-
login_name varchar,
|
12
|
-
display_name varchar,
|
13
|
-
first_name varchar,
|
14
|
-
last_name varchar,
|
15
|
-
email varchar,
|
16
|
-
mins_to_unlock varchar,
|
17
|
-
days_to_expiry varchar,
|
18
|
-
comment varchar,
|
19
|
-
disabled varchar,
|
20
|
-
must_change_password varchar,
|
21
|
-
snowflake_lock varchar,
|
22
|
-
default_warehouse varchar,
|
23
|
-
default_namespace varchar,
|
24
|
-
default_role varchar,
|
25
|
-
default_secondary_roles varchar,
|
26
|
-
ext_authn_duo varchar,
|
27
|
-
ext_authn_uid varchar,
|
28
|
-
mins_to_bypass_mfa varchar,
|
29
|
-
owner varchar,
|
30
|
-
last_success_login TIMESTAMPTZ,
|
31
|
-
expires_at_time TIMESTAMPTZ,
|
32
|
-
locked_until_time TIMESTAMPTZ,
|
33
|
-
has_password varchar,
|
34
|
-
has_rsa_public_key varchar,
|
35
|
-
)
|
36
|
-
"""
|
37
|
-
|
38
|
-
|
39
|
-
def create_global_database(conn: DuckDBPyConnection) -> None:
|
40
|
-
"""Create a "global" database for storing objects which span database.
|
41
|
-
|
42
|
-
Including (but not limited to):
|
43
|
-
- Users
|
44
|
-
"""
|
45
|
-
conn.execute(f"ATTACH IF NOT EXISTS ':memory:' AS {GLOBAL_DATABASE_NAME}")
|
46
|
-
conn.execute(SQL_CREATE_INFORMATION_SCHEMA_USERS_TABLE_EXT)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|