blob: a7cb8d9980c419559c2e7010813f09bc1102bc10 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import os
import pathlib
import platform
import re
import typing
import uuid
import pytest
import adbc_driver_manager.dbapi as dbapi
pytestmark = [pytest.mark.sqlite]
@pytest.fixture(scope="module", autouse=True)
def profile_dir(tmp_path_factory) -> typing.Generator[pathlib.Path, None, None]:
path = tmp_path_factory.mktemp("profile_dir")
with pytest.MonkeyPatch().context() as mp:
mp.setenv("ADBC_PROFILE_PATH", str(path))
yield path
@pytest.fixture(scope="module")
def sqlitedev(profile_dir) -> str:
with (profile_dir / "sqlitedev.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
""")
return "sqlitedev"
def test_profile_option(sqlitedev) -> None:
# Test loading via "profile" option
with dbapi.connect(profile=sqlitedev) as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
def test_profile_redundant(sqlitedev) -> None:
# Test that proving the profile twice is rejected
with pytest.raises(dbapi.ProgrammingError, match="Multiple profiles specified"):
with dbapi.connect(uri=f"profile://{sqlitedev}", profile=sqlitedev):
pass
with pytest.raises(dbapi.ProgrammingError, match="Multiple profiles specified"):
with dbapi.connect(driver=f"profile://{sqlitedev}", profile=sqlitedev):
pass
def test_option_env_var(subtests, tmp_path, monkeypatch) -> None:
# Test a profile that uses env var substitution for option values
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
# ruff: disable[E501]
# fmt: off
cases = [
# env vars, raw string, rendered value
# Make sure manager doesn't misinterpret empty string as null (there
# is no null in TOML)
({}, "", ""),
# No actual substitution
({}, "{{ env_var(NONEXISTENT)", "{{ env_var(NONEXISTENT)"),
({}, "{{ env_var(NONEXISTENT) }", "{{ env_var(NONEXISTENT) }"),
({}, "{ env_var(NONEXISTENT) }", "{ env_var(NONEXISTENT) }"),
# Env var does not exist
({}, "{{ env_var(NONEXISTENT) }}", ""),
({}, "{{ env_var(NONEXISTENT) }}bar", "bar"),
({}, "foo{{ env_var(NONEXISTENT) }}", "foo"),
({}, "foo{{ env_var(NONEXISTENT) }}bar", "foobar"),
# Multiple env vars do not exist
({}, "foo{{ env_var(NONEXISTENT) }}bar{{ env_var(NONEXISTENT2) }}baz", "foobarbaz"),
# Multiple env vars do not exist in different positions
({}, "{{ env_var(NONEXISTENT) }}foobarbaz{{ env_var(NONEXISTENT2) }}", "foobarbaz"),
# Check whitespace sensitivity
({"TESTVALUE": "a"}, "{{env_var(TESTVALUE)}},{{ env_var(TESTVALUE) }},{{env_var(TESTVALUE) }},{{ env_var(TESTVALUE)}},{{ env_var(TESTVALUE) }}", "a,a,a,a,a"),
# Multiple vars
({"TESTVALUE": "a", "TESTVALUE2": "b"}, "{{env_var(TESTVALUE)}}{{env_var(TESTVALUE2)}}", "ab"),
({"TESTVALUE": "a", "TESTVALUE2": "b"}, "foo{{env_var(TESTVALUE)}}bar{{env_var(TESTVALUE2)}}baz", "fooabarbbaz"),
]
# fmt: on
# ruff: enable[E501]
for i, (env_vars, raw_value, rendered_value) in enumerate(cases):
with subtests.test(i=i, msg=raw_value):
with (tmp_path / "subst.toml").open("w") as sink:
sink.write(f"""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
adbc.foo.bar = "{raw_value}"
""")
with monkeypatch.context() as mp:
for k, v in env_vars.items():
mp.setenv(k, v)
expected = re.escape(
f"Unknown database option adbc.foo.bar='{rendered_value}'"
)
with pytest.raises(dbapi.Error, match=expected):
with dbapi.connect("profile://subst"):
pass
def test_option_env_var_multiple(tmp_path, monkeypatch) -> None:
# Test that we can set multiple options via env var substitution
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
with (tmp_path / "subst.toml").open("w") as sink:
# On Windows, ensure we get file:///c:/ not file://c:/
windows = "/" if platform.system() == "Windows" else ""
rest = "{{ env_var(TEST_DIR) }}/{{ env_var(TEST_NUM) }}"
batch = "{{ env_var(BATCH_SIZE) }}"
sink.write(f"""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
uri = "file://{windows}{rest}.db"
adbc.sqlite.query.batch_rows = "{batch}"
""")
monkeypatch.setenv("TEST_DIR", str(tmp_path.as_posix()))
monkeypatch.setenv("TEST_NUM", "42")
monkeypatch.setenv("BATCH_SIZE", "1")
key = "adbc.sqlite.query.batch_rows"
with dbapi.connect("profile://subst") as conn:
assert conn.adbc_database.get_option_int(key) == 1
assert conn.adbc_connection.get_option_int(key) == 1
assert conn.adbc_database.get_option("uri") == (tmp_path / "42.db").as_uri()
with conn.cursor() as cursor:
assert cursor.adbc_statement.get_option_int(key) == 1
cursor.execute("CREATE TABLE foo (id INTEGER)")
cursor.execute("INSERT INTO foo VALUES (1), (2)")
cursor.execute("SELECT * FROM foo")
with cursor.fetch_record_batch() as reader:
assert len(next(reader)) == 1
assert len(next(reader)) == 1
def test_option_env_var_invalid(subtests, tmp_path, monkeypatch) -> None:
# Test that various invalid syntaxes fail
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
for contents, error in [
("{{ }}", "unsupported interpolation type in key `uri`: ``"),
("{{ bar_baz }}", "unsupported interpolation type in key `uri`: `bar_baz`"),
("{{ rand() }}", "unsupported interpolation type in key `uri`: `rand()`"),
(
"{{ env_var(TEST_DIR }}",
"malformed env_var() in key `uri`: missing closing parenthesis",
),
(
"{{ env_var() }}",
"malformed env_var() in key `uri`: missing environment variable name",
),
]:
with (tmp_path / "subst.toml").open("w") as sink:
sink.write(f"""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
uri = "{contents}"
""")
with subtests.test(msg=contents):
with pytest.raises(
dbapi.ProgrammingError, match=re.escape(f"In profile: {error}")
):
with dbapi.connect("profile://subst"):
pass
def test_option_override(tmp_path, monkeypatch) -> None:
# Test that the driver is optional
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
# NOTE: if we use an int value here, the override won't appear to work,
# because the options are set separately and do not override each other.
with (tmp_path / "dev.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
adbc.sqlite.query.batch_rows = "7"
""")
key = "adbc.sqlite.query.batch_rows"
with dbapi.connect("profile://dev") as conn:
assert conn.adbc_database.get_option_int(key) == 7
with dbapi.connect("profile://dev", db_kwargs={key: "42"}) as conn:
assert conn.adbc_database.get_option_int(key) == 42
def test_uri(sqlitedev) -> None:
# Test loading via profile:// URI
with dbapi.connect(f"profile://{sqlitedev}") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
def test_driver_optional(subtests, tmp_path, monkeypatch) -> None:
# Test that the driver is optional
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
with (tmp_path / "nodriver.toml").open("w") as sink:
sink.write("""
profile_version = 1
[Options]
""")
with subtests.test(msg="missing driver"):
with pytest.raises(dbapi.ProgrammingError, match="Must set 'driver' option"):
with dbapi.connect("profile://nodriver"):
pass
with subtests.test(msg="uri"):
with dbapi.connect("adbc_driver_sqlite", uri="profile://nodriver") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
with subtests.test(msg="profile"):
with dbapi.connect("adbc_driver_sqlite", profile="nodriver") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
def test_driver_override(subtests, tmp_path, monkeypatch) -> None:
# Test that the driver cannot be overridden by an option
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
with (tmp_path / "foo.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
""")
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
with subtests.test(msg="with redundant (URI)"):
with dbapi.connect("adbc_driver_sqlite", "profile://foo") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
with subtests.test(msg="with redundant (profile)"):
with dbapi.connect("adbc_driver_sqlite", profile="foo") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
with subtests.test(msg="with override (URI)"):
with pytest.raises(
dbapi.ProgrammingError,
match="Profile `foo` specifies driver `adbc_driver_sqlite`"
" which does not match requested driver `adbc_driver_somethingelse`",
):
with dbapi.connect("adbc_driver_somethingelse", "profile://foo"):
pass
with subtests.test(msg="with override (profile)"):
with pytest.raises(
dbapi.ProgrammingError,
match="Profile `foo` specifies driver `adbc_driver_sqlite`"
" which does not match requested driver `adbc_driver_somethingelse`",
):
with dbapi.connect("adbc_driver_somethingelse", profile="foo"):
pass
def test_driver_invalid(subtests, tmp_path, monkeypatch) -> None:
# Test invalid values for the driver
# TODO(lidavidm): give a more specific error
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
with (tmp_path / "nodriver.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = 2
[Options]
""")
with subtests.test(msg="numeric driver"):
with pytest.raises(dbapi.ProgrammingError, match="Must set 'driver' option"):
with dbapi.connect("profile://nodriver"):
pass
with (tmp_path / "nodriver.toml").open("w") as sink:
sink.write("""
profile_version = 1
[driver]
foo = "bar"
[Options]
""")
with subtests.test(msg="table driver"):
with pytest.raises(dbapi.ProgrammingError, match="Must set 'driver' option"):
with dbapi.connect("profile://nodriver"):
pass
def test_version_invalid(tmp_path, monkeypatch) -> None:
# Test that invalid versions are rejected
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
with (tmp_path / "badversion.toml").open("w") as sink:
sink.write("""
driver = "adbc_driver_sqlite"
[Options]
""")
with pytest.raises(
dbapi.ProgrammingError, match="Profile version is not an integer"
):
with dbapi.connect("profile://badversion"):
pass
with (tmp_path / "badversion.toml").open("w") as sink:
sink.write("""
driver = "adbc_driver_sqlite"
profile_version = "1"
[Options]
""")
with pytest.raises(
dbapi.ProgrammingError, match="Profile version is not an integer"
):
with dbapi.connect("profile://badversion"):
pass
with (tmp_path / "badversion.toml").open("w") as sink:
sink.write("""
driver = "adbc_driver_sqlite"
profile_version = 9001
[Options]
""")
with pytest.raises(
dbapi.ProgrammingError, match="Profile version '9001' is not supported"
):
with dbapi.connect("profile://badversion"):
pass
def test_parse_error_includes_location(tmp_path, monkeypatch) -> None:
# Test that TOML parse errors include line/column information
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
with (tmp_path / "badtoml.toml").open("w") as sink:
sink.write("""profile_version = 1
driver = unquoted_value
[Options]
""")
with pytest.raises(dbapi.ProgrammingError, match=r"line 2.*column"):
with dbapi.connect("profile://badtoml"):
pass
def test_reject_malformed(tmp_path, monkeypatch) -> None:
# Test that invalid profiles are rejected
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
with (tmp_path / "nodriver.toml").open("w") as sink:
sink.write("""
profile_version = 1
[Options]
""")
with pytest.raises(dbapi.ProgrammingError, match="Must set 'driver' option"):
with dbapi.connect("profile://nodriver"):
pass
with (tmp_path / "nooptions.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
""")
with pytest.raises(dbapi.ProgrammingError, match="Profile options is not a table"):
with dbapi.connect("profile://nooptions"):
pass
with (tmp_path / "unknownkeys.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
[foobar]
""")
# Unknown keys is OK, though
with dbapi.connect("profile://unknownkeys"):
pass
def test_driver_options(tmp_path, monkeypatch) -> None:
# Test that options are properly applied
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
# On Windows, ensure we get file:///c:/ not file://c:/
windows = "/" if platform.system() == "Windows" else ""
uri = f"file://{windows}{tmp_path.resolve().absolute().as_posix()}/foo.db"
with (tmp_path / "sqlitetest.toml").open("w") as sink:
sink.write(f"""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
uri = "{uri}"
""")
with dbapi.connect("profile://sqlitetest") as conn:
assert conn.adbc_database.get_option("uri") == uri
def test_load_driver_manifest(tmp_path, monkeypatch) -> None:
# Test a profile that references a manifest
manifest_path = tmp_path / "manifest"
profile_path = tmp_path / "profile"
manifest_path.mkdir()
profile_path.mkdir()
monkeypatch.setenv("ADBC_DRIVER_PATH", str(manifest_path))
monkeypatch.setenv("ADBC_PROFILE_PATH", str(profile_path))
with (manifest_path / "sqlitemanifest.toml").open("w") as sink:
sink.write("""
manifest_version = 1
[Driver]
shared = "adbc_driver_sqlite"
""")
with (profile_path / "proddata.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "sqlitemanifest"
[Options]
""")
with dbapi.connect("profile://proddata") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
def test_manifest_parse_error_includes_location(tmp_path, monkeypatch) -> None:
# Test that TOML parse errors in manifests include line/column information
manifest_path = tmp_path / "manifest"
manifest_path.mkdir()
monkeypatch.setenv("ADBC_DRIVER_PATH", str(manifest_path))
with (manifest_path / "badmanifest.toml").open("w") as sink:
sink.write("""manifest_version = 1
[Driver]
shared = unquoted_value
""")
with pytest.raises(dbapi.ProgrammingError, match=r"line 3.*column"):
with dbapi.connect("badmanifest"):
pass
def test_subdir(monkeypatch, tmp_path) -> None:
# Test that we can search in subdirectories
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
subdir = tmp_path / "sqlite" / "prod"
subdir.mkdir(parents=True)
with (subdir / "sqlitetest.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
""")
with dbapi.connect("profile://sqlite/prod/sqlitetest") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
def test_absolute(monkeypatch, tmp_path) -> None:
# Test that we can load profiles by absolute path
monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
subdir = tmp_path / "sqlite" / "staging"
subdir.mkdir(parents=True)
with (subdir / "sqlitetest.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
""")
path = (subdir / "sqlitetest.toml").absolute().as_posix()
with dbapi.connect(f"profile://{path}") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
@pytest.mark.system
def test_user_path() -> None:
if platform.system() == "Darwin":
path = pathlib.Path.home() / "Library/Application Support/ADBC/Profiles"
elif platform.system() == "Linux":
path = pathlib.Path.home() / ".config/adbc/profiles"
elif platform.system() == "Windows":
path = pathlib.Path(os.environ["LOCALAPPDATA"]) / "ADBC/Profiles"
else:
pytest.skip(f"Unsupported platform {platform.system()}")
subdir = str(uuid.uuid4())
profile = str(uuid.uuid4())
subpath = path / subdir
path.mkdir(exist_ok=True, parents=True)
subpath.mkdir(exist_ok=True)
with (path / f"{profile}.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
""")
with (subpath / f"{profile}.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
""")
try:
with dbapi.connect(f"profile://{profile}") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
with dbapi.connect(f"profile://{subdir}/{profile}") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
finally:
(path / f"{profile}.toml").unlink()
(subpath / f"{profile}.toml").unlink()
subpath.rmdir()
def test_conda(conda_prefix) -> None:
path = conda_prefix / "etc/adbc/profiles/"
path.mkdir(exist_ok=True, parents=True)
profile = str(uuid.uuid4())
with (path / f"{profile}.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
""")
try:
with dbapi.connect(f"profile://{profile}") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
finally:
(path / f"{profile}.toml").unlink()
def test_conda_subdir(conda_prefix) -> None:
subdir = str(uuid.uuid4())
path = conda_prefix / f"etc/adbc/profiles/{subdir}"
path.mkdir(exist_ok=True, parents=True)
profile = str(uuid.uuid4())
with (path / f"{profile}.toml").open("w") as sink:
sink.write("""
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
""")
try:
with dbapi.connect(f"profile://{subdir}/{profile}") as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
finally:
(path / f"{profile}.toml").unlink()
path.rmdir()
# For virtualenv tests: see Compose job python-venv
def test_separate_profile_manifest_paths(tmp_path) -> None:
# Test that we can have separate paths for profiles and manifests
manifest_path = tmp_path / "manifest"
profile_path = tmp_path / "profile"
manifest_path.mkdir()
profile_path.mkdir()
manifest = """
manifest_version = 1
[Driver]
shared = "adbc_driver_sqlite"
"""
profile = """
profile_version = 1
driver = "adbc_driver_sqlite"
[Options]
"""
with (manifest_path / "mani.toml").open("w") as sink:
sink.write(manifest)
with (manifest_path / "prof.toml").open("w") as sink:
sink.write(profile)
with (profile_path / "manifest.toml").open("w") as sink:
sink.write(manifest)
with (profile_path / "profile.toml").open("w") as sink:
sink.write(profile)
kwargs = {
"additional_manifest_search_path_list": str(manifest_path),
"additional_profile_search_path_list": str(profile_path),
}
with dbapi.connect("mani", db_kwargs=kwargs) as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
with dbapi.connect("profile://profile", db_kwargs=kwargs) as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT sqlite_version()")
assert cursor.fetchone() is not None
with pytest.raises(dbapi.ProgrammingError):
with dbapi.connect("manifest", db_kwargs=kwargs):
pass
with pytest.raises(dbapi.ProgrammingError):
with dbapi.connect("profile://prof", db_kwargs=kwargs):
pass