Repository: ibis-project/ibis

Issue:
feat: Add Support for read_csv with Snowflake backend
### Is your feature request related to a problem?

Many backends support the ability to read CSVs and make them available as tables. Snowflake can query CSVs directly from stages and customizable file formats, but the file must be available in the stage. 

### Describe the solution you'd like

Use a Snowflake connection to read_csv. This could be extended to other formats supported by Snowflake later if desired. 

I mocked up a local function and was able to get this working, using a Snowflake backend and some of the other logic I found that was available. I've published a Hex Project app that demonstrates this working with the code to repro. 

https://app.hex.tech/99d4b06a-ff63-47a7-8092-ee0a51db9be9/app/04a6c277-acae-4caa-9cbe-4a29be629cc9/latest

Here are the commands as they are showing up behind the scenes:
```
CREATE TEMP STAGE _ibis_stage_t33lak4wzzgthfz7jnpon4fbee;
PUT 'file://penguins.csv' @_ibis_stage_t33lak4wzzgthfz7jnpon4fbee
        PARALLEL = 32
        AUTO_COMPRESS = FALSE;
CREATE TEMP FILE FORMAT _ibis_format_zra7ozvdhrdyxbiyrxdtvrv45e
        TYPE = CSV
        PARSE_HEADER = TRUE;
 CREATE TEMP TABLE _ibis_table_y3vaciknijaolcsta3x653bjlm
            USING TEMPLATE (
                SELECT ARRAY_AGG(OBJECT_CONSTRUCT(*))
                FROM TABLE(
                    INFER_SCHEMA(
                    LOCATION=>'@_ibis_stage_t33lak4wzzgthfz7jnpon4fbee',
                    FILE_FORMAT=>'_ibis_format_zra7ozvdhrdyxbiyrxdtvrv45e'
                    )
                ));
  COPY INTO _ibis_table_y3vaciknijaolcsta3x653bjlm
            FROM @_ibis_stage_t33lak4wzzgthfz7jnpon4fbee
            FILE_FORMAT = (TYPE = CSV, SKIP_HEADER = 1);
   ```
   
This solution takes advantage of Snowflake's newer `INFER_SCHEMA` feature to generate a table using the schema of the CSV. However, the previously generated file format will not work with the COPY INTO, which is why I opted to spec the type manually. 

### What version of ibis are you running?

6.1.0

### What backend(s) are you using, if any?

Snowflake

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:

Resolving patch:
diff --git a/ibis/backends/pyspark/__init__.py b/ibis/backends/pyspark/__init__.py
--- a/ibis/backends/pyspark/__init__.py
+++ b/ibis/backends/pyspark/__init__.py
@@ -671,8 +671,12 @@ def read_csv(
         ir.Table
             The just-registered table
         """
+        inferSchema = kwargs.pop("inferSchema", True)
+        header = kwargs.pop("header", True)
         source_list = normalize_filenames(source_list)
-        spark_df = self._session.read.csv(source_list, **kwargs)
+        spark_df = self._session.read.csv(
+            source_list, inferSchema=inferSchema, header=header, **kwargs
+        )
         table_name = table_name or util.gen_name("read_csv")
 
         spark_df.createOrReplaceTempView(table_name)
diff --git a/ibis/backends/snowflake/__init__.py b/ibis/backends/snowflake/__init__.py
--- a/ibis/backends/snowflake/__init__.py
+++ b/ibis/backends/snowflake/__init__.py
@@ -11,6 +11,7 @@
 import tempfile
 import textwrap
 import warnings
+from pathlib import Path
 from typing import TYPE_CHECKING, Any, Iterable, Iterator, Mapping
 
 import pyarrow as pa
@@ -675,6 +676,97 @@ def drop_table(
         with self.begin() as con:
             con.exec_driver_sql(drop_stmt)
 
+    def read_csv(
+        self, path: str | Path, table_name: str | None = None, **kwargs: Any
+    ) -> ir.Table:
+        """Register a CSV file as a table in the Snowflake backend.
+
+        Parameters
+        ----------
+        path
+            Path to the CSV file
+        table_name
+            Optional name for the table; if not passed, a random name will be generated
+        kwargs
+            Snowflake-specific file format configuration arguments. See the documentation for
+            the full list of options: https://docs.snowflake.com/en/sql-reference/sql/create-file-format#type-csv
+
+        Returns
+        -------
+        Table
+            The table that was read from the CSV file
+        """
+        stage = ibis.util.gen_name("stage")
+        file_format = ibis.util.gen_name("format")
+        # 99 is the maximum allowed number of threads by Snowflake:
+        # https://docs.snowflake.com/en/sql-reference/sql/put#optional-parameters
+        threads = min((os.cpu_count() or 2) // 2, 99)
+        table = table_name or ibis.util.gen_name("read_csv_snowflake")
+
+        parse_header = header = kwargs.pop("parse_header", True)
+        skip_header = kwargs.pop("skip_header", True)
+
+        if int(parse_header) != int(skip_header):
+            raise com.IbisInputError(
+                "`parse_header` and `skip_header` must match: "
+                f"parse_header = {parse_header}, skip_header = {skip_header}"
+            )
+
+        options = " " * bool(kwargs) + " ".join(
+            f"{name.upper()} = {value!r}" for name, value in kwargs.items()
+        )
+
+        with self.begin() as con:
+            # create a temporary stage for the file
+            con.exec_driver_sql(f"CREATE TEMP STAGE {stage}")
+
+            # create a temporary file format for CSV schema inference
+            create_infer_fmt = (
+                f"CREATE TEMP FILE FORMAT {file_format}_infer TYPE = CSV PARSE_HEADER = {str(header).upper()}"
+                + options
+            )
+            con.exec_driver_sql(create_infer_fmt)
+
+            # create a temporary file format for loading
+            create_load_fmt = (
+                f"CREATE TEMP FILE FORMAT {file_format}_load TYPE = CSV SKIP_HEADER = {int(header)}"
+                + options
+            )
+            con.exec_driver_sql(create_load_fmt)
+
+            # copy the local file to the stage
+            con.exec_driver_sql(
+                f"PUT '{Path(path).absolute().as_uri()}' @{stage} PARALLEL = {threads:d} AUTO_COMPRESS = FALSE"
+            )
+
+            # create a temporary table using the stage and format inferred
+            # from the CSV
+            con.exec_driver_sql(
+                f"""
+                CREATE TEMP TABLE "{table}"
+                USING TEMPLATE (
+                    SELECT ARRAY_AGG(OBJECT_CONSTRUCT(*))
+                    FROM TABLE(
+                        INFER_SCHEMA(
+                            LOCATION => '@{stage}',
+                            FILE_FORMAT => '{file_format}_infer'
+                        )
+                    )
+                )
+                """
+            )
+
+            # load the CSV into the table
+            con.exec_driver_sql(
+                f"""
+                COPY INTO "{table}"
+                FROM @{stage}
+                FILE_FORMAT = (FORMAT_NAME = {file_format}_load)
+                """
+            )
+
+        return self.table(table)
+
 
 @compiles(sa.sql.Join, "snowflake")
 def compile_join(element, compiler, **kw):


Test patch:
diff --git a/ibis/backends/snowflake/tests/test_client.py b/ibis/backends/snowflake/tests/test_client.py
--- a/ibis/backends/snowflake/tests/test_client.py
+++ b/ibis/backends/snowflake/tests/test_client.py
@@ -173,3 +173,12 @@ def test_drop_current_schema_not_allowed(schema_con):
         c.exec_driver_sql(f"USE SCHEMA {cur_schema}")
 
     schema_con.drop_schema(schema)
+
+
+def test_read_csv_options(con, tmp_path):
+    path = tmp_path / "test_pipe.csv"
+    path.write_text("a|b\n1|2\n3|4\n")
+
+    t = con.read_csv(path, field_delimiter="|")
+
+    assert t.schema() == ibis.schema(dict(a="int64", b="int64"))
diff --git a/ibis/backends/tests/test_register.py b/ibis/backends/tests/test_register.py
--- a/ibis/backends/tests/test_register.py
+++ b/ibis/backends/tests/test_register.py
@@ -10,6 +10,7 @@
 import pytest
 from pytest import param
 
+import ibis
 from ibis.backends.conftest import TEST_TABLES
 
 if TYPE_CHECKING:
@@ -437,16 +438,33 @@ def test_read_parquet(con, tmp_path, data_dir, fname, in_table_name, out_table_n
         table.count().execute()
 
 
+@pytest.fixture(scope="module")
+def num_diamonds(data_dir):
+    with open(data_dir / "csv" / "diamonds.csv") as f:
+        # subtract 1 for the header
+        return sum(1 for _ in f) - 1
+
+
+DIAMONDS_COLUMN_TYPES = {
+    # snowflake's `INFER_SCHEMA` returns this for the diamonds CSV `price`
+    # column type
+    "snowflake": {
+        "carat": "decimal(3, 2)",
+        "depth": "decimal(3, 1)",
+        "table": "decimal(3, 1)",
+        "x": "decimal(4, 2)",
+        "y": "decimal(4, 2)",
+        "z": "decimal(4, 2)",
+    },
+    "pyspark": {"price": "int32"},
+}
+
+
 @pytest.mark.parametrize(
-    ("fname", "in_table_name", "out_table_name"),
+    ("in_table_name", "out_table_name"),
     [
-        param("diamonds.csv", None, "ibis_read_csv_", id="default"),
-        param(
-            "diamonds.csv",
-            "fancy_stones",
-            "fancy_stones",
-            id="file_name",
-        ),
+        param(None, "ibis_read_csv_", id="default"),
+        param("fancy_stones", "fancy_stones", id="file_name"),
     ],
 )
 @pytest.mark.notyet(
@@ -459,12 +477,12 @@ def test_read_parquet(con, tmp_path, data_dir, fname, in_table_name, out_table_n
         "mysql",
         "pandas",
         "postgres",
-        "snowflake",
         "sqlite",
         "trino",
     ]
 )
-def test_read_csv(con, data_dir, fname, in_table_name, out_table_name):
+def test_read_csv(con, data_dir, in_table_name, out_table_name, num_diamonds):
+    fname = "diamonds.csv"
     with pushd(data_dir / "csv"):
         if con.name == "pyspark":
             # pyspark doesn't respect CWD
@@ -472,5 +490,22 @@ def test_read_csv(con, data_dir, fname, in_table_name, out_table_name):
         table = con.read_csv(fname, table_name=in_table_name)
 
     assert any(out_table_name in t for t in con.list_tables())
-    if con.name != "datafusion":
-        table.count().execute()
+
+    special_types = DIAMONDS_COLUMN_TYPES.get(con.name, {})
+
+    assert table.schema() == ibis.schema(
+        {
+            "carat": "float64",
+            "cut": "string",
+            "color": "string",
+            "clarity": "string",
+            "depth": "float64",
+            "table": "float64",
+            "price": "int64",
+            "x": "float64",
+            "y": "float64",
+            "z": "float64",
+            **special_types,
+        }
+    )
+    assert table.count().execute() == num_diamonds