fakesnow 0.9.22__py3-none-any.whl → 0.9.24__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/arrow.py CHANGED
@@ -1,3 +1,5 @@
1
+ from typing import Any
2
+
1
3
  import pyarrow as pa
2
4
 
3
5
 
@@ -8,10 +10,21 @@ def with_sf_metadata(schema: pa.Schema) -> pa.Schema:
8
10
  for i, t in enumerate(schema.types):
9
11
  f = schema.field(i)
10
12
 
11
- if isinstance(t, pa.Decimal128Type):
13
+ # TODO: precision, scale, charLength etc. for all types
14
+
15
+ if t == pa.bool_():
16
+ fm = f.with_metadata({"logicalType": "BOOLEAN"})
17
+ elif t == pa.int64():
18
+ # scale and precision required, see here
19
+ # https://github.com/snowflakedb/snowflake-connector-python/blob/416ff57/src/snowflake/connector/nanoarrow_cpp/ArrowIterator/CArrowChunkIterator.cpp#L147
20
+ fm = f.with_metadata({"logicalType": "FIXED", "precision": "38", "scale": "0"})
21
+ elif t == pa.float64():
22
+ fm = f.with_metadata({"logicalType": "REAL"})
23
+ elif isinstance(t, pa.Decimal128Type):
12
24
  fm = f.with_metadata({"logicalType": "FIXED", "precision": str(t.precision), "scale": str(t.scale)})
13
25
  elif t == pa.string():
14
- fm = f.with_metadata({"logicalType": "TEXT"})
26
+ # TODO: set charLength to size of column
27
+ fm = f.with_metadata({"logicalType": "TEXT", "charLength": "16777216"})
15
28
  else:
16
29
  raise NotImplementedError(f"Unsupported Arrow type: {t}")
17
30
  fms.append(fm)
@@ -26,7 +39,29 @@ def to_ipc(table: pa.Table) -> pa.Buffer:
26
39
 
27
40
  sink = pa.BufferOutputStream()
28
41
 
29
- with pa.ipc.new_stream(sink, with_sf_metadata(batch.schema)) as writer:
42
+ with pa.ipc.new_stream(sink, with_sf_metadata(table.schema)) as writer:
30
43
  writer.write_batch(batch)
31
44
 
32
45
  return sink.getvalue()
46
+
47
+
48
+ # TODO: should this be derived before with_schema?
49
+ def to_rowtype(schema: pa.Schema) -> list[dict[str, Any]]:
50
+ return [
51
+ {
52
+ "name": f.name,
53
+ # TODO
54
+ # "database": "",
55
+ # "schema": "",
56
+ # "table": "",
57
+ "nullable": f.nullable,
58
+ "type": f.metadata.get(b"logicalType").decode("utf-8").lower(), # type: ignore
59
+ # TODO
60
+ # "byteLength": 20,
61
+ "length": int(f.metadata.get(b"charLength")) if f.metadata.get(b"charLength") else None, # type: ignore
62
+ "scale": int(f.metadata.get(b"scale")) if f.metadata.get(b"scale") else None, # type: ignore
63
+ "precision": int(f.metadata.get(b"precision")) if f.metadata.get(b"precision") else None, # type: ignore
64
+ "collation": None,
65
+ }
66
+ for f in schema
67
+ ]
fakesnow/conn.py ADDED
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Iterable
5
+ from pathlib import Path
6
+ from types import TracebackType
7
+ from typing import Any
8
+
9
+ import snowflake.connector.converter
10
+ import snowflake.connector.errors
11
+ import sqlglot
12
+ from duckdb import DuckDBPyConnection
13
+ from snowflake.connector.cursor import DictCursor, SnowflakeCursor
14
+ from sqlglot import exp
15
+ from typing_extensions import Self
16
+
17
+ import fakesnow.info_schema as info_schema
18
+ import fakesnow.macros as macros
19
+ from fakesnow.cursor import FakeSnowflakeCursor
20
+ from fakesnow.variables import Variables
21
+
22
+
23
+ class FakeSnowflakeConnection:
24
+ def __init__(
25
+ self,
26
+ duck_conn: DuckDBPyConnection,
27
+ database: str | None = None,
28
+ schema: str | None = None,
29
+ create_database: bool = True,
30
+ create_schema: bool = True,
31
+ db_path: str | os.PathLike | None = None,
32
+ nop_regexes: list[str] | None = None,
33
+ *args: Any,
34
+ **kwargs: Any,
35
+ ):
36
+ self._duck_conn = duck_conn
37
+ self._is_closed = False
38
+ # upper case database and schema like snowflake unquoted identifiers
39
+ # so they appear as upper-cased in information_schema
40
+ # catalog and schema names are not actually case-sensitive in duckdb even though
41
+ # they are as cased in information_schema.schemata, so when selecting from
42
+ # information_schema.schemata below we use upper-case to match any existing duckdb
43
+ # catalog or schemas like "information_schema"
44
+ self.database = database and database.upper()
45
+ self.schema = schema and schema.upper()
46
+
47
+ self.database_set = False
48
+ self.schema_set = False
49
+ self.db_path = Path(db_path) if db_path else None
50
+ self.nop_regexes = nop_regexes
51
+ self._paramstyle = snowflake.connector.paramstyle
52
+ self.variables = Variables()
53
+
54
+ # create database if needed
55
+ if (
56
+ create_database
57
+ and self.database
58
+ and not duck_conn.execute(
59
+ f"""select * from information_schema.schemata
60
+ where upper(catalog_name) = '{self.database}'"""
61
+ ).fetchone()
62
+ ):
63
+ db_file = f"{self.db_path/self.database}.db" if self.db_path else ":memory:"
64
+ duck_conn.execute(f"ATTACH DATABASE '{db_file}' AS {self.database}")
65
+ duck_conn.execute(info_schema.creation_sql(self.database))
66
+ duck_conn.execute(macros.creation_sql(self.database))
67
+
68
+ # create schema if needed
69
+ if (
70
+ create_schema
71
+ and self.database
72
+ and self.schema
73
+ and not duck_conn.execute(
74
+ f"""select * from information_schema.schemata
75
+ where upper(catalog_name) = '{self.database}' and upper(schema_name) = '{self.schema}'"""
76
+ ).fetchone()
77
+ ):
78
+ duck_conn.execute(f"CREATE SCHEMA {self.database}.{self.schema}")
79
+
80
+ # set database and schema if both exist
81
+ if (
82
+ self.database
83
+ and self.schema
84
+ and duck_conn.execute(
85
+ f"""select * from information_schema.schemata
86
+ where upper(catalog_name) = '{self.database}' and upper(schema_name) = '{self.schema}'"""
87
+ ).fetchone()
88
+ ):
89
+ duck_conn.execute(f"SET schema='{self.database}.{self.schema}'")
90
+ self.database_set = True
91
+ self.schema_set = True
92
+ # set database if only that exists
93
+ elif (
94
+ self.database
95
+ and duck_conn.execute(
96
+ f"""select * from information_schema.schemata
97
+ where upper(catalog_name) = '{self.database}'"""
98
+ ).fetchone()
99
+ ):
100
+ duck_conn.execute(f"SET schema='{self.database}.main'")
101
+ self.database_set = True
102
+
103
+ # use UTC instead of local time zone for consistent testing
104
+ duck_conn.execute("SET GLOBAL TimeZone = 'UTC'")
105
+
106
+ def __enter__(self) -> Self:
107
+ return self
108
+
109
+ def __exit__(
110
+ self,
111
+ exc_type: type[BaseException] | None,
112
+ exc_value: BaseException | None,
113
+ traceback: TracebackType | None,
114
+ ) -> None:
115
+ pass
116
+
117
+ def close(self, retry: bool = True) -> None:
118
+ self._duck_conn.close()
119
+ self._is_closed = True
120
+
121
+ def commit(self) -> None:
122
+ self.cursor().execute("COMMIT")
123
+
124
+ def cursor(self, cursor_class: type[SnowflakeCursor] = SnowflakeCursor) -> FakeSnowflakeCursor:
125
+ # TODO: use duck_conn cursor for thread-safety
126
+ return FakeSnowflakeCursor(conn=self, duck_conn=self._duck_conn, use_dict_result=cursor_class == DictCursor)
127
+
128
+ def execute_string(
129
+ self,
130
+ sql_text: str,
131
+ remove_comments: bool = False,
132
+ return_cursors: bool = True,
133
+ cursor_class: type[SnowflakeCursor] = SnowflakeCursor,
134
+ **kwargs: dict[str, Any],
135
+ ) -> Iterable[FakeSnowflakeCursor]:
136
+ cursors = [
137
+ self.cursor(cursor_class).execute(e.sql(dialect="snowflake"))
138
+ for e in sqlglot.parse(sql_text, read="snowflake")
139
+ if e and not isinstance(e, exp.Semicolon) # ignore comments
140
+ ]
141
+ return cursors if return_cursors else []
142
+
143
+ def is_closed(self) -> bool:
144
+ return self._is_closed
145
+
146
+ def rollback(self) -> None:
147
+ self.cursor().execute("ROLLBACK")
fakesnow/cursor.py ADDED
@@ -0,0 +1,465 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import sys
6
+ from collections.abc import Iterator, Sequence
7
+ from string import Template
8
+ from types import TracebackType
9
+ from typing import TYPE_CHECKING, Any, cast
10
+
11
+ import duckdb
12
+ import pyarrow
13
+ import snowflake.connector.converter
14
+ import snowflake.connector.errors
15
+ import sqlglot
16
+ from duckdb import DuckDBPyConnection
17
+ from snowflake.connector.cursor import ResultMetadata
18
+ from snowflake.connector.result_batch import ResultBatch
19
+ from sqlglot import exp, parse_one
20
+ from typing_extensions import Self
21
+
22
+ import fakesnow.checks as checks
23
+ import fakesnow.expr as expr
24
+ import fakesnow.info_schema as info_schema
25
+ import fakesnow.transforms as transforms
26
+ from fakesnow.types import describe_as_result_metadata
27
+
28
+ if TYPE_CHECKING:
29
+ # don't require pandas at import time
30
+ import pandas as pd
31
+ import pyarrow.lib
32
+
33
+ # avoid circular import
34
+ from fakesnow.conn import FakeSnowflakeConnection
35
+
36
+
37
+ SCHEMA_UNSET = "schema_unset"
38
+ SQL_SUCCESS = "SELECT 'Statement executed successfully.' as 'status'"
39
+ SQL_CREATED_DATABASE = Template("SELECT 'Database ${name} successfully created.' as 'status'")
40
+ SQL_CREATED_SCHEMA = Template("SELECT 'Schema ${name} successfully created.' as 'status'")
41
+ SQL_CREATED_TABLE = Template("SELECT 'Table ${name} successfully created.' as 'status'")
42
+ SQL_CREATED_VIEW = Template("SELECT 'View ${name} successfully created.' as 'status'")
43
+ SQL_DROPPED = Template("SELECT '${name} successfully dropped.' as 'status'")
44
+ SQL_INSERTED_ROWS = Template("SELECT ${count} as 'number of rows inserted'")
45
+ SQL_UPDATED_ROWS = Template("SELECT ${count} as 'number of rows updated', 0 as 'number of multi-joined rows updated'")
46
+ SQL_DELETED_ROWS = Template("SELECT ${count} as 'number of rows deleted'")
47
+
48
+
49
+ class FakeSnowflakeCursor:
50
+ def __init__(
51
+ self,
52
+ conn: FakeSnowflakeConnection,
53
+ duck_conn: DuckDBPyConnection,
54
+ use_dict_result: bool = False,
55
+ ) -> None:
56
+ """Create a fake snowflake cursor backed by DuckDB.
57
+
58
+ Args:
59
+ conn (FakeSnowflakeConnection): Used to maintain current database and schema.
60
+ duck_conn (DuckDBPyConnection): DuckDB connection.
61
+ use_dict_result (bool, optional): If true rows are returned as dicts otherwise they
62
+ are returned as tuples. Defaults to False.
63
+ """
64
+ self._conn = conn
65
+ self._duck_conn = duck_conn
66
+ self._use_dict_result = use_dict_result
67
+ self._last_sql = None
68
+ self._last_params = None
69
+ self._sqlstate = None
70
+ self._arraysize = 1
71
+ self._arrow_table = None
72
+ self._arrow_table_fetch_index = None
73
+ self._rowcount = None
74
+ self._converter = snowflake.connector.converter.SnowflakeConverter()
75
+
76
+ def __enter__(self) -> Self:
77
+ return self
78
+
79
+ def __exit__(
80
+ self,
81
+ exc_type: type[BaseException] | None,
82
+ exc_value: BaseException | None,
83
+ traceback: TracebackType | None,
84
+ ) -> None:
85
+ pass
86
+
87
+ @property
88
+ def arraysize(self) -> int:
89
+ return self._arraysize
90
+
91
+ @arraysize.setter
92
+ def arraysize(self, value: int) -> None:
93
+ self._arraysize = value
94
+
95
+ def close(self) -> bool:
96
+ self._last_sql = None
97
+ self._last_params = None
98
+ return True
99
+
100
+ def describe(self, command: str, *args: Any, **kwargs: Any) -> list[ResultMetadata]:
101
+ """Return the schema of the result without executing the query.
102
+
103
+ Takes the same arguments as execute
104
+
105
+ Returns:
106
+ list[ResultMetadata]: _description_
107
+ """
108
+
109
+ describe = f"DESCRIBE {command}"
110
+ self.execute(describe, *args, **kwargs)
111
+ return describe_as_result_metadata(self.fetchall())
112
+
113
+ @property
114
+ def description(self) -> list[ResultMetadata]:
115
+ # use a separate cursor to avoid consuming the result set on this cursor
116
+ with self._conn.cursor() as cur:
117
+ expression = sqlglot.parse_one(f"DESCRIBE {self._last_sql}", read="duckdb")
118
+ cur._execute(expression, self._last_params) # noqa: SLF001
119
+ meta = describe_as_result_metadata(cur.fetchall())
120
+
121
+ return meta
122
+
123
+ def execute(
124
+ self,
125
+ command: str,
126
+ params: Sequence[Any] | dict[Any, Any] | None = None,
127
+ *args: Any,
128
+ **kwargs: Any,
129
+ ) -> FakeSnowflakeCursor:
130
+ try:
131
+ self._sqlstate = None
132
+
133
+ if os.environ.get("FAKESNOW_DEBUG") == "snowflake":
134
+ print(f"{command};{params=}" if params else f"{command};", file=sys.stderr)
135
+
136
+ command = self._inline_variables(command)
137
+ command, params = self._rewrite_with_params(command, params)
138
+ if self._conn.nop_regexes and any(re.match(p, command, re.IGNORECASE) for p in self._conn.nop_regexes):
139
+ transformed = transforms.SUCCESS_NOP
140
+ else:
141
+ expression = parse_one(command, read="snowflake")
142
+ transformed = self._transform(expression)
143
+ return self._execute(transformed, params)
144
+ except snowflake.connector.errors.ProgrammingError as e:
145
+ self._sqlstate = e.sqlstate
146
+ raise e
147
+
148
+ def _transform(self, expression: exp.Expression) -> exp.Expression:
149
+ return (
150
+ expression.transform(transforms.upper_case_unquoted_identifiers)
151
+ .transform(transforms.update_variables, variables=self._conn.variables)
152
+ .transform(transforms.set_schema, current_database=self._conn.database)
153
+ .transform(transforms.create_database, db_path=self._conn.db_path)
154
+ .transform(transforms.extract_comment_on_table)
155
+ .transform(transforms.extract_comment_on_columns)
156
+ .transform(transforms.information_schema_fs_columns_snowflake)
157
+ .transform(transforms.information_schema_fs_tables_ext)
158
+ .transform(transforms.drop_schema_cascade)
159
+ .transform(transforms.tag)
160
+ .transform(transforms.semi_structured_types)
161
+ .transform(transforms.try_parse_json)
162
+ .transform(transforms.split)
163
+ # NOTE: trim_cast_varchar must be before json_extract_cast_as_varchar
164
+ .transform(transforms.trim_cast_varchar)
165
+ # indices_to_json_extract must be before regex_substr
166
+ .transform(transforms.indices_to_json_extract)
167
+ .transform(transforms.json_extract_cast_as_varchar)
168
+ .transform(transforms.json_extract_cased_as_varchar)
169
+ .transform(transforms.json_extract_precedence)
170
+ .transform(transforms.flatten_value_cast_as_varchar)
171
+ .transform(transforms.flatten)
172
+ .transform(transforms.regex_replace)
173
+ .transform(transforms.regex_substr)
174
+ .transform(transforms.values_columns)
175
+ .transform(transforms.to_date)
176
+ .transform(transforms.to_decimal)
177
+ .transform(transforms.try_to_decimal)
178
+ .transform(transforms.to_timestamp_ntz)
179
+ .transform(transforms.to_timestamp)
180
+ .transform(transforms.object_construct)
181
+ .transform(transforms.timestamp_ntz)
182
+ .transform(transforms.float_to_double)
183
+ .transform(transforms.integer_precision)
184
+ .transform(transforms.extract_text_length)
185
+ .transform(transforms.sample)
186
+ .transform(transforms.array_size)
187
+ .transform(transforms.random)
188
+ .transform(transforms.identifier)
189
+ .transform(transforms.array_agg_within_group)
190
+ .transform(transforms.array_agg)
191
+ .transform(transforms.dateadd_date_cast)
192
+ .transform(transforms.dateadd_string_literal_timestamp_cast)
193
+ .transform(transforms.datediff_string_literal_timestamp_cast)
194
+ .transform(lambda e: transforms.show_schemas(e, self._conn.database))
195
+ .transform(lambda e: transforms.show_objects_tables(e, self._conn.database))
196
+ # TODO collapse into a single show_keys function
197
+ .transform(lambda e: transforms.show_keys(e, self._conn.database, kind="PRIMARY"))
198
+ .transform(lambda e: transforms.show_keys(e, self._conn.database, kind="UNIQUE"))
199
+ .transform(lambda e: transforms.show_keys(e, self._conn.database, kind="FOREIGN"))
200
+ .transform(transforms.show_users)
201
+ .transform(transforms.create_user)
202
+ .transform(transforms.sha256)
203
+ .transform(transforms.create_clone)
204
+ .transform(transforms.alias_in_join)
205
+ .transform(transforms.alter_table_strip_cluster_by)
206
+ )
207
+
208
+ def _execute(
209
+ self, transformed: exp.Expression, params: Sequence[Any] | dict[Any, Any] | None = None
210
+ ) -> FakeSnowflakeCursor:
211
+ self._arrow_table = None
212
+ self._arrow_table_fetch_index = None
213
+ self._rowcount = None
214
+
215
+ cmd = expr.key_command(transformed)
216
+
217
+ no_database, no_schema = checks.is_unqualified_table_expression(transformed)
218
+
219
+ if no_database and not self._conn.database_set:
220
+ raise snowflake.connector.errors.ProgrammingError(
221
+ msg=f"Cannot perform {cmd}. This session does not have a current database. Call 'USE DATABASE', or use a qualified name.", # noqa: E501
222
+ errno=90105,
223
+ sqlstate="22000",
224
+ )
225
+ elif no_schema and not self._conn.schema_set:
226
+ raise snowflake.connector.errors.ProgrammingError(
227
+ msg=f"Cannot perform {cmd}. This session does not have a current schema. Call 'USE SCHEMA', or use a qualified name.", # noqa: E501
228
+ errno=90106,
229
+ sqlstate="22000",
230
+ )
231
+
232
+ sql = transformed.sql(dialect="duckdb")
233
+
234
+ if transformed.find(exp.Select) and (seed := transformed.args.get("seed")):
235
+ sql = f"SELECT setseed({seed}); {sql}"
236
+
237
+ result_sql = None
238
+
239
+ try:
240
+ self._log_sql(sql, params)
241
+ self._duck_conn.execute(sql, params)
242
+ except duckdb.BinderException as e:
243
+ msg = e.args[0]
244
+ raise snowflake.connector.errors.ProgrammingError(msg=msg, errno=2043, sqlstate="02000") from None
245
+ except duckdb.CatalogException as e:
246
+ # minimal processing to make it look like a snowflake exception, message content may differ
247
+ msg = cast(str, e.args[0]).split("\n")[0]
248
+ raise snowflake.connector.errors.ProgrammingError(msg=msg, errno=2003, sqlstate="42S02") from None
249
+ except duckdb.TransactionException as e:
250
+ if "cannot rollback - no transaction is active" in str(
251
+ e
252
+ ) or "cannot commit - no transaction is active" in str(e):
253
+ # snowflake doesn't error on rollback or commit outside a tx
254
+ result_sql = SQL_SUCCESS
255
+ else:
256
+ raise e
257
+ except duckdb.ConnectionException as e:
258
+ raise snowflake.connector.errors.DatabaseError(msg=e.args[0], errno=250002, sqlstate="08003") from None
259
+
260
+ affected_count = None
261
+
262
+ if set_database := transformed.args.get("set_database"):
263
+ self._conn.database = set_database
264
+ self._conn.database_set = True
265
+
266
+ elif set_schema := transformed.args.get("set_schema"):
267
+ self._conn.schema = set_schema
268
+ self._conn.schema_set = True
269
+
270
+ elif create_db_name := transformed.args.get("create_db_name"):
271
+ # we created a new database, so create the info schema extensions
272
+ self._duck_conn.execute(info_schema.creation_sql(create_db_name))
273
+ result_sql = SQL_CREATED_DATABASE.substitute(name=create_db_name)
274
+
275
+ elif cmd == "INSERT":
276
+ (affected_count,) = self._duck_conn.fetchall()[0]
277
+ result_sql = SQL_INSERTED_ROWS.substitute(count=affected_count)
278
+
279
+ elif cmd == "UPDATE":
280
+ (affected_count,) = self._duck_conn.fetchall()[0]
281
+ result_sql = SQL_UPDATED_ROWS.substitute(count=affected_count)
282
+
283
+ elif cmd == "DELETE":
284
+ (affected_count,) = self._duck_conn.fetchall()[0]
285
+ result_sql = SQL_DELETED_ROWS.substitute(count=affected_count)
286
+
287
+ elif cmd in ("DESCRIBE TABLE", "DESCRIBE VIEW"):
288
+ # DESCRIBE TABLE/VIEW has already been run above to detect and error if the table exists
289
+ # We now rerun DESCRIBE TABLE/VIEW but transformed with columns to match Snowflake
290
+ result_sql = transformed.transform(
291
+ lambda e: transforms.describe_table(e, self._conn.database, self._conn.schema)
292
+ ).sql(dialect="duckdb")
293
+
294
+ elif (eid := transformed.find(exp.Identifier, bfs=False)) and isinstance(eid.this, str):
295
+ ident = eid.this if eid.quoted else eid.this.upper()
296
+ if cmd == "CREATE SCHEMA" and ident:
297
+ result_sql = SQL_CREATED_SCHEMA.substitute(name=ident)
298
+
299
+ elif cmd == "CREATE TABLE" and ident:
300
+ result_sql = SQL_CREATED_TABLE.substitute(name=ident)
301
+
302
+ elif cmd.startswith("ALTER") and ident:
303
+ result_sql = SQL_SUCCESS
304
+
305
+ elif cmd == "CREATE VIEW" and ident:
306
+ result_sql = SQL_CREATED_VIEW.substitute(name=ident)
307
+
308
+ elif cmd.startswith("DROP") and ident:
309
+ result_sql = SQL_DROPPED.substitute(name=ident)
310
+
311
+ # if dropping the current database/schema then reset conn metadata
312
+ if cmd == "DROP DATABASE" and ident == self._conn.database:
313
+ self._conn.database = None
314
+ self._conn.schema = None
315
+
316
+ elif cmd == "DROP SCHEMA" and ident == self._conn.schema:
317
+ self._conn.schema = None
318
+
319
+ if table_comment := cast(tuple[exp.Table, str], transformed.args.get("table_comment")):
320
+ # record table comment
321
+ table, comment = table_comment
322
+ catalog = table.catalog or self._conn.database
323
+ schema = table.db or self._conn.schema
324
+ assert catalog and schema
325
+ self._duck_conn.execute(info_schema.insert_table_comment_sql(catalog, schema, table.name, comment))
326
+
327
+ if (text_lengths := cast(list[tuple[str, int]], transformed.args.get("text_lengths"))) and (
328
+ table := transformed.find(exp.Table)
329
+ ):
330
+ # record text lengths
331
+ catalog = table.catalog or self._conn.database
332
+ schema = table.db or self._conn.schema
333
+ assert catalog and schema
334
+ self._duck_conn.execute(info_schema.insert_text_lengths_sql(catalog, schema, table.name, text_lengths))
335
+
336
+ if result_sql:
337
+ self._log_sql(result_sql, params)
338
+ self._duck_conn.execute(result_sql)
339
+
340
+ self._arrow_table = self._duck_conn.fetch_arrow_table()
341
+ self._rowcount = affected_count or self._arrow_table.num_rows
342
+
343
+ self._last_sql = result_sql or sql
344
+ self._last_params = params
345
+
346
+ return self
347
+
348
+ def _log_sql(self, sql: str, params: Sequence[Any] | dict[Any, Any] | None = None) -> None:
349
+ if (fs_debug := os.environ.get("FAKESNOW_DEBUG")) and fs_debug != "snowflake":
350
+ print(f"{sql};{params=}" if params else f"{sql};", file=sys.stderr)
351
+
352
+ def executemany(
353
+ self,
354
+ command: str,
355
+ seqparams: Sequence[Any] | dict[str, Any],
356
+ **kwargs: Any,
357
+ ) -> FakeSnowflakeCursor:
358
+ if isinstance(seqparams, dict):
359
+ # see https://docs.snowflake.com/en/developer-guide/python-connector/python-connector-api
360
+ raise NotImplementedError("dict params not supported yet")
361
+
362
+ # TODO: support insert optimisations
363
+ # the snowflake connector will optimise inserts into a single query
364
+ # unless num_statements != 1 .. but for simplicity we execute each
365
+ # query one by one, which means the response differs
366
+ for p in seqparams:
367
+ self.execute(command, p)
368
+
369
+ return self
370
+
371
+ def fetchall(self) -> list[tuple] | list[dict]:
372
+ if self._arrow_table is None:
373
+ # mimic snowflake python connector error type
374
+ raise TypeError("No open result set")
375
+ return self.fetchmany(self._arrow_table.num_rows)
376
+
377
+ def fetch_pandas_all(self, **kwargs: dict[str, Any]) -> pd.DataFrame:
378
+ if self._arrow_table is None:
379
+ # mimic snowflake python connector error type
380
+ raise snowflake.connector.NotSupportedError("No open result set")
381
+ return self._arrow_table.to_pandas()
382
+
383
+ def fetchone(self) -> dict | tuple | None:
384
+ result = self.fetchmany(1)
385
+ return result[0] if result else None
386
+
387
+ def fetchmany(self, size: int | None = None) -> list[tuple] | list[dict]:
388
+ # https://peps.python.org/pep-0249/#fetchmany
389
+ size = size or self._arraysize
390
+
391
+ if self._arrow_table is None:
392
+ # mimic snowflake python connector error type
393
+ raise TypeError("No open result set")
394
+ tslice = self._arrow_table.slice(offset=self._arrow_table_fetch_index or 0, length=size).to_pylist()
395
+
396
+ if self._arrow_table_fetch_index is None:
397
+ self._arrow_table_fetch_index = size
398
+ else:
399
+ self._arrow_table_fetch_index += size
400
+
401
+ return tslice if self._use_dict_result else [tuple(d.values()) for d in tslice]
402
+
403
+ def get_result_batches(self) -> list[ResultBatch] | None:
404
+ if self._arrow_table is None:
405
+ return None
406
+ return [FakeResultBatch(self._use_dict_result, b) for b in self._arrow_table.to_batches(max_chunksize=1000)]
407
+
408
+ @property
409
+ def rowcount(self) -> int | None:
410
+ return self._rowcount
411
+
412
+ @property
413
+ def sfqid(self) -> str | None:
414
+ return "fakesnow"
415
+
416
+ @property
417
+ def sqlstate(self) -> str | None:
418
+ return self._sqlstate
419
+
420
+ def _rewrite_with_params(
421
+ self,
422
+ command: str,
423
+ params: Sequence[Any] | dict[Any, Any] | None = None,
424
+ ) -> tuple[str, Sequence[Any] | dict[Any, Any] | None]:
425
+ if params and self._conn._paramstyle in ("pyformat", "format"): # noqa: SLF001
426
+ # handle client-side in the same manner as the snowflake python connector
427
+
428
+ def convert(param: Any) -> Any: # noqa: ANN401
429
+ return self._converter.quote(self._converter.escape(self._converter.to_snowflake(param)))
430
+
431
+ if isinstance(params, dict):
432
+ params = {k: convert(v) for k, v in params.items()}
433
+ else:
434
+ params = tuple(convert(v) for v in params)
435
+
436
+ return command % params, None
437
+
438
+ return command, params
439
+
440
+ def _inline_variables(self, sql: str) -> str:
441
+ return self._conn.variables.inline_variables(sql)
442
+
443
+
444
+ class FakeResultBatch(ResultBatch):
445
+ def __init__(self, use_dict_result: bool, batch: pyarrow.RecordBatch):
446
+ self._use_dict_result = use_dict_result
447
+ self._batch = batch
448
+
449
+ def create_iter(
450
+ self, **kwargs: dict[str, Any]
451
+ ) -> Iterator[dict | Exception] | Iterator[tuple | Exception] | Iterator[pyarrow.Table] | Iterator[pd.DataFrame]:
452
+ if self._use_dict_result:
453
+ return iter(self._batch.to_pylist())
454
+
455
+ return iter(tuple(d.values()) for d in self._batch.to_pylist())
456
+
457
+ @property
458
+ def rowcount(self) -> int:
459
+ return self._batch.num_rows
460
+
461
+ def to_pandas(self) -> pd.DataFrame:
462
+ return self._batch.to_pandas()
463
+
464
+ def to_arrow(self) -> pyarrow.Table:
465
+ raise NotImplementedError()