fakesnow 0.9.39__py3-none-any.whl → 0.9.40__py3-none-any.whl

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/copy_into.py CHANGED
@@ -11,6 +11,7 @@ import snowflake.connector.errors
11
11
  from duckdb import DuckDBPyConnection
12
12
  from sqlglot import exp
13
13
 
14
+ import fakesnow.transforms.stage as stage
14
15
  from fakesnow import logger
15
16
 
16
17
 
@@ -196,40 +197,10 @@ def _from_source(expr: exp.Copy) -> str:
196
197
  return from_.name
197
198
 
198
199
 
199
- def normalise_ident(name: str) -> str:
200
- """
201
- Strip double quotes if present else return uppercased.
202
- Snowflake treats quoted identifiers as case-sensitive and un-quoted identifiers as case-insensitive
203
- """
204
- if name.startswith('"') and name.endswith('"'):
205
- return name[1:-1] # Strip quotes
206
-
207
- return name.upper()
208
-
209
-
210
200
  def stage_url_from_var(
211
201
  from_source: str, duck_conn: DuckDBPyConnection, current_database: str | None, current_schema: str | None
212
202
  ) -> str:
213
- parts = from_source[1:].split(".")
214
- if len(parts) == 3:
215
- # Fully qualified name
216
- database_name, schema_name, name = parts
217
- elif len(parts) == 2:
218
- # Schema + stage name
219
- assert current_database, "Current database must be set when stage name is not fully qualified"
220
- database_name, schema_name, name = current_database, parts[0], parts[1]
221
- elif len(parts) == 1:
222
- # Stage name only
223
- assert current_database, "Current database must be set when stage name is not fully qualified"
224
- assert current_schema, "Current schema must be set when stage name is not fully qualified"
225
- database_name, schema_name, name = current_database, current_schema, parts[0]
226
- else:
227
- raise ValueError(f"Invalid stage name: {from_source}")
228
-
229
- # Normalize names to uppercase if not wrapped in double quotes
230
- database_name = normalise_ident(database_name)
231
- schema_name = normalise_ident(schema_name)
232
- name = normalise_ident(name)
203
+ database_name, schema_name, name = stage.parts_from_var(from_source, current_database, current_schema)
233
204
 
234
205
  # Look up the stage URL
235
206
  duck_conn.execute(
fakesnow/cursor.py CHANGED
@@ -76,6 +76,7 @@ class FakeSnowflakeCursor:
76
76
  self._use_dict_result = use_dict_result
77
77
  self._last_sql = None
78
78
  self._last_params = None
79
+ self._last_transformed = None
79
80
  self._sqlstate = None
80
81
  self._arraysize = 1
81
82
  self._arrow_table = None
@@ -106,6 +107,7 @@ class FakeSnowflakeCursor:
106
107
  def close(self) -> bool:
107
108
  self._last_sql = None
108
109
  self._last_params = None
110
+ self._last_transformed = None
109
111
  return True
110
112
 
111
113
  def describe(self, command: str, *args: Any, **kwargs: Any) -> list[ResultMetadata]:
@@ -239,6 +241,7 @@ class FakeSnowflakeCursor:
239
241
  .transform(transforms.identifier)
240
242
  .transform(transforms.array_agg_within_group)
241
243
  .transform(transforms.array_agg)
244
+ .transform(transforms.array_construct_etc)
242
245
  .transform(transforms.dateadd_date_cast)
243
246
  .transform(transforms.dateadd_string_literal_timestamp_cast)
244
247
  .transform(transforms.datediff_string_literal_timestamp_cast)
@@ -261,6 +264,7 @@ class FakeSnowflakeCursor:
261
264
  .transform(transforms.alias_in_join)
262
265
  .transform(transforms.alter_table_strip_cluster_by)
263
266
  .transform(lambda e: transforms.create_stage(e, self._conn.database, self._conn.schema))
267
+ .transform(lambda e: transforms.put_stage(e, self._conn.database, self._conn.schema))
264
268
  )
265
269
 
266
270
  def _transform_explode(self, expression: exp.Expression) -> list[exp.Expression]:
@@ -330,10 +334,10 @@ class FakeSnowflakeCursor:
330
334
  self._duck_conn.execute(info_schema.per_db_creation_sql(create_db_name))
331
335
  result_sql = SQL_CREATED_DATABASE.substitute(name=create_db_name)
332
336
 
333
- elif stage_name := transformed.args.get("stage_name"):
337
+ elif stage_name := transformed.args.get("create_stage_name"):
334
338
  if stage_name == "?":
335
339
  assert isinstance(params, (tuple, list)) and len(params) == 1, (
336
- "Expected single parameter for stage name"
340
+ "Expected single parameter for create_stage_name"
337
341
  )
338
342
  result_sql = SQL_CREATED_STAGE.substitute(name=params[0].upper())
339
343
  else:
@@ -411,8 +415,21 @@ class FakeSnowflakeCursor:
411
415
  self._rowcount = affected_count or self._arrow_table.num_rows
412
416
  self._sfqid = str(uuid.uuid4())
413
417
 
418
+ if stage_name := transformed.args.get("put_stage_name"):
419
+ if stage_name == "?":
420
+ assert isinstance(params, (tuple, list)) and len(params) == 1, (
421
+ "Expected single parameter for put_stage_name"
422
+ )
423
+ if self._arrow_table.num_rows != 1:
424
+ raise snowflake.connector.errors.ProgrammingError(
425
+ msg=f"SQL compilation error:\nStage '{stage_name}' does not exist or not authorized.",
426
+ errno=2003,
427
+ sqlstate="02000",
428
+ )
429
+
414
430
  self._last_sql = result_sql or sql
415
431
  self._last_params = None if result_sql else params
432
+ self._last_transformed = transformed
416
433
 
417
434
  def executemany(
418
435
  self,
fakesnow/macros.py CHANGED
@@ -29,9 +29,20 @@ CREATE OR REPLACE MACRO ${catalog}._fs_flatten(input) AS TABLE
29
29
  """
30
30
  )
31
31
 
32
+ # emulates https://docs.snowflake.com/en/sql-reference/functions/array_construct_compact
33
+ # requires transforms.array_construct_compact
34
+ ARRAY_CONSTRUCT_COMPACT = Template(
35
+ """
36
+ CREATE OR REPLACE MACRO ${catalog}.array_construct_compact(list) AS (
37
+ SELECT ARRAY_AGG(x)::JSON FROM UNNEST(list) AS t(x) WHERE x IS NOT NULL
38
+ );
39
+ """
40
+ )
41
+
32
42
 
33
43
  def creation_sql(catalog: str) -> str:
34
44
  return f"""
35
45
  {EQUAL_NULL.substitute(catalog=catalog)};
36
46
  {FS_FLATTEN.substitute(catalog=catalog)};
47
+ {ARRAY_CONSTRUCT_COMPACT.substitute(catalog=catalog)};
37
48
  """
fakesnow/server.py CHANGED
@@ -9,6 +9,7 @@ from dataclasses import dataclass
9
9
  from typing import Any
10
10
 
11
11
  import snowflake.connector.errors
12
+ from sqlglot import parse_one
12
13
  from starlette.applications import Starlette
13
14
  from starlette.concurrency import run_in_threadpool
14
15
  from starlette.requests import Request
@@ -89,11 +90,24 @@ async def query_request(request: Request) -> JSONResponse:
89
90
  else:
90
91
  params = None
91
92
 
93
+ expr = parse_one(sql_text, read="snowflake")
94
+
92
95
  try:
93
96
  # only a single sql statement is sent at a time by the python snowflake connector
94
97
  cur = await run_in_threadpool(conn.cursor().execute, sql_text, binding_params=params)
95
98
  rowtype = describe_as_rowtype(cur._describe_last_sql()) # noqa: SLF001
96
99
 
100
+ expr = cur._last_transformed # noqa: SLF001
101
+ assert expr
102
+ if put_stage_data := expr.args.get("put_stage_data"):
103
+ # this is a PUT command, so return the stage data
104
+ return JSONResponse(
105
+ {
106
+ "data": put_stage_data,
107
+ "success": True,
108
+ }
109
+ )
110
+
97
111
  except snowflake.connector.errors.ProgrammingError as e:
98
112
  logger.info(f"{sql_text=} ProgrammingError {e}")
99
113
  code = f"{e.errno:06d}"
@@ -13,13 +13,17 @@ from fakesnow.transforms.show import (
13
13
  show_users as show_users,
14
14
  show_warehouses as show_warehouses,
15
15
  )
16
- from fakesnow.transforms.stage import create_stage as create_stage
16
+ from fakesnow.transforms.stage import (
17
+ create_stage as create_stage,
18
+ put_stage as put_stage,
19
+ )
17
20
  from fakesnow.transforms.transforms import (
18
21
  SUCCESS_NOP as SUCCESS_NOP,
19
22
  alias_in_join as alias_in_join,
20
23
  alter_table_strip_cluster_by as alter_table_strip_cluster_by,
21
24
  array_agg as array_agg,
22
25
  array_agg_within_group as array_agg_within_group,
26
+ array_construct_etc as array_construct_etc,
23
27
  array_size as array_size,
24
28
  create_clone as create_clone,
25
29
  create_database as create_database,
@@ -1,13 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import datetime
4
+ from urllib.parse import urlparse
5
+ from urllib.request import url2pathname
4
6
 
7
+ import snowflake.connector.errors
5
8
  import sqlglot
6
9
  from sqlglot import exp
7
10
 
11
+ LOCAL_BUCKET_PATH = "/tmp/fakesnow_bucket"
12
+
8
13
 
9
14
  def create_stage(
10
- expression: exp.Expression, current_database: str | None = None, current_schema: str | None = None
15
+ expression: exp.Expression, current_database: str | None, current_schema: str | None
11
16
  ) -> exp.Expression:
12
17
  """Transform CREATE STAGE to an INSERT statement for the fake stages table."""
13
18
  if not (
@@ -64,5 +69,95 @@ def create_stage(
64
69
  )
65
70
  """
66
71
  transformed = sqlglot.parse_one(insert_sql, read="duckdb")
67
- transformed.args["stage_name"] = stage_name
72
+ transformed.args["create_stage_name"] = stage_name
73
+ return transformed
74
+
75
+
76
+ # TODO: handle ?
77
+
78
+
79
+ def put_stage(expression: exp.Expression, current_database: str | None, current_schema: str | None) -> exp.Expression:
80
+ """Transform PUT to a SELECT statement to locate the stage.
81
+
82
+ See https://docs.snowflake.com/en/sql-reference/sql/put
83
+ """
84
+ if not isinstance(expression, exp.Put):
85
+ return expression
86
+
87
+ assert isinstance(expression.this, exp.Literal), "PUT command requires a file path as a literal"
88
+ src_url = urlparse(expression.this.this)
89
+ src_path = url2pathname(src_url.path)
90
+ target = expression.args["target"]
91
+
92
+ assert isinstance(target, exp.Var), f"{target} is not a exp.Var"
93
+ var = target.text("this")
94
+ if not var.startswith("@"):
95
+ msg = f"SQL compilation error:\n{var} does not start with @"
96
+ raise snowflake.connector.errors.ProgrammingError(
97
+ msg=msg,
98
+ errno=1003,
99
+ sqlstate="42000",
100
+ )
101
+ catalog, schema, stage_name = parts_from_var(var, current_database=current_database, current_schema=current_schema)
102
+
103
+ query = f"""
104
+ SELECT *
105
+ from _fs_global._fs_information_schema._fs_stages
106
+ where database_name = '{catalog}' and schema_name = '{schema}' and name = '{stage_name}'
107
+ """
108
+
109
+ transformed = sqlglot.parse_one(query, read="duckdb")
110
+ transformed.args["put_stage_name"] = f"{catalog}.{schema}.{stage_name}"
111
+ transformed.args["put_stage_data"] = {
112
+ "stageInfo": {
113
+ # use LOCAL_FS otherwise we need to mock S3 with HTTPS which requires a certificate
114
+ "locationType": "LOCAL_FS",
115
+ "location": f"{LOCAL_BUCKET_PATH}/{stage_name}/",
116
+ "creds": {},
117
+ },
118
+ "src_locations": [src_path],
119
+ # defaults as per https://docs.snowflake.com/en/sql-reference/sql/put TODO: support other values
120
+ "parallel": 4,
121
+ "autoCompress": True,
122
+ "sourceCompression": "auto_detect",
123
+ "overwrite": False,
124
+ "command": "UPLOAD",
125
+ }
126
+
68
127
  return transformed
128
+
129
+
130
+ def normalise_ident(name: str) -> str:
131
+ """
132
+ Strip double quotes if present else return uppercased.
133
+ Snowflake treats quoted identifiers as case-sensitive and un-quoted identifiers as case-insensitive
134
+ """
135
+ if name.startswith('"') and name.endswith('"'):
136
+ return name[1:-1] # Strip quotes
137
+
138
+ return name.upper()
139
+
140
+
141
+ def parts_from_var(var: str, current_database: str | None, current_schema: str | None) -> tuple[str, str, str]:
142
+ parts = var[1:].split(".")
143
+ if len(parts) == 3:
144
+ # Fully qualified name
145
+ database_name, schema_name, name = parts
146
+ elif len(parts) == 2:
147
+ # Schema + stage name
148
+ assert current_database, "Current database must be set when stage name is not fully qualified"
149
+ database_name, schema_name, name = current_database, parts[0], parts[1]
150
+ elif len(parts) == 1:
151
+ # Stage name only
152
+ assert current_database, "Current database must be set when stage name is not fully qualified"
153
+ assert current_schema, "Current schema must be set when stage name is not fully qualified"
154
+ database_name, schema_name, name = current_database, current_schema, parts[0]
155
+ else:
156
+ raise ValueError(f"Invalid stage name: {var}")
157
+
158
+ # Normalize names to uppercase if not wrapped in double quotes
159
+ database_name = normalise_ident(database_name)
160
+ schema_name = normalise_ident(schema_name)
161
+ name = normalise_ident(name)
162
+
163
+ return database_name, schema_name, name
@@ -45,6 +45,24 @@ def alter_table_strip_cluster_by(expression: exp.Expression) -> exp.Expression:
45
45
  return expression
46
46
 
47
47
 
48
+ def array_construct_etc(expression: exp.Expression) -> exp.Expression:
49
+ """Handle ARRAY_CONSTRUCT_* and ARRAY_CAT
50
+
51
+ Convert ARRAY_CONSTRUCT args to json_array.
52
+ Convert ARRAY_CONSTRUCT_COMPACT args to a list.
53
+ Because the macro expects a single argument to use with UNNEST.
54
+
55
+ TODO: fix ARRAY_CONSTRUCT_COMPACT to handle args of differing types.
56
+ """
57
+ if isinstance(expression, exp.ArrayConstructCompact):
58
+ return exp.ArrayConstructCompact(expressions=[exp.Array(expressions=expression.expressions)])
59
+ elif isinstance(expression, exp.Array) and isinstance(expression.parent, exp.Select):
60
+ return exp.Anonymous(this="json_array", expressions=expression.expressions)
61
+ elif isinstance(expression, exp.ArrayConcat) and isinstance(expression.parent, exp.Select):
62
+ return exp.Cast(this=expression, to=exp.DataType(this=exp.DataType.Type.JSON, nested=False))
63
+ return expression
64
+
65
+
48
66
  def array_size(expression: exp.Expression) -> exp.Expression:
49
67
  if isinstance(expression, exp.ArraySize):
50
68
  # return null if not json array
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fakesnow
3
- Version: 0.9.39
3
+ Version: 0.9.40
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
@@ -214,7 +214,7 @@ License-File: LICENSE
214
214
  Requires-Dist: duckdb~=1.2.0
215
215
  Requires-Dist: pyarrow
216
216
  Requires-Dist: snowflake-connector-python
217
- Requires-Dist: sqlglot~=26.16.2
217
+ Requires-Dist: sqlglot~=26.24.0
218
218
  Provides-Extra: server
219
219
  Requires-Dist: starlette; extra == "server"
220
220
  Requires-Dist: uvicorn; extra == "server"
@@ -5,29 +5,29 @@ fakesnow/checks.py,sha256=bOJPMp46AvjJV_bXXjx2njO2dXNjffLrznwRuKyYZ4g,2889
5
5
  fakesnow/cli.py,sha256=9qfI-Ssr6mo8UmIlXkUAOz2z2YPBgDsrEVaZv9FjGFs,2201
6
6
  fakesnow/conn.py,sha256=diCwcjaCBrlCn9PyjbScfIQTNQjqiPTkQanUTqcvblE,6009
7
7
  fakesnow/converter.py,sha256=wPOfsFXIUJNJSx5oFNAxh13udxmAVIIHsLK8BiGkXGA,1635
8
- fakesnow/copy_into.py,sha256=CRqAK5CUz9xYSwP9PZJpVxUz8KT9ZZpsCRQEuEEVSbM,14832
9
- fakesnow/cursor.py,sha256=xDKvet679VnICUxbDCfRnT8K9Q03Tii948_3XD2YFnk,22954
8
+ fakesnow/copy_into.py,sha256=YIr5Bq3JwKOPYWm5t2QXUuFGxLL-1ioEXEumNjGbBvM,13648
9
+ fakesnow/cursor.py,sha256=mOwyXnBFXp79nDh0vtbmxS_hmFNy4j7hPlskENWeIrI,23818
10
10
  fakesnow/expr.py,sha256=CAxuYIUkwI339DQIBzvFF0F-m1tcVGKEPA5rDTzmH9A,892
11
11
  fakesnow/fakes.py,sha256=JQTiUkkwPeQrJ8FDWhPFPK6pGwd_aR2oiOrNzCWznlM,187
12
12
  fakesnow/fixtures.py,sha256=2rj0MTZlaZc4PNWhaqC5IiiLa7E9G0QZT3g45YawsL0,633
13
13
  fakesnow/info_schema.py,sha256=lqEYD5aWK2MamjALbj6ct7pz_1yyAq3tAk51kLa8NKk,9872
14
14
  fakesnow/instance.py,sha256=OKoYXwaI6kL9HQpnHx44yzpON_xNfuIT_F4oJNF_XXQ,2114
15
15
  fakesnow/logger.py,sha256=U6EjUENQuTrDeNYqER2hxazoySmXzLmZJ-t-SDZgjkg,363
16
- fakesnow/macros.py,sha256=bQfZR5ptO4Gk-8fFRK2iksqYWkJUT8e-rPp-000qzu0,999
16
+ fakesnow/macros.py,sha256=lxtznTCYryjecFkwswbqWMzCVamDLWyQZRKWtkWCWEk,1397
17
17
  fakesnow/pandas_tools.py,sha256=wI203UQHC8JvDzxE_VjE1NeV4rThek2P-u52oTg2foo,3481
18
18
  fakesnow/py.typed,sha256=B-DLSjYBi7pkKjwxCSdpVj2J02wgfJr-E7B1wOUyxYU,80
19
19
  fakesnow/rowtype.py,sha256=QUp8EaXD5LT0Xv8BXk5ze4WseEn52xoJ6R05pJjs5mM,2729
20
- fakesnow/server.py,sha256=WyLU_4zcqQHLcvilQbNeavwRLVRQLm7n8NmtHJHzjqY,6687
20
+ fakesnow/server.py,sha256=PGNuYEpmI0L0ZrwBCP1pDTc_lnFrtfSnlZ6zyVuCTqk,7173
21
21
  fakesnow/variables.py,sha256=BGnD4LAdVByfJ2GXL6qpGBaTF8ZJRjt3pdJsd9sIAcw,3134
22
- fakesnow/transforms/__init__.py,sha256=ZT0ehX8F1PPUY1983RjVofJ5spPE2syen5henc2m1Sk,2722
22
+ fakesnow/transforms/__init__.py,sha256=OE-dunCuum8lv832s2cjEzThgBnpKELo5aaTXD_bMNg,2807
23
23
  fakesnow/transforms/merge.py,sha256=Pg7_rwbAT_vr1U4ocBofUSyqaK8_e3qdIz_2SDm2S3s,8320
24
24
  fakesnow/transforms/show.py,sha256=ejvs9S2l2Wcal4fhnNSVs3JkZwKsFxMEU35ufUV3-kg,20421
25
- fakesnow/transforms/stage.py,sha256=jkabKkxFEYjTVuLsDH3fgjyhFdc_GGh3DavWv7G7k68,2560
26
- fakesnow/transforms/transforms.py,sha256=I2X7KZ5wphq1NJvfbFmY8JYE-8vpVHMn-KTXurMxouE,47149
27
- fakesnow-0.9.39.dist-info/licenses/LICENSE,sha256=kW-7NWIyaRMQiDpryfSmF2DObDZHGR1cJZ39s6B1Svg,11344
25
+ fakesnow/transforms/stage.py,sha256=FSIyI5kpthD_pdbVfzYCrby5HaikMZUpdRr6oBSrgk4,6176
26
+ fakesnow/transforms/transforms.py,sha256=t99pHmNm8aG89o738zVSXCG6a0dOXpnY1OqAz28EQHc,48072
27
+ fakesnow-0.9.40.dist-info/licenses/LICENSE,sha256=kW-7NWIyaRMQiDpryfSmF2DObDZHGR1cJZ39s6B1Svg,11344
28
28
  tools/decode.py,sha256=kC5kUvLQxdCkMRsnH6BqCajlKxKeN77w6rwCKsY6gqU,1781
29
- fakesnow-0.9.39.dist-info/METADATA,sha256=IlCT__J_g9f5ltb3eC7t0cUs5t3Aq0BaX4eNcKkxCYE,20680
30
- fakesnow-0.9.39.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
- fakesnow-0.9.39.dist-info/entry_points.txt,sha256=2riAUgu928ZIHawtO8EsfrMEJhi-EH-z_Vq7Q44xKPM,47
32
- fakesnow-0.9.39.dist-info/top_level.txt,sha256=Yos7YveA3f03xVYuURqnBsfMV2DePXfu_yGcsj3pPzI,30
33
- fakesnow-0.9.39.dist-info/RECORD,,
29
+ fakesnow-0.9.40.dist-info/METADATA,sha256=ssyuxQf4dJU5Mt5dQYr14H52eO9r62X1NIzdfJ7k1jA,20680
30
+ fakesnow-0.9.40.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
+ fakesnow-0.9.40.dist-info/entry_points.txt,sha256=2riAUgu928ZIHawtO8EsfrMEJhi-EH-z_Vq7Q44xKPM,47
32
+ fakesnow-0.9.40.dist-info/top_level.txt,sha256=Yos7YveA3f03xVYuURqnBsfMV2DePXfu_yGcsj3pPzI,30
33
+ fakesnow-0.9.40.dist-info/RECORD,,