diff --git a/.devcontainer/py3.6-dev/Dockerfile b/.devcontainer/py3.6-dev/Dockerfile
index fd10308..8370a7b 100644
--- a/.devcontainer/py3.6-dev/Dockerfile
+++ b/.devcontainer/py3.6-dev/Dockerfile
@@ -4,3 +4,6 @@ ADD https://dl.yarnpkg.com/debian/pubkey.gpg /etc/apt/trusted.gpg.d/yarn.asc
RUN chmod +r /etc/apt/trusted.gpg.d/*.asc && \
echo "deb http://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
+
+COPY install-legacy-extensions.sh /usr/local/bin/install-legacy-extensions.sh
+RUN chmod +x /usr/local/bin/install-legacy-extensions.sh
diff --git a/.devcontainer/py3.6-dev/devcontainer.json b/.devcontainer/py3.6-dev/devcontainer.json
index ad4bb43..b730212 100644
--- a/.devcontainer/py3.6-dev/devcontainer.json
+++ b/.devcontainer/py3.6-dev/devcontainer.json
@@ -17,13 +17,13 @@
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
- // "postCreateCommand": "pip3 install --user -r requirements.txt",
+ "postCreateCommand": "install-legacy-extensions.sh",
// Configure tool-specific properties.
// Py3.6 support (switch extensions to `pre-release` and `install another version`):
// Pylance v2022.6.30
// Python v2022.8.1
- // Python Debugger v2023.1.XXX (pre-release version | debugpy v1.5.1)
+ // Python Debugger v2023.1.12492010 (pre-release version | debugpy v1.5.1)
// Black Formatter v2022.2.0
// Isort v2022.1.11601002 (pre-release)
"customizations": {
@@ -31,15 +31,10 @@
"extensions": [
"littlefoxteam.vscode-python-test-adapter",
"jkillian.custom-local-formatters",
- "ms-python.vscode-pylance",
- "ms-python.python",
- "ms-python.debugpy",
- "ms-python.black-formatter",
- "ms-python.isort",
"ms-toolsai.jupyter"
]
}
- }
+ },
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
diff --git a/.devcontainer/py3.6-dev/install-legacy-extensions.sh b/.devcontainer/py3.6-dev/install-legacy-extensions.sh
new file mode 100644
index 0000000..a9a917f
--- /dev/null
+++ b/.devcontainer/py3.6-dev/install-legacy-extensions.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+code --install-extension "ms-python.python@2022.8.1"
+code --install-extension "ms-python.vscode-pylance@2022.6.30"
+code --install-extension "ms-python.debugpy@2023.1.12492010"
+code --install-extension "ms-python.black-formatter@2022.2.0"
+code --install-extension "ms-python.isort@2022.1.11601002"
diff --git a/.devcontainer/py3.7/devcontainer.json b/.devcontainer/py3.7/devcontainer.json
new file mode 100644
index 0000000..7875428
--- /dev/null
+++ b/.devcontainer/py3.7/devcontainer.json
@@ -0,0 +1,38 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
+// README at: https://github.com/devcontainers/templates/tree/main/src/python
+{
+ "name": "Python 3.7",
+ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
+ "image": "mcr.microsoft.com/devcontainers/python:3.7",
+ "features": {
+ "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {}
+ },
+
+ // Features to add to the dev container. More info: https://containers.dev/features.
+ // "features": {},
+
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
+ // "forwardPorts": [],
+
+ // Use 'postCreateCommand' to run commands after the container is created.
+ // "postCreateCommand": "pip3 install --user -r requirements.txt",
+
+ // Configure tool-specific properties.
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "littlefoxteam.vscode-python-test-adapter",
+ "jkillian.custom-local-formatters",
+ "ms-python.vscode-pylance",
+ "ms-python.python",
+ "ms-python.debugpy",
+ "ms-python.black-formatter",
+ "ms-python.isort",
+ "ms-toolsai.jupyter"
+ ]
+ }
+ }
+
+ // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
+ // "remoteUser": "root"
+}
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index bccc1b4..b85fd89 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -13,7 +13,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.6", "3.12"]
+ # last supported for sqlitecloud, last security maintained, last release
+ python-version: ["3.6", "3.8", "3.12"]
steps:
- uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index 1593f97..37ece73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,7 @@
build/
experiments/
sdk/
-venv/
+.venv*/
main.dSYM/
.env
*.pyc
@@ -12,6 +12,11 @@ main.dSYM/
.DS_Store
.idea
-SqliteCloud.egg-info
+sqlitecloud.egg-info
playground.ipynb
+
+src/tests/assets/*-shm
+src/tests/assets/*-wal
+
+chinook.sqlite
diff --git a/README.md b/README.md
index 385c87a..4dfa80a 100644
--- a/README.md
+++ b/README.md
@@ -4,16 +4,19 @@
-
-[](https://codecov.io/github/sqlitecloud/python)
+
+[](https://codecov.io/gh/sqlitecloud/sqlitecloud-py)



-- [Driver for SQLite Cloud](#driver-for-sqlite-cloud)
-- [Example](#example)
+- [SQLite Cloud](#)
+- [Compatibility with sqlite3 API](#compatibility-with-sqlite3-api)
+ - [Autocommit transactions: Difference between sqlitecloud and sqlite3](#autocommit-transactions-difference-between-sqlitecloud-and-sqlite3)
+- [Installation and Usage](#installation-and-usage)
- [SQLite Cloud loves sqlite3](#sqlite-cloud-loves-sqlite3)
+- [SQLite Cloud for SQLAlchemy (beta)](#sqlite-cloud-for-sqlalchemy-beta)
- [SQLite Cloud for Pandas DataFrame](#sqlite-cloud-for-pandas-dataframe)
---
@@ -21,15 +24,23 @@
[SQLite Cloud](https://sqlitecloud.io) is a powerful Python package that allows you to interact with the SQLite Cloud database seamlessly. It provides methods for various database operations. This package is designed to simplify database operations in Python applications, making it easier than ever to work with SQLite Cloud.
-#### Compatibility with sqlite3 API
+## Compatibility with sqlite3 API
-We aim for full compatibility with the Python built-in [sqlite3](https://docs.python.org/3.6/library/sqlite3.html) API (based on Python [PEP 249](https://peps.python.org/pep-0249)), with the primary distinction being that our driver connects to SQLite Cloud databases. This allows you to migrate your local SQLite databases to SQLite Cloud without needing to modify your existing Python code that uses the sqlite3 API.
+We aim for full compatibility with the Python built-in [sqlite3](https://docs.python.org/3.6/library/sqlite3.html) API (based on Python DBAPI 2.0 [PEP 249](https://peps.python.org/pep-0249)), with the primary distinction being that our driver connects to SQLite Cloud databases. This allows you to migrate your local SQLite databases to SQLite Cloud without needing to modify your existing Python code that uses the sqlite3 API.
- Documentation: Our API closely follows the sqlite3 API. You can refer to the sqlite3 documentation for most functionality. The list of implemented features are documented [here](https://github.com/sqlitecloud/sqlitecloud-py/issues/8).
- Source: [https://github.com/sqlitecloud/sqlitecloud-py](https://github.com/sqlitecloud/sqlitecloud-py)
- Site: [https://sqlitecloud.io](https://sqlitecloud.io/developers)
-## Example
+### Autocommit transactions: Difference between sqlitecloud and sqlite3
+
+In `sqlitecloud`, autocommit is **always enabled**, and we currently do not support disabling it. This means that the `isolation_level` is always set to `None`, resulting in autocommit being permanently on.
+
+This behavior differs from the sqlite3 Python module, where autocommit can be controlled (see details in the section [Controlling Transactions](https://docs.python.org/3.6/library/sqlite3.html#controlling-transactions) in the official documentation).
+
+To manage transactions in sqlitecloud, you should explicitly use the `BEGIN`, `ROLLBACK`, `SAVEPOINT`, and `RELEASE` commands as needed.
+
+## Installation and Usage
```bash
$ pip install sqlitecloud
@@ -87,6 +98,81 @@ for row in cursor:
print(row)
```
+## SQLite Cloud for SQLAlchemy (beta)
+
+_This is an initial release, features and stability may not be guaranteed in all scenarios._
+
+_If you encounter any bugs or issues, please feel free to open an issue on our GitHub repository._
+
+We’ve implemented the initial support for `sqlitecloud` with [SQLAlchemy](https://www.sqlalchemy.org/), allowing you to utilize all standard SQLAlchemy operations and queries.
+For further information, please see the dedicated [REDAME](https://github.com/sqlitecloud/sqlitecloud-py/tree/%238-compatibility-sqlite3-dbapi2/sqlalchemy-sqlitecloud).
+
+### Example
+
+_The example is based on `chinook.sqlite` databse on SQLite Cloud_
+
+Install the package:
+
+```bash
+$ pip install sqlalchemy-sqlitecloud
+```
+
+
+```python
+import sqlalchemy
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.dialects import registry
+from sqlalchemy.orm import backref, declarative_base, relationship, sessionmaker
+
+Base = declarative_base()
+
+
+class Artist(Base):
+ __tablename__ = "artists"
+
+ ArtistId = Column("ArtistId", Integer, primary_key=True)
+ Name = Column("Name", String)
+ Albums = relationship("Album", backref=backref("artist"))
+
+
+class Album(Base):
+ __tablename__ = "albums"
+
+ AlbumId = Column("AlbumId", Integer, primary_key=True)
+ ArtistId = Column("ArtistId", Integer, ForeignKey("artists.ArtistId"))
+ Title = Column("Title", String)
+
+# SQLite Cloud connection string
+connection_string = "sqlitecloud://myhost.sqlite.cloud:8860/mydatabase.sqlite?apikey=myapikey"
+
+engine = sqlalchemy.create_engine(connection_string)
+Session = sessionmaker(bind=engine)
+session = Session()
+
+name = "John Doe"
+query = sqlalchemy.insert(Artist).values(Name=name)
+result_insert = session.execute(query)
+
+title = "The Album"
+query = sqlalchemy.insert(Album).values(
+ ArtistId=result_insert.lastrowid, Title=title
+)
+session.execute(query)
+
+query = (
+ sqlalchemy.select(Artist, Album)
+ .join(Album, Artist.ArtistId == Album.ArtistId)
+ .where(Artist.ArtistId == result_insert.lastrowid)
+)
+
+result = session.execute(query).fetchone()
+
+print("Artist Name: " + result[0].Name)
+print("Album Title: " + result[1].Title)
+
+```
+
+
## SQLite Cloud for Pandas DataFrame
[Pandas](https://pypi.org/project/pandas/) is a Python package for data manipulation and analysis. It provides high-performance, easy-to-use data structures, such as DataFrame.
diff --git a/bandit-baseline.json b/bandit-baseline.json
index aee9ade..418161f 100644
--- a/bandit-baseline.json
+++ b/bandit-baseline.json
@@ -1,17 +1,17 @@
{
"errors": [],
- "generated_at": "2024-08-06T12:35:09Z",
+ "generated_at": "2024-08-26T09:15:36Z",
"metrics": {
"_totals": {
- "CONFIDENCE.HIGH": 0.0,
- "CONFIDENCE.LOW": 3.0,
- "CONFIDENCE.MEDIUM": 1.0,
+ "CONFIDENCE.HIGH": 17.0,
+ "CONFIDENCE.LOW": 4.0,
+ "CONFIDENCE.MEDIUM": 12.0,
"CONFIDENCE.UNDEFINED": 0.0,
"SEVERITY.HIGH": 0.0,
- "SEVERITY.LOW": 1.0,
- "SEVERITY.MEDIUM": 3.0,
+ "SEVERITY.LOW": 18.0,
+ "SEVERITY.MEDIUM": 15.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 3497,
+ "loc": 5145,
"nosec": 0
},
"src/setup.py": {
@@ -35,7 +35,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 3,
+ "loc": 25,
"nosec": 0
},
"src/sqlitecloud/client.py": {
@@ -47,7 +47,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 104,
+ "loc": 103,
"nosec": 0
},
"src/sqlitecloud/datatypes.py": {
@@ -59,7 +59,7 @@
"SEVERITY.LOW": 1.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 177,
+ "loc": 173,
"nosec": 0
},
"src/sqlitecloud/dbapi2.py": {
@@ -71,7 +71,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 376,
+ "loc": 750,
"nosec": 0
},
"src/sqlitecloud/download.py": {
@@ -95,7 +95,19 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 787,
+ "loc": 834,
+ "nosec": 0
+ },
+ "src/sqlitecloud/exceptions.py": {
+ "CONFIDENCE.HIGH": 0.0,
+ "CONFIDENCE.LOW": 0.0,
+ "CONFIDENCE.MEDIUM": 0.0,
+ "CONFIDENCE.UNDEFINED": 0.0,
+ "SEVERITY.HIGH": 0.0,
+ "SEVERITY.LOW": 0.0,
+ "SEVERITY.MEDIUM": 0.0,
+ "SEVERITY.UNDEFINED": 0.0,
+ "loc": 142,
"nosec": 0
},
"src/sqlitecloud/pubsub.py": {
@@ -119,7 +131,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 99,
+ "loc": 111,
"nosec": 0
},
"src/sqlitecloud/upload.py": {
@@ -155,7 +167,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 35,
+ "loc": 76,
"nosec": 0
},
"src/tests/integration/__init__.py": {
@@ -171,15 +183,15 @@
"nosec": 0
},
"src/tests/integration/test_client.py": {
- "CONFIDENCE.HIGH": 0.0,
+ "CONFIDENCE.HIGH": 1.0,
"CONFIDENCE.LOW": 1.0,
"CONFIDENCE.MEDIUM": 0.0,
"CONFIDENCE.UNDEFINED": 0.0,
"SEVERITY.HIGH": 0.0,
- "SEVERITY.LOW": 0.0,
+ "SEVERITY.LOW": 1.0,
"SEVERITY.MEDIUM": 1.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 543,
+ "loc": 552,
"nosec": 0
},
"src/tests/integration/test_dbapi2.py": {
@@ -191,7 +203,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 181,
+ "loc": 319,
"nosec": 0
},
"src/tests/integration/test_download.py": {
@@ -203,19 +215,19 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 21,
+ "loc": 20,
"nosec": 0
},
"src/tests/integration/test_driver.py": {
- "CONFIDENCE.HIGH": 0.0,
+ "CONFIDENCE.HIGH": 4.0,
"CONFIDENCE.LOW": 0.0,
"CONFIDENCE.MEDIUM": 0.0,
"CONFIDENCE.UNDEFINED": 0.0,
"SEVERITY.HIGH": 0.0,
- "SEVERITY.LOW": 0.0,
+ "SEVERITY.LOW": 4.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 18,
+ "loc": 86,
"nosec": 0
},
"src/tests/integration/test_pandas.py": {
@@ -227,7 +239,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 50,
+ "loc": 51,
"nosec": 0
},
"src/tests/integration/test_pubsub.py": {
@@ -239,10 +251,10 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 1.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 129,
+ "loc": 128,
"nosec": 0
},
- "src/tests/integration/test_sqlite3_parity.py": {
+ "src/tests/integration/test_sqlalchemy.py": {
"CONFIDENCE.HIGH": 0.0,
"CONFIDENCE.LOW": 0.0,
"CONFIDENCE.MEDIUM": 0.0,
@@ -251,7 +263,19 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 176,
+ "loc": 55,
+ "nosec": 0
+ },
+ "src/tests/integration/test_sqlite_parity.py": {
+ "CONFIDENCE.HIGH": 12.0,
+ "CONFIDENCE.LOW": 1.0,
+ "CONFIDENCE.MEDIUM": 11.0,
+ "CONFIDENCE.UNDEFINED": 0.0,
+ "SEVERITY.HIGH": 0.0,
+ "SEVERITY.LOW": 12.0,
+ "SEVERITY.MEDIUM": 12.0,
+ "SEVERITY.UNDEFINED": 0.0,
+ "loc": 1011,
"nosec": 0
},
"src/tests/integration/test_upload.py": {
@@ -287,7 +311,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 241,
+ "loc": 226,
"nosec": 0
},
"src/tests/unit/test_driver.py": {
@@ -299,7 +323,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 188,
+ "loc": 134,
"nosec": 0
},
"src/tests/unit/test_resultset.py": {
@@ -311,7 +335,7 @@
"SEVERITY.LOW": 0.0,
"SEVERITY.MEDIUM": 0.0,
"SEVERITY.UNDEFINED": 0.0,
- "loc": 119,
+ "loc": 99,
"nosec": 0
},
"src/tests/unit/test_types.py": {
@@ -329,20 +353,14 @@
},
"results": [
{
- "code": "87 class SQLiteCloudAccount:\n88 def __init__(\n89 self,\n90 username: Optional[str] = \"\",\n91 password: Optional[str] = \"\",\n92 hostname: str = \"\",\n93 dbname: Optional[str] = \"\",\n94 port: int = SQLITECLOUD_DEFAULT.PORT.value,\n95 apikey: Optional[str] = \"\",\n96 ) -> None:\n97 # User name is required unless connectionstring is provided\n98 self.username = username\n99 # Password is required unless connection string is provided\n100 self.password = password\n101 # Password is hashed\n102 self.password_hashed = False\n103 # API key instead of username and password\n104 self.apikey = apikey\n105 # Name of database to open\n106 self.dbname = dbname\n107 # Like mynode.sqlitecloud.io\n108 self.hostname = hostname\n109 self.port = port\n110 \n",
+ "code": "93 class SQLiteCloudAccount:\n94 def __init__(\n95 self,\n96 username: Optional[str] = \"\",\n97 password: Optional[str] = \"\",\n98 hostname: str = \"\",\n99 dbname: Optional[str] = \"\",\n100 port: int = SQLITECLOUD_DEFAULT.PORT.value,\n101 apikey: Optional[str] = \"\",\n102 ) -> None:\n103 # User name is required unless connectionstring is provided\n104 self.username = username\n105 # Password is required unless connection string is provided\n106 self.password = password\n107 # Password is hashed\n108 self.password_hashed = False\n109 # API key instead of username and password\n110 self.apikey = apikey\n111 # Name of database to open\n112 self.dbname = dbname\n113 # Like mynode.sqlitecloud.io\n114 self.hostname = hostname\n115 self.port = port\n116 \n",
"col_offset": 4,
"filename": "src/sqlitecloud/datatypes.py",
"issue_confidence": "MEDIUM",
"issue_severity": "LOW",
"issue_text": "Possible hardcoded password: ''",
- "line_number": 88,
- "line_range": [
- 88,
- 89,
- 90,
- 91,
- 92,
- 93,
+ "line_number": 94,
+ "line_range": [
94,
95,
96,
@@ -358,37 +376,478 @@
106,
107,
108,
- 109
+ 109,
+ 110,
+ 111,
+ 112,
+ 113,
+ 114,
+ 115
],
"more_info": "https://bandit.readthedocs.io/en/latest/plugins/b107_hardcoded_password_default.html",
"test_id": "B107",
"test_name": "hardcoded_password_default"
},
{
- "code": "639 for i in range(nRows):\n640 sql += f\"INSERT INTO TestCompress (name) VALUES ('Test {i}'); \"\n641 \n",
- "col_offset": 23,
+ "code": "648 \n649 table_name = \"TestCompress\" + str(random.randint(0, 99999))\n650 try:\n",
+ "col_offset": 42,
+ "filename": "src/tests/integration/test_client.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 649,
+ "line_range": [
+ 649
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "664 rowset = client.exec_query(\n665 f\"SELECT * from {table_name}\",\n666 connection,\n",
+ "col_offset": 16,
"filename": "src/tests/integration/test_client.py",
"issue_confidence": "LOW",
"issue_severity": "MEDIUM",
"issue_text": "Possible SQL injection vector through string-based query construction.",
- "line_number": 640,
+ "line_number": 665,
"line_range": [
- 640
+ 665
],
"more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
"test_id": "B608",
"test_name": "hardcoded_sql_expressions"
},
{
- "code": "179 client.exec_query(\n180 f\"UPDATE genres SET Name = '{new_name}' WHERE GenreId = 1;\", connection\n181 )\n",
+ "code": "50 \n51 name = \"MyGenre\" + str(random.randint(0, 1000))\n52 query = \"INSERT INTO genres (Name) VALUES (?)\"\n",
+ "col_offset": 31,
+ "filename": "src/tests/integration/test_driver.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 51,
+ "line_range": [
+ 51
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "73 \n74 name = \"MyGenre\" + str(random.randint(0, 1000))\n75 result_insert = driver.execute_statement(\n",
+ "col_offset": 31,
+ "filename": "src/tests/integration/test_driver.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 74,
+ "line_range": [
+ 74
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "95 \n96 name = \"MyGenre\" + str(random.randint(0, 1000))\n97 result_insert = driver.execute_statement(\n",
+ "col_offset": 31,
+ "filename": "src/tests/integration/test_driver.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 96,
+ "line_range": [
+ 96
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "100 \n101 new_name = \"AnotherMyGenre\" + str(random.randint(0, 1000))\n102 result = driver.execute_statement(\n",
+ "col_offset": 42,
+ "filename": "src/tests/integration/test_driver.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 101,
+ "line_range": [
+ 101
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "177 client.exec_query(\n178 f\"UPDATE genres SET Name = '{new_name}' WHERE GenreId = 1;\", connection\n179 )\n",
"col_offset": 12,
"filename": "src/tests/integration/test_pubsub.py",
"issue_confidence": "LOW",
"issue_severity": "MEDIUM",
"issue_text": "Possible SQL injection vector through string-based query construction.",
- "line_number": 180,
+ "line_number": 178,
+ "line_range": [
+ 178
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "54 \n55 table = \"sqlitetest\" + str(random.randint(0, 99999))\n56 try:\n",
+ "col_offset": 35,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 55,
+ "line_range": [
+ 55
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "63 \n64 select_query = f\"SELECT * FROM {table}\"\n65 cursor = connection.execute(select_query)\n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "LOW",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 64,
+ "line_range": [
+ 64
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "619 \n620 tableName = \"TestTextFactory\" + str(random.randint(0, 99999))\n621 try:\n",
+ "col_offset": 44,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 620,
+ "line_range": [
+ 620
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "624 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (15,))\n625 cursor.execute(f\"SELECT p FROM {tableName}\")\n626 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 625,
+ "line_range": [
+ 625
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "648 \n649 tableName = \"TestTextFactory\" + str(random.randint(0, 99999))\n650 try:\n",
+ "col_offset": 44,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 649,
+ "line_range": [
+ 649
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "653 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (\"15\",))\n654 cursor.execute(f\"SELECT p FROM {tableName}\")\n655 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 654,
+ "line_range": [
+ 654
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "710 \n711 tableName = \"TestParseDeclTypes\" + str(random.randint(0, 99999))\n712 try:\n",
+ "col_offset": 47,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 711,
+ "line_range": [
+ 711
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "715 cursor.executemany(f\"INSERT INTO {tableName}(p) VALUES (?)\", [(p1,), (p2,)])\n716 cursor.execute(f\"SELECT p FROM {tableName}\")\n717 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 716,
+ "line_range": [
+ 716
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "899 \n900 tableName = \"TestParseDeclTypes\" + str(random.randint(0, 99999))\n901 try:\n",
+ "col_offset": 47,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 900,
+ "line_range": [
+ 900
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "904 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (str(p),))\n905 cursor.execute(f\"SELECT p FROM {tableName}\")\n906 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 905,
+ "line_range": [
+ 905
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "924 \n925 tableName = \"TestParseDeclTypes\" + str(random.randint(0, 99999))\n926 try:\n",
+ "col_offset": 47,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 925,
+ "line_range": [
+ 925
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "929 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (\"1.0,2.0\",))\n930 cursor.execute(f\"SELECT p FROM {tableName}\")\n931 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 930,
+ "line_range": [
+ 930
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "952 \n953 tableName = \"TestParseDeclTypes\" + str(random.randint(0, 99999))\n954 try:\n",
+ "col_offset": 47,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 953,
+ "line_range": [
+ 953
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "957 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (mynumber,))\n958 cursor.execute(f\"SELECT p FROM {tableName}\")\n959 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 958,
+ "line_range": [
+ 958
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "985 \n986 tableName = \"TestParseDeclTypes\" + str(random.randint(0, 99999))\n987 try:\n",
+ "col_offset": 47,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 986,
+ "line_range": [
+ 986
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "990 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (pippo,))\n991 cursor.execute(f\"SELECT p FROM {tableName}\")\n992 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 991,
+ "line_range": [
+ 991
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "1014 \n1015 tableName = \"TestParseDeclTypes\" + str(random.randint(0, 99999))\n1016 try:\n",
+ "col_offset": 47,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 1015,
+ "line_range": [
+ 1015
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "1019 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (mynumber,))\n1020 cursor.execute(f\"SELECT p FROM {tableName}\")\n1021 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 1020,
+ "line_range": [
+ 1020
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "1041 \n1042 tableName = \"TestParseDeclTypes\" + str(random.randint(0, 99999))\n1043 try:\n",
+ "col_offset": 47,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 1042,
+ "line_range": [
+ 1042
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "1054 )\n1055 cursor.execute(f\"SELECT d, t FROM {tableName}\")\n1056 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 1055,
+ "line_range": [
+ 1055
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "1093 \n1094 tableName = \"TestParseDeclTypes\" + str(random.randint(0, 99999))\n1095 try:\n",
+ "col_offset": 47,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 1094,
+ "line_range": [
+ 1094
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "1099 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (p,))\n1100 cursor.execute(f\"SELECT p FROM {tableName}\")\n1101 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 1100,
+ "line_range": [
+ 1100
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
+ "test_id": "B608",
+ "test_name": "hardcoded_sql_expressions"
+ },
+ {
+ "code": "1179 \n1180 tableName = \"TestParseColnames\" + str(random.randint(0, 99999))\n1181 try:\n",
+ "col_offset": 46,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "HIGH",
+ "issue_severity": "LOW",
+ "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.",
+ "line_number": 1180,
+ "line_range": [
+ 1180
+ ],
+ "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b311-random",
+ "test_id": "B311",
+ "test_name": "blacklist"
+ },
+ {
+ "code": "1185 cursor.execute(f\"INSERT INTO {tableName}(p) VALUES (?)\", (str(p),))\n1186 cursor.execute(f'SELECT p, p \"lat lng [coordinate]\" FROM {tableName}')\n1187 \n",
+ "col_offset": 27,
+ "filename": "src/tests/integration/test_sqlite_parity.py",
+ "issue_confidence": "MEDIUM",
+ "issue_severity": "MEDIUM",
+ "issue_text": "Possible SQL injection vector through string-based query construction.",
+ "line_number": 1186,
"line_range": [
- 180
+ 1186
],
"more_info": "https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html",
"test_id": "B608",
diff --git a/pyproject.toml b/pyproject.toml
index 0f87497..b088f1f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,3 +4,9 @@
[tool.isort]
# make the tools compatible to each other
profile = "black"
+
+[tool.pytest.ini_options]
+markers = [
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
+ "serial",
+]
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 207f9a6..7ae6b68 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -18,3 +18,4 @@ bandit==1.7.1
# this package is only used for testing compatibility
# with pandas dataframe
pandas>=1.1.5
+SQLAlchemy>=1.4.53
diff --git a/sqlalchemy-sqlitecloud/README.md b/sqlalchemy-sqlitecloud/README.md
new file mode 100644
index 0000000..7a86d70
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/README.md
@@ -0,0 +1,135 @@
+# SQLite Cloud Dialect for SQLAlchemy (Beta)
+
+This package enables SQLAlchemy to work seamlessly with SQLite Cloud. The dialect is built upon the existing `sqlite` dialect in SQLAlchemy.
+
+## Beta Version
+
+This dialect is in its early stages and is compatible with Python >= 3.6.
+
+**Note:** It has been tested only `SQLAlchemy 1.4`.
+
+The dialect has undergone testing against the SQLAlchemy `test_suite`, as outlined in the [official documentation](https://github.com/sqlalchemy/sqlalchemy/blob/rel_1_4_53/README.dialects.rst).
+
+You can track the progress of the remaining test issues [in this issue](https://github.com/sqlitecloud/sqlitecloud-py/issues/21#issuecomment-2305162632).
+
+_The same tests failed and passed on both Python 3.6 and Python 3.11._
+
+## References
+
+- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/en/14/index.html)
+- [SQLAlchemy Official Website](https://www.sqlalchemy.org/)
+
+## Installation and Usage
+
+To install the package, use the following command:
+
+```bash
+$ pip install sqlalchemy-sqlitecloud
+```
+
+> Get your SQLite Cloud connection string from the SQLite Cloud dashboard, or register at [sqlitecloud.io](https://sqlitecloud.io) to get one.
+
+Create an SQLAlchemy engine using the SQLite Cloud connection string::
+
+```python
+from sqlalchemy import create_engine
+
+engine = create_engine('sqlitecloud://mynode.sqlite.io?apikey=key1234')
+```
+
+### Example
+
+_The example is based on `chinook.sqlite` database on SQLite Cloud_
+
+```python
+import sqlalchemy
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.dialects import registry
+from sqlalchemy.orm import backref, declarative_base, relationship, sessionmaker
+
+Base = declarative_base()
+
+
+class Artist(Base):
+ __tablename__ = "artists"
+
+ ArtistId = Column("ArtistId", Integer, primary_key=True)
+ Name = Column("Name", String)
+ Albums = relationship("Album", backref=backref("artist"))
+
+
+class Album(Base):
+ __tablename__ = "albums"
+
+ AlbumId = Column("AlbumId", Integer, primary_key=True)
+ ArtistId = Column("ArtistId", Integer, ForeignKey("artists.ArtistId"))
+ Title = Column("Title", String)
+
+# SQLite Cloud connection string
+connection_string = "sqlitecloud://myhost.sqlite.cloud:8860/mydatabase.sqlite?apikey=myapikey"
+
+engine = sqlalchemy.create_engine(connection_string)
+Session = sessionmaker(bind=engine)
+session = Session()
+
+name = "John Doe"
+query = sqlalchemy.insert(Artist).values(Name=name)
+result_insert = session.execute(query)
+
+title = "The Album"
+query = sqlalchemy.insert(Album).values(
+ ArtistId=result_insert.lastrowid, Title=title
+)
+session.execute(query)
+
+query = (
+ sqlalchemy.select(Artist, Album)
+ .join(Album, Artist.ArtistId == Album.ArtistId)
+ .where(Artist.ArtistId == result_insert.lastrowid)
+)
+
+result = session.execute(query).fetchone()
+
+print("Artist Name: " + result[0].Name)
+print("Album Title: " + result[1].Title)
+
+```
+
+
+
+# Run the Test Suite
+
+To run the test suite, first install the sqlitecloud package:
+
+```bash
+$ pip install sqlitecloud # last version
+```
+or install the reference to the local version:
+
+
+```bash
+$ cd ../src # sqlitecloud src directory
+$ pip install -e .
+```
+
+Then, run the test suite with:
+
+```bash
+$ cd sqlalchemy-sqlitecloud
+$ pytest
+```
+
+> Note: VSCode Test Explorer and VSCode GUI debugger doesn't work because the actual implementation of tests
+is not in the `test/test_suite.py` file. The test source code is located in a third-party directory and it's not recognized.
+
+For command-line debugging, use `pytest --pdb` with `pdb.set_trace()`.
+
+Some useful `pdb` commands include:
+
+ - `s` step into
+ - `n` next line
+ - `r` jump to the end of the function
+ - `c` continue
+ - `w` print stack trace
+
+ More info: [https://docs.python.org/3/library/pdb.html](https://docs.python.org/3/library/pdb.html)
diff --git a/sqlalchemy-sqlitecloud/pyproject.toml b/sqlalchemy-sqlitecloud/pyproject.toml
new file mode 100644
index 0000000..6cd3b3a
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/pyproject.toml
@@ -0,0 +1,5 @@
+# Configuration file to store build system requirements for Python projects
+# https://www.python.org/dev/peps/pep-0518/
+
+[tool.pytest.ini_options]
+addopts = "--tb native -v -r fxX --maxfail=25 -p no:warnings --requirements sqlalchemy_sqlitecloud.requirements:Requirements"
diff --git a/sqlalchemy-sqlitecloud/requirements.txt b/sqlalchemy-sqlitecloud/requirements.txt
new file mode 100644
index 0000000..d7783e7
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/requirements.txt
@@ -0,0 +1 @@
+sqlitecloud # latest
diff --git a/sqlalchemy-sqlitecloud/setup.cfg b/sqlalchemy-sqlitecloud/setup.cfg
new file mode 100644
index 0000000..64c0dfd
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/setup.cfg
@@ -0,0 +1,11 @@
+[tool:pytest]
+addopts= --tb native -v -r fxX --maxfail=25 -p no:warnings --requirements sqlalchemy_sqlitecloud.requirements:Requirements
+python_files=test/*test_*.py
+
+[sqla_testing]
+requirement_cls=sqlalchemy_sqlitecloud.requirements:Requirements
+profile_file=test/profiles.txt
+
+[db]
+# SQLite Cloud connection string is set in conftest.py with testing apikey
+default=sqlitecloud://placeholder.sqlite.io
diff --git a/sqlalchemy-sqlitecloud/setup.py b/sqlalchemy-sqlitecloud/setup.py
new file mode 100644
index 0000000..1f8a0d2
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/setup.py
@@ -0,0 +1,38 @@
+from pathlib import Path
+
+from setuptools import find_packages, setup
+
+long_description = (Path(__file__).parent / "README.md").read_text()
+
+setup(
+ name="sqlalchemy-sqlitecloud",
+ version="0.1.0",
+ author="sqlitecloud.io",
+ description="SQLAlchemy Dialect for SQLite Cloud.",
+ long_description=long_description,
+ long_description_content_type="text/markdown",
+ url="https://github.com/sqlitecloud/sqlitecloud-py",
+ packages=find_packages(),
+ install_requires=[
+ "sqlitecloud",
+ ],
+ keywords="SQLAlchemy SQLite Cloud",
+ classifiers=[
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ ],
+ license="MIT",
+ entry_points={
+ "sqlalchemy.dialects": [
+ "sqlitecloud = sqlalchemy_sqlitecloud.base:SQLiteCloudDialect",
+ ]
+ },
+)
diff --git a/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/__init__.py b/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/base.py b/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/base.py
new file mode 100644
index 0000000..62ebf70
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/base.py
@@ -0,0 +1,134 @@
+import sqlalchemy.types as sqltypes
+from sqlalchemy import util
+from sqlalchemy.dialects.sqlite.base import DATE, DATETIME, SQLiteDialect
+from sqlalchemy.engine.url import URL
+from sqlalchemy.exc import ArgumentError
+from sqlalchemy.pool import NullPool
+
+
+class _SQLite_pysqliteTimeStamp(DATETIME):
+ def bind_processor(self, dialect):
+ if dialect.native_datetime:
+ return None
+ else:
+ return DATETIME.bind_processor(self, dialect)
+
+ def result_processor(self, dialect, coltype):
+ if dialect.native_datetime:
+ return None
+ else:
+ return DATETIME.result_processor(self, dialect, coltype)
+
+
+class _SQLite_pysqliteDate(DATE):
+ def bind_processor(self, dialect):
+ if dialect.native_datetime:
+ return None
+ else:
+ return DATE.bind_processor(self, dialect)
+
+ def result_processor(self, dialect, coltype):
+ if dialect.native_datetime:
+ return None
+ else:
+ return DATE.result_processor(self, dialect, coltype)
+
+
+class SQLiteCloudDialect(SQLiteDialect):
+ name = "sqlitecloud"
+ driver = "sqlitecloud"
+
+ default_paramstyle = "qmark"
+ supports_statement_cache = False
+
+ colspecs = util.update_copy(
+ SQLiteDialect.colspecs,
+ {
+ sqltypes.Date: _SQLite_pysqliteDate,
+ sqltypes.TIMESTAMP: _SQLite_pysqliteTimeStamp,
+ },
+ )
+
+ @classmethod
+ def dbapi(cls):
+ from sqlitecloud import dbapi2
+
+ return dbapi2
+
+ @classmethod
+ def get_pool_class(cls, url):
+ return NullPool
+
+ def _get_server_version_info(self, connection):
+ return self.dbapi.sqlite_version_info
+
+ def set_isolation_level(self, connection, level):
+ if level != "AUTOCOMMIT":
+ raise ArgumentError(
+ "SQLite Cloud supports only AUTOCOMMIT isolation level."
+ )
+
+ if hasattr(connection, "dbapi_connection"):
+ dbapi_connection = connection.dbapi_connection
+ else:
+ dbapi_connection = connection
+
+ if level == "AUTOCOMMIT":
+ dbapi_connection.isolation_level = None
+ else:
+ dbapi_connection.isolation_level = ""
+ return super(SQLiteCloudDialect, self).set_isolation_level(
+ connection, level
+ )
+
+ def on_connect(self):
+ connect = super(SQLiteCloudDialect, self).on_connect()
+
+ fns = []
+
+ if self.isolation_level is not None:
+
+ def iso_level(conn):
+ self.set_isolation_level(conn, self.isolation_level)
+
+ fns.append(iso_level)
+
+ def connect(conn): # noqa: F811
+ for fn in fns:
+ fn(conn)
+
+ return connect
+
+ def create_connect_args(self, url: URL):
+ if not url.host:
+ raise ArgumentError(
+ "SQLite Cloud URL is required.\n"
+ "Register on https://sqlitecloud.io/ to get your free SQLite Cloud account.\n"
+ "Valid SQLite Cloud URL are:\n"
+ " sqlitecloud:///myuser:mypass@myserver.sqlite.cloud/mydb.sqlite?non_linearizable=true\n"
+ " sqlitecloud:///myserver.sqlite.cloud/?apikey=mykey1234"
+ )
+
+ # TODO: this should be the list of SQLite Cloud Config params
+ pysqlite_args = [
+ # ("timeout", float),
+ # ("isolation_level", str),
+ ("detect_types", int),
+ ]
+ opts = url.query
+ pysqlite_opts = {}
+ for key, type_ in pysqlite_args:
+ util.coerce_kw_type(opts, key, type_, dest=pysqlite_opts)
+
+ # sqlitecloud//...
+ url = url.set(drivername="sqlitecloud")
+
+ return ([url.render_as_string(hide_password=False)], pysqlite_opts)
+
+ def is_disconnect(self, e, connection, cursor):
+ return isinstance(
+ e, self.dbapi.ProgrammingError
+ ) and "Cannot operate on a closed database." in str(e)
+
+
+dialect = SQLiteCloudDialect
diff --git a/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/provision.py b/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/provision.py
new file mode 100644
index 0000000..28e3001
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/provision.py
@@ -0,0 +1,71 @@
+import os
+
+from dotenv import load_dotenv
+from sqlalchemy.engine import make_url
+from sqlalchemy.pool import Pool
+from sqlalchemy.testing.provision import (
+ generate_driver_url,
+ post_configure_engine,
+ stop_test_class_outside_fixtures,
+ temp_table_keyword_args,
+)
+
+_drivernames = {
+ "sqlitecloud",
+}
+
+
+@generate_driver_url.for_db("sqlitecloud")
+def generate_driver_url(url, driver, query_str):
+ # no database specified here, it's created and used later
+ # eg: sqlitecloud://mynode.sqlite.cloud/?apikey=key123
+
+ load_dotenv("../../.env")
+
+ connection_string = os.getenv("SQLITE_CONNECTION_STRING")
+ apikey = os.getenv("SQLITE_API_KEY")
+
+ connection_string += f"?apikey={apikey}"
+
+ return make_url(connection_string)
+
+
+@post_configure_engine.for_db("sqlitecloud")
+def _sqlite_post_configure_engine(url, engine, follower_ident):
+ from sqlalchemy import event
+
+ main_database = "sqlalchemy_sqlitecloud.db"
+ attached_database = "sqlalchemy_sqlitecloud_test_schema.db"
+
+ _create_dbs(url, main_database)
+ _create_dbs(url, attached_database)
+
+ @event.listens_for(Pool, "connect")
+ def connect(dbapi_connection, connection_record):
+ # attach the test schema for all new connections,
+ # not just when the engine is first created and connected.
+ # Next connections lost the attached schema.
+ dbapi_connection.execute(f"USE DATABASE {main_database}")
+ dbapi_connection.execute(
+ f'ATTACH DATABASE "{attached_database}" AS test_schema'
+ )
+
+
+def _create_dbs(url, database):
+ import sqlitecloud
+
+ with sqlitecloud.connect(str(url)) as conn:
+ conn.execute(
+ f"REMOVE DATABASE {database} IF EXISTS; "
+ f"CREATE DATABASE {database} IF NOT EXISTS"
+ )
+
+
+@stop_test_class_outside_fixtures.for_db("sqlitecloud")
+def stop_test_class_outside_fixtures(config, db, cls):
+ db.dispose()
+
+
+@temp_table_keyword_args.for_db("sqlitecloud")
+def _sqlite_temp_table_keyword_args(cfg, eng):
+ return {"prefixes": ["TEMPORARY"]}
diff --git a/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/requirements.py b/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/requirements.py
new file mode 100644
index 0000000..2d31f81
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/sqlalchemy_sqlitecloud/requirements.py
@@ -0,0 +1,45 @@
+from sqlalchemy.testing import exclusions
+from sqlalchemy.testing.requirements import SuiteRequirements
+
+supported = exclusions.open
+unsupported = exclusions.closed
+
+
+class Requirements(SuiteRequirements):
+ @property
+ def table_ddl_if_exists(self):
+ """target platform supports IF NOT EXISTS / IF EXISTS for tables."""
+
+ return supported()
+
+ @property
+ def index_ddl_if_exists(self):
+ """target platform supports IF NOT EXISTS / IF EXISTS for indexes."""
+
+ return supported()
+
+ @property
+ def implicitly_named_constraints(self):
+ """target database must apply names to unnamed constraints."""
+
+ return unsupported()
+
+ @property
+ def reflects_pk_names(self):
+ """https://www.sqlite.org/syntax/column-constraint.html"""
+
+ return supported()
+
+ @property
+ def views(self):
+ """Target database must support VIEWs."""
+
+ return supported()
+
+ @property
+ def dbapi_lastrowid(self):
+ """target platform includes a 'lastrowid' accessor on the DBAPI
+ cursor object.
+
+ """
+ return supported()
diff --git a/sqlalchemy-sqlitecloud/test/__init__.py b/sqlalchemy-sqlitecloud/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sqlalchemy-sqlitecloud/test/conftest.py b/sqlalchemy-sqlitecloud/test/conftest.py
new file mode 100644
index 0000000..d5cbdb3
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/test/conftest.py
@@ -0,0 +1,9 @@
+import pytest
+from sqlalchemy.dialects import registry
+
+pytest.register_assert_rewrite("sqlalchemy.testing.assertions")
+
+registry.register("sqlitecloud", "sqlalchemy_sqlitecloud.base", "SQLiteCloudDialect")
+
+
+from sqlalchemy.testing.plugin.pytestplugin import * # noqa
diff --git a/sqlalchemy-sqlitecloud/test/profiles.txt b/sqlalchemy-sqlitecloud/test/profiles.txt
new file mode 100644
index 0000000..e69de29
diff --git a/sqlalchemy-sqlitecloud/test/test_suite.py b/sqlalchemy-sqlitecloud/test/test_suite.py
new file mode 100644
index 0000000..2405aa5
--- /dev/null
+++ b/sqlalchemy-sqlitecloud/test/test_suite.py
@@ -0,0 +1,2 @@
+# sqlalchemy.testing.suite to run dialect against to
+from sqlalchemy.testing.suite import * # noqa
diff --git a/src/README.md b/src/README.md
new file mode 100644
index 0000000..48cdce8
--- /dev/null
+++ b/src/README.md
@@ -0,0 +1 @@
+placeholder
diff --git a/src/setup.py b/src/setup.py
index c2f10e2..3357624 100644
--- a/src/setup.py
+++ b/src/setup.py
@@ -19,7 +19,7 @@
"lz4 >= 3.1.10",
],
classifiers=[
- "Development Status :: 3 - Alpha",
+ "Development Status :: 3 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.6",
diff --git a/src/sqlitecloud/__init__.py b/src/sqlitecloud/__init__.py
index a1a62a2..aecc566 100644
--- a/src/sqlitecloud/__init__.py
+++ b/src/sqlitecloud/__init__.py
@@ -2,8 +2,30 @@
# the classes and functions from the dbapi2 module.
# eg: sqlite3.connect() -> sqlitecloud.connect()
#
-from .dbapi2 import Connection, Cursor, connect
-__all__ = ["VERSION", "Connection", "Cursor", "connect"]
+from .dbapi2 import (
+ PARSE_COLNAMES,
+ PARSE_DECLTYPES,
+ Connection,
+ Cursor,
+ Row,
+ adapters,
+ connect,
+ converters,
+ register_adapter,
+ register_converter,
+)
-VERSION = "0.0.79"
+__all__ = [
+ "VERSION",
+ "Connection",
+ "Cursor",
+ "connect",
+ "register_adapter",
+ "register_converter",
+ "PARSE_DECLTYPES",
+ "PARSE_COLNAMES",
+ "adapters",
+ "converters",
+ "Row",
+]
diff --git a/src/sqlitecloud/client.py b/src/sqlitecloud/client.py
index 6e4a897..7c7dccc 100644
--- a/src/sqlitecloud/client.py
+++ b/src/sqlitecloud/client.py
@@ -8,9 +8,9 @@
SQLiteCloudConfig,
SQLiteCloudConnect,
SQLiteCloudDataTypes,
- SQLiteCloudException,
)
from sqlitecloud.driver import Driver
+from sqlitecloud.exceptions import SQLiteCloudException
from sqlitecloud.resultset import SQLiteCloudResultSet
@@ -121,9 +121,7 @@ def exec_statement(
Returns:
SqliteCloudResultSet: The result set obtained from executing the query.
"""
- prepared_statement = self._driver.prepare_statement(query, parameters)
-
- result = self._driver.execute(prepared_statement, conn)
+ result = self._driver.execute_statement(query, parameters, conn)
return SQLiteCloudResultSet(result)
diff --git a/src/sqlitecloud/datatypes.py b/src/sqlitecloud/datatypes.py
index e1181eb..a215381 100644
--- a/src/sqlitecloud/datatypes.py
+++ b/src/sqlitecloud/datatypes.py
@@ -3,8 +3,14 @@
from typing import Any, Callable, Dict, Optional, Union
from urllib import parse
+from sqlitecloud.exceptions import SQLiteCloudException
+
from .resultset import SQLiteCloudResultSet
+# SQLite supported data types
+SQLiteDataTypes = Union[str, int, float, bytes, None]
+
+
# Basic types supported by SQLite Cloud APIs
SQLiteCloudDataTypes = Union[str, int, bool, Dict[Union[str, int], Any], bytes, None]
@@ -117,7 +123,6 @@ class SQLiteCloudConnect:
def __init__(self):
self.socket: any = None
self.config: SQLiteCloudConfig
- self.isblob: bool = False
self.pubsub_socket: any = None
self.pubsub_callback: Callable[
@@ -224,14 +229,6 @@ def _parse_connection_string(self, connection_string) -> None:
) from e
-class SQLiteCloudException(Exception):
- def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
- super().__init__(message)
- self.errmsg = str(message)
- self.errcode = code
- self.xerrcode = xerrcode
-
-
class SQLiteCloudNumber:
"""
Represents the parsed number or the error code.
@@ -241,6 +238,7 @@ def __init__(self) -> None:
self.value: Optional[int] = None
self.cstart: int = 0
self.extcode: int = None
+ self.offcode: int = None
class SQLiteCloudValue:
@@ -249,6 +247,6 @@ class SQLiteCloudValue:
"""
def __init__(self) -> None:
- self.value: Optional[str] = None
+ self.value: Optional[SQLiteCloudDataTypes] = None
self.len: int = 0
self.cellsize: int = 0
diff --git a/src/sqlitecloud/dbapi2.py b/src/sqlitecloud/dbapi2.py
index af56a74..46c527e 100644
--- a/src/sqlitecloud/dbapi2.py
+++ b/src/sqlitecloud/dbapi2.py
@@ -3,6 +3,12 @@
# PEP 249 – Python Database API Specification v2.0
# https://peps.python.org/pep-0249/
#
+import collections
+import datetime
+import logging
+import re
+import sys
+import time
from typing import (
Any,
Callable,
@@ -12,6 +18,7 @@
List,
Optional,
Tuple,
+ Type,
Union,
overload,
)
@@ -20,11 +27,60 @@
SQLiteCloudAccount,
SQLiteCloudConfig,
SQLiteCloudConnect,
- SQLiteCloudDataTypes,
- SQLiteCloudException,
+ SQLiteDataTypes,
)
from sqlitecloud.driver import Driver
-from sqlitecloud.resultset import SQLITECLOUD_RESULT_TYPE, SQLiteCloudResult
+from sqlitecloud.exceptions import (
+ SQLiteCloudDatabaseError,
+ SQLiteCloudDataError,
+ SQLiteCloudError,
+ SQLiteCloudIntegrityError,
+ SQLiteCloudInterfaceError,
+ SQLiteCloudInternalError,
+ SQLiteCloudNotSupportedError,
+ SQLiteCloudOperationalError,
+ SQLiteCloudProgrammingError,
+ SQLiteCloudWarning,
+)
+from sqlitecloud.resultset import (
+ SQLITECLOUD_RESULT_TYPE,
+ SQLITECLOUD_VALUE_TYPE,
+ SQLiteCloudOperationResult,
+ SQLiteCloudResult,
+)
+
+version = "0.1.0"
+version_info = (0, 1, 0)
+
+# version from sqlite3 in py3.6
+sqlite_version = "3.34.1"
+sqlite_version_info = (3, 34, 1)
+
+Binary = bytes
+Date = datetime.date
+Time = datetime.time
+Timestamp = datetime.datetime
+
+Warning = SQLiteCloudWarning
+Error = SQLiteCloudError
+InterfaceError = SQLiteCloudInterfaceError
+DatabaseError = SQLiteCloudDatabaseError
+DataError = SQLiteCloudDataError
+OperationalError = SQLiteCloudOperationalError
+IntegrityError = SQLiteCloudIntegrityError
+InternalError = SQLiteCloudInternalError
+ProgrammingError = SQLiteCloudProgrammingError
+NotSupportedError = SQLiteCloudNotSupportedError
+
+# Map for types for SQLite
+STRING = "TEXT"
+BINARY = "BINARY"
+NUMBER = "INTEGER"
+DATETIME = "TIMESTAMP"
+ROWID = "INTEGER PRIMARY KEY"
+
+# SQLite supported types
+SQLiteTypes = Union[int, float, str, bytes, None]
# Question mark style, e.g. ...WHERE name=?
# Module also supports Named style, e.g. ...WHERE name=:name
@@ -36,6 +92,16 @@
# DB API level
apilevel = "2.0"
+# These constants are meant to be used with the detect_types
+# parameter of the connect() function
+PARSE_DECLTYPES = 1
+PARSE_COLNAMES = 2
+
+# Adapters registry to convert Python types to SQLite types
+adapters: Dict[Type[Any], Callable[[Any], SQLiteDataTypes]] = {}
+# Converters registry to convert SQLite types to Python types
+converters: Dict[str, Callable[[bytes], Any]] = {}
+
@overload
def connect(connection_str: str) -> "Connection":
@@ -79,6 +145,7 @@ def connect(
def connect(
connection_info: Union[str, SQLiteCloudAccount],
config: Optional[SQLiteCloudConfig] = None,
+ detect_types: int = 0,
) -> "Connection":
"""
Establishes a connection to the SQLite Cloud database.
@@ -88,6 +155,11 @@ def connect(
It can be either a connection string or a `SqliteCloudAccount` object.
config (Optional[SQLiteCloudConfig]): The configuration options for the connection.
Defaults to None.
+ detect_types (int): Default (0), disabled. How data types not natively supported
+ by SQLite are looked up to be converted to Python types, using the converters
+ registered with register_converter().
+ Accepts any combination (using |, bitwise or) of PARSE_DECLTYPES and PARSE_COLNAMES.
+ Column names takes precedence over declared types if both flags are set.
Returns:
Connection: A DB-API 2.0 connection object representing the connection to the database.
@@ -104,46 +176,89 @@ def connect(
else:
config = SQLiteCloudConfig(connection_info)
- return Connection(
- driver.connect(config.account.hostname, config.account.port, config)
+ connection = Connection(
+ driver.connect(config.account.hostname, config.account.port, config),
+ detect_types=detect_types,
)
+ return connection
+
+
+def register_adapter(
+ pytype: Type, adapter_callable: Callable[[Any], SQLiteTypes]
+) -> None:
+ """
+ Registers a callable to convert the type into one of the supported SQLite types.
+
+ Args:
+ type (Type): The type to convert.
+ callable (Callable): The callable that converts the type into a supported
+ SQLite supported type.
+ """
+ registry = _get_adapters_registry()
+ registry[pytype] = adapter_callable
+
+
+def register_converter(type_name: str, converter: Callable[[bytes], Any]) -> None:
+ """
+ Registers a callable to convert a bytestring from the database into a custom Python type.
+
+ Args:
+ type_name (str): The name of the type to convert.
+ The match with the name of the type in the query is case-insensitive.
+ converter (Callable): The callable that converts the bytestring into the custom Python type.
+ """
+ registry = _get_converters_registry()
+ registry[type_name.lower()] = converter
+
+
+def _get_adapters_registry() -> dict:
+ return adapters
+
+
+def _get_converters_registry() -> dict:
+ return converters
+
class Connection:
"""
- Represents a DB-APi 2.0 connection to the SQLite Cloud database.
+ Represents a DB-API 2.0 connection to the SQLite Cloud database.
Args:
- SQLiteCloud_connection (SQLiteCloudConnect): The SQLite Cloud connection object.
+ sqlitecloud_connection (SQLiteCloudConnect): The SQLite Cloud connection object.
Attributes:
- _driver (Driver): The driver object used for database operations.
- SQLiteCloud_connection (SQLiteCloudConnect): The SQLite Cloud connection object.
+ sqlitecloud_connection (SQLiteCloudConnect): The SQLite Cloud connection object.
"""
- row_factory: Optional[Callable[["Cursor", Tuple], object]] = None
-
- def __init__(self, SQLiteCloud_connection: SQLiteCloudConnect) -> None:
+ def __init__(
+ self, sqlitecloud_connection: SQLiteCloudConnect, detect_types: int = 0
+ ) -> None:
self._driver = Driver()
- self.row_factory = None
- self.SQLiteCloud_connection = SQLiteCloud_connection
+ self._autocommit = True
+ self.sqlitecloud_connection = sqlitecloud_connection
+
+ self.row_factory: Optional[Callable[["Cursor", Tuple], object]] = None
+ self.text_factory: Union[Type[Union[str, bytes]], Callable[[bytes], Any]] = str
+
+ self.detect_types = detect_types
+
+ self.total_changes = 0
@property
- def sqlcloud_connection(self) -> SQLiteCloudConnect:
- """
- Returns the SQLite Cloud connection object.
+ def autocommit(self) -> bool:
+ """Autocommit enabled is the only currently supported option in SQLite Cloud."""
+ return self._autocommit
- Returns:
- SQLiteCloudConnect: The SQLite Cloud connection object.
- """
- return self.SQLiteCloud_connection
+ @autocommit.setter
+ def autocommit(self, value: bool) -> None:
+ if not value:
+ raise SQLiteCloudNotSupportedError("Disable Autocommit is not supported.")
def execute(
self,
sql: str,
- parameters: Union[
- Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]
- ] = (),
+ parameters: Union[Tuple[any], Dict[Union[str, int], any]] = (),
) -> "Cursor":
"""
Shortcut for cursor.execute().
@@ -151,7 +266,7 @@ def execute(
Args:
sql (str): The SQL query to execute.
- parameters (Union[Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]]):
+ parameters (Union[Tuple[any], Dict[Union[str, int], any]]):
The parameters to be used in the query. It can be a tuple or a dictionary. (Default ())
conn (SQLiteCloudConnect): The connection object to use for executing the query.
@@ -164,11 +279,7 @@ def execute(
def executemany(
self,
sql: str,
- seq_of_parameters: Iterable[
- Union[
- Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]
- ]
- ],
+ seq_of_parameters: Iterable[Union[Tuple[any], Dict[Union[str, int], any]]],
) -> "Cursor":
"""
Shortcut for cursor.executemany().
@@ -176,7 +287,7 @@ def executemany(
Args:
sql (str): The SQL statement to execute.
- seq_of_parameters (Iterable[Union[Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]]]):
+ seq_of_parameters (Iterable[Union[Tuple[any], Dict[Union[str, int], any]]]):
The sequence of parameter sets to bind to the SQL statement.
Returns:
@@ -185,6 +296,39 @@ def executemany(
cursor = self.cursor()
return cursor.executemany(sql, seq_of_parameters)
+ def executescript(self, sql_script: str):
+ raise SQLiteCloudNotSupportedError("executescript() is not supported.")
+
+ def create_function(self, name, num_params, func):
+ raise SQLiteCloudNotSupportedError("create_function() is not supported.")
+
+ def create_aggregate(self, name, num_params, aggregate_class):
+ raise SQLiteCloudNotSupportedError("create_aggregate() is not supported.")
+
+ def create_collation(self, name, func):
+ raise SQLiteCloudNotSupportedError("create_collation() is not supported.")
+
+ def interrupt(self):
+ raise SQLiteCloudNotSupportedError("interrupt() is not supported.")
+
+ def set_authorizer(self, authorizer):
+ raise SQLiteCloudNotSupportedError("set_authorizer() is not supported.")
+
+ def set_progress_handler(self, handler, n):
+ raise SQLiteCloudNotSupportedError("set_progress_handler() is not supported.")
+
+ def set_trace_callback(self, trace_callback):
+ raise SQLiteCloudNotSupportedError("set_trace_callback() is not supported.")
+
+ def enable_load_extension(self, enable):
+ raise SQLiteCloudNotSupportedError("enable_load_extension() is not supported.")
+
+ def load_extension(path):
+ raise SQLiteCloudNotSupportedError("load_extension() is not supported.")
+
+ def iterdump(self):
+ raise SQLiteCloudNotSupportedError("iterdump() is not supported.")
+
def close(self):
"""
Closes the database connection.
@@ -194,17 +338,51 @@ def close(self):
DB-API 2.0 interface does not manage the Sqlite Cloud PubSub feature.
Therefore, only the main socket is closed.
"""
- self._driver.disconnect(self.SQLiteCloud_connection, True)
+ self._driver.disconnect(self.sqlitecloud_connection, True)
+
+ def is_connected(self) -> bool:
+ """
+ Check if the connection to SQLite Cloud database is still open.
+
+ Returns:
+ bool: True if the connection is open, False otherwise.
+ """
+ return self._driver.is_connected(self.sqlitecloud_connection)
def commit(self):
"""
- Not implementied yet.
+ Commit any pending transactions on database.
"""
+ try:
+ self._driver.execute("COMMIT;", self.sqlitecloud_connection)
+ except SQLiteCloudOperationalError as e:
+ if (
+ e.errcode == 1
+ and e.xerrcode == 1
+ and "no transaction is active" in e.errmsg
+ ):
+ # compliance to sqlite3
+ logging.debug(e)
def rollback(self):
"""
- Not implemented yet.
+ Causes the database to roll back to the start of any pending transaction.
+ A transaction will also rool back if the database is closed or if an error occurs
+ and the roll back conflict resolution algorithm is specified.
+
+ See the documentation on the `ON CONFLICT `
+ clause for additional information about the ROLLBACK conflict resolution algorithm.
"""
+ try:
+ self._driver.execute("ROLLBACK;", self.sqlitecloud_connection)
+ except SQLiteCloudOperationalError as e:
+ if (
+ e.errcode == 1
+ and e.xerrcode == 1
+ and "no transaction is active" in e.errmsg
+ ):
+ # compliance to sqlite3
+ logging.debug(e)
def cursor(self):
"""
@@ -217,6 +395,48 @@ def cursor(self):
cursor.row_factory = self.row_factory
return cursor
+ def _apply_adapter(self, value: Any) -> SQLiteTypes:
+ """
+ Applies the registered adapter to convert the Python type into a SQLite supported type.
+ In the case there is no registered adapter, it calls the __conform__() method when the value object implements it.
+
+ Args:
+ value (Any): The Python type to convert.
+
+ Returns:
+ SQLiteTypes: The SQLite supported type or the given value when no adapter is found.
+ """
+ if type(value) in adapters:
+ return adapters[type(value)](value)
+
+ if hasattr(value, "__conform__"):
+ # we don't support sqlite3.PrepareProtocol
+ return value.__conform__(None)
+
+ return value
+
+ def __enter__(self):
+ """
+ Context manager to handle transactions.
+
+ In sqlite3 module the control of the autocommit mode is governed by
+ the `isolation_level` of the connection. To follow this behavior, the
+ context manager does't start a new transaction implicitly. Instead,
+ it handles the commit or rollback of transactions that are explicitly opened.
+ """
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ if exc_type is None:
+ self.commit()
+ else:
+ self.rollback()
+ logging.error(
+ f"Rolling back transaction - error '{exc_value}'",
+ exc_info=True,
+ extra={"traceback": traceback},
+ )
+
def __del__(self) -> None:
self.close()
@@ -232,14 +452,14 @@ class Cursor(Iterator[Any]):
arraysize: int = 1
- row_factory: Optional[Callable[["Cursor", Tuple], object]] = None
-
def __init__(self, connection: Connection) -> None:
self._driver = Driver()
- self.row_factory = None
self._connection = connection
self._iter_row: int = 0
self._resultset: SQLiteCloudResult = None
+ self._result_operation: SQLiteCloudOperationResult = None
+
+ self.row_factory: Optional[Callable[["Cursor", Tuple], object]] = None
@property
def connection(self) -> Connection:
@@ -262,11 +482,23 @@ def description(
if not self._is_result_rowset():
return None
+ # Since py3.7:
+ # bpo-39652: The column name found in sqlite3.Cursor.description is
+ # now truncated on the first ‘[’ only if the PARSE_COLNAMES option is set.
+ # https://github.com/python/cpython/issues/83833
+ parse_colname = (
+ self.connection.detect_types & PARSE_COLNAMES
+ ) == PARSE_COLNAMES
+ if sys.version_info < (3, 7):
+ parse_colname = True
+
description = ()
for i in range(self._resultset.ncols):
+ colname = self._resultset.colname[i]
+
description += (
(
- self._resultset.colname[i],
+ self._parse_colname(colname)[0] if parse_colname else colname,
None,
None,
None,
@@ -281,21 +513,28 @@ def description(
@property
def rowcount(self) -> int:
"""
- The number of rows that the last .execute*() produced (for DQL statements like SELECT)
-
- The number of rows affected by DML statements like UPDATE or INSERT is not supported.
+ The number of rows that the last .execute*() returned for DQL statements like SELECT or
+ the number rows affected by DML statements like UPDATE, INSERT and DELETE.
- Returns:
- int: The number of rows in the result set or -1 if no result set is available.
+ For the executemany() it returns the number of changes only for the last operation.
"""
- return self._resultset.nrows if self._is_result_rowset() else -1
+ if self._is_result_rowset():
+ return self._resultset.nrows
+ if self._is_result_operation():
+ return self._result_operation.changes
+ return -1
@property
def lastrowid(self) -> Optional[int]:
"""
- Not implemented yet in the library.
+ Last rowid for DML operations (INSERT, UPDATE, DELETE).
+ In case of `executemany()` it returns the last rowid of the last operation.
"""
- return None
+ return (
+ self._result_operation.rowid
+ if self._result_operation and self._result_operation.rowid > 0
+ else None
+ )
def close(self) -> None:
"""
@@ -309,9 +548,7 @@ def close(self) -> None:
def execute(
self,
sql: str,
- parameters: Union[
- Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]
- ] = (),
+ parameters: Union[Tuple[Any], Dict[Union[str, int], Any]] = (),
) -> "Cursor":
"""
Prepare and execute a SQL statement (either a query or command) to the SQLite Cloud database.
@@ -329,7 +566,7 @@ def execute(
Args:
sql (str): The SQL query to execute.
- parameters (Union[Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]]):
+ parameters (Union[Tuple[any], Dict[Union[str, int], any]]):
The parameters to be used in the query. It can be a tuple or a dictionary. (Default ())
conn (SQLiteCloudConnect): The connection object to use for executing the query.
@@ -338,23 +575,30 @@ def execute(
"""
self._ensure_connection()
- prepared_statement = self._driver.prepare_statement(sql, parameters)
- result = self._driver.execute(
- prepared_statement, self.connection.sqlcloud_connection
+ parameters = self._adapt_parameters(parameters)
+
+ if isinstance(parameters, dict):
+ parameters = self._named_to_question_mark_parameters(sql, parameters)
+
+ result = self._driver.execute_statement(
+ sql, parameters, self.connection.sqlitecloud_connection
)
- self._resultset = result
+ self._resultset = None
+ self._result_operation = None
+
+ if isinstance(result, SQLiteCloudResult):
+ self._resultset = result
+ if isinstance(result, SQLiteCloudOperationResult):
+ self._result_operation = result
+ self._connection.total_changes = result.total_changes
return self
def executemany(
self,
sql: str,
- seq_of_parameters: Iterable[
- Union[
- Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]
- ]
- ],
+ seq_of_parameters: Iterable[Union[Tuple[Any], Dict[Union[str, int], Any]]],
) -> "Cursor":
"""
Executes a SQL statement multiple times, each with a different set of parameters.
@@ -363,7 +607,7 @@ def executemany(
Args:
sql (str): The SQL statement to execute.
- seq_of_parameters (Iterable[Union[Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]]]):
+ seq_of_parameters (Iterable[Union[Tuple[any], Dict[Union[str, int], any]]]):
The sequence of parameter sets to bind to the SQL statement.
Returns:
@@ -372,11 +616,19 @@ def executemany(
self._ensure_connection()
commands = ""
+ params = []
for parameters in seq_of_parameters:
- prepared_statement = self._driver.prepare_statement(sql, parameters)
- commands += prepared_statement + ";"
+ if isinstance(parameters, dict):
+ parameters = self._named_to_question_mark_parameters(sql, parameters)
+
+ params += list(parameters)
- self.execute(commands)
+ if not sql.endswith(";"):
+ sql += ";"
+
+ commands += sql
+
+ self.execute(commands, params)
return self
@@ -439,15 +691,24 @@ def fetchall(self) -> List[Any]:
return self.fetchmany(self.rowcount)
def setinputsizes(self, sizes) -> None:
- pass
+ raise SQLiteCloudNotSupportedError("setinputsizes() is not supported.")
def setoutputsize(self, size, column=None) -> None:
- pass
+ raise SQLiteCloudNotSupportedError("setoutputsize() is not supported.")
+
+ def scroll(value, mode="relative"):
+ raise SQLiteCloudNotSupportedError("scroll() is not supported.")
+
+ def messages(self):
+ raise SQLiteCloudNotSupportedError("messages() is not supported.")
def _call_row_factory(self, row: Tuple) -> object:
if self.row_factory is None:
return row
+ if self.row_factory is Row:
+ return Row(row, [col[0] for col in self.description])
+
return self.row_factory(self, row)
def _is_result_rowset(self) -> bool:
@@ -456,6 +717,9 @@ def _is_result_rowset(self) -> bool:
and self._resultset.tag == SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET
)
+ def _is_result_operation(self) -> bool:
+ return self._result_operation is not None
+
def _ensure_connection(self):
"""
Ensure the cursor is usable or has been closed.
@@ -463,26 +727,260 @@ def _ensure_connection(self):
Raises:
SQLiteCloudException: If the cursor is closed.
"""
- if not self._connection:
- raise SQLiteCloudException("The cursor is closed.")
+ if not self._connection or not self._connection.is_connected():
+ raise SQLiteCloudProgrammingError("The cursor is closed.", code=1)
+
+ def _adapt_parameters(self, parameters: Union[Dict, Tuple]) -> Union[Dict, Tuple]:
+ if isinstance(parameters, dict):
+ params = {}
+ for i in parameters.keys():
+ params[i] = self._connection._apply_adapter(parameters[i])
+ return params
+
+ return tuple(self._connection._apply_adapter(p) for p in parameters)
+
+ def _convert_value(
+ self, value: Any, colname: Optional[str], decltype: Optional[str]
+ ) -> Any:
+ if (
+ colname
+ and (self.connection.detect_types & PARSE_COLNAMES) == PARSE_COLNAMES
+ ):
+ try:
+ return self._parse_colnames(value, colname)
+ except MissingDecltypeException:
+ pass
+
+ if (
+ decltype
+ and (self.connection.detect_types & PARSE_DECLTYPES) == PARSE_DECLTYPES
+ ):
+ try:
+ return self._parse_decltypes(value, decltype)
+ except MissingDecltypeException:
+ pass
+
+ if decltype == SQLITECLOUD_VALUE_TYPE.TEXT.value or (
+ decltype is None and isinstance(value, str)
+ ):
+ return self._apply_text_factory(value)
+
+ return value
+
+ def _parse_colname(self, colname: str) -> Tuple[str, str]:
+ """
+ Parse the column name to extract the column name and the
+ declared type if present when it follows the syntax `colname [decltype]`.
+
+ Args:
+ colname (str): The column name with optional declared type.
+ Eg: "mycol [mytype]"
+
+ Returns:
+ Tuple[str, str]: The column name and the declared type.
+ Eg: ("mycol", "mytype")
+ """
+ # search for `[mytype]` in `mycol [mytype]`
+ pattern = r"\[(.*?)\]"
+
+ matches = re.findall(pattern, colname)
+ if not matches or len(matches) == 0:
+ return colname, None
+
+ return colname.replace(f"[{matches[0]}]", "").strip(), matches[0]
+
+ def _parse_colnames(self, value: Any, colname: str) -> Optional[Any]:
+ """Convert the value using the explicit type in the column name."""
+ _, decltype = self._parse_colname(colname)
+
+ if decltype:
+ return self._parse_decltypes(value, decltype)
+
+ raise MissingDecltypeException(f"No decltype declared for: {decltype}")
+
+ def _parse_decltypes(self, value: Any, decltype: str) -> Optional[Any]:
+ """Convert the value by calling the registered converter for the given decltype."""
+ decltype = decltype.lower()
+ registry = _get_converters_registry()
+ if decltype in registry:
+ # sqlite3 always passes value as bytes
+ value = (
+ str(value).encode("utf-8") if not isinstance(value, bytes) else value
+ )
+ return registry[decltype](value)
+
+ raise MissingDecltypeException(f"No decltype registered for: {decltype}")
+
+ def _apply_text_factory(self, value: Any) -> Any:
+ """Use Connection.text_factory to convert value with TEXT column or
+ string value with undleclared column type."""
+
+ if self._connection.text_factory is bytes:
+ return value.encode("utf-8")
+ if self._connection.text_factory is not str and callable(
+ self._connection.text_factory
+ ):
+ return self._connection.text_factory(value.encode("utf-8"))
+
+ return value
+
+ def _named_to_question_mark_parameters(
+ self, sql: str, params: Dict[str, Any]
+ ) -> Tuple[Any]:
+ """
+ Convert named placeholders parameters from a dictionary to a list of
+ parameters for question mark style.
+
+ SCSP protocol does not support named placeholders yet.
+ """
+ pattern = r":(\w+)"
+ matches = re.findall(pattern, sql)
+
+ params_list = ()
+ for match in matches:
+ if match in params:
+ params_list += (params[match],)
+
+ return params_list
+
+ def _get_value(self, row: int, col: int) -> Optional[Any]:
+ if not self._is_result_rowset():
+ return None
+
+ value = self._resultset.get_value(row, col)
+ colname = self._resultset.get_name(col)
+ decltype = self._resultset.get_decltype(col)
+
+ return self._convert_value(value, colname, decltype)
def __iter__(self) -> "Cursor":
return self
def __next__(self) -> Optional[Tuple[Any]]:
- self._ensure_connection()
-
if (
- not self._resultset.is_result
+ self._resultset
+ and not self._resultset.is_result
and self._resultset.data
and self._iter_row < self._resultset.nrows
):
out: Tuple[Any] = ()
for col in range(self._resultset.ncols):
- out += (self._resultset.get_value(self._iter_row, col),)
+ out += (self._get_value(self._iter_row, col),)
self._iter_row += 1
return self._call_row_factory(out)
raise StopIteration
+
+
+class Row:
+ def __init__(self, data: Tuple[Any], column_names: List[str]):
+ """
+ Initialize the Row object with data and column names.
+
+ Args:
+ data (Tuple[Any]): A tuple containing the row data.
+ column_names (List[str]): A list of column names corresponding to the data.
+ """
+ self._data = data
+ self._column_names = column_names
+ self._column_map = {name.lower(): idx for idx, name in enumerate(column_names)}
+
+ def keys(self) -> List[str]:
+ """Return the column names."""
+ return self._column_names
+
+ def __getitem__(self, key):
+ """Support indexing by both column name and index."""
+ if isinstance(key, int):
+ return self._data[key]
+ elif isinstance(key, str):
+ return self._data[self._column_map[key.lower()]]
+ else:
+ raise TypeError("Invalid key type. Must be int or str.")
+
+ def __len__(self) -> int:
+ return len(self._data)
+
+ def __iter__(self) -> Iterator[Any]:
+ return iter(self._data)
+
+ def __repr__(self) -> str:
+ return "\n".join(
+ f"{name}: {self._data[idx]}" for idx, name in enumerate(self._column_names)
+ )
+
+ def __hash__(self) -> int:
+ return hash((self._data, tuple(self._column_map)))
+
+ def __eq__(self, other) -> bool:
+ """Check if both have the same data and column names."""
+ if not isinstance(other, Row):
+ return NotImplemented
+
+ return self._data == other._data and self._column_map == other._column_map
+
+ def __ne__(self, other):
+ if not isinstance(other, Row):
+ return NotImplemented
+ return not self.__eq__(other)
+
+
+class MissingDecltypeException(Exception):
+ def __init__(self, message: str) -> None:
+ super().__init__(message)
+ self.message = message
+
+
+def DateFromTicks(ticks):
+ return Date(*time.localtime(ticks)[:3])
+
+
+def TimeFromTicks(ticks):
+ return Time(*time.localtime(ticks)[3:6])
+
+
+def TimestampFromTicks(ticks):
+ return Timestamp(*time.localtime(ticks)[:6])
+
+
+def register_adapters_and_converters():
+ """
+ sqlite3 default adapters and converters.
+
+ This code comes from the Python standard library's sqlite3 module.
+ The Python standard library is licensed under the Python Software Foundation License.
+ Source: https://github.com/python/cpython/blob/3.6/Lib/sqlite3/dbapi2.py
+ """
+
+ def adapt_date(val):
+ return val.isoformat()
+
+ def adapt_datetime(val):
+ return val.isoformat(" ")
+
+ def convert_date(val):
+ return datetime.date(*map(int, val.split(b"-")))
+
+ def convert_timestamp(val):
+ datepart, timepart = val.split(b" ")
+ year, month, day = map(int, datepart.split(b"-"))
+ timepart_full = timepart.split(b".")
+ hours, minutes, seconds = map(int, timepart_full[0].split(b":"))
+ if len(timepart_full) == 2:
+ microseconds = int("{:0<6.6}".format(timepart_full[1].decode()))
+ else:
+ microseconds = 0
+
+ val = datetime.datetime(year, month, day, hours, minutes, seconds, microseconds)
+ return val
+
+ register_adapter(datetime.date, adapt_date)
+ register_adapter(datetime.datetime, adapt_datetime)
+ register_converter("date", convert_date)
+ register_converter("timestamp", convert_timestamp)
+
+
+register_adapters_and_converters()
+collections.abc.Sequence.register(Row)
diff --git a/src/sqlitecloud/driver.py b/src/sqlitecloud/driver.py
index 905f38e..fc262de 100644
--- a/src/sqlitecloud/driver.py
+++ b/src/sqlitecloud/driver.py
@@ -5,7 +5,7 @@
import ssl
import threading
from io import BufferedReader, BufferedWriter
-from typing import Callable, Dict, Optional, Tuple, Union
+from typing import Any, Callable, List, Optional, Tuple, Union
import lz4.block
@@ -16,14 +16,18 @@
SQLITECLOUD_ROWSET,
SQLiteCloudConfig,
SQLiteCloudConnect,
- SQLiteCloudDataTypes,
- SQLiteCloudException,
SQLiteCloudNumber,
SQLiteCloudRowsetSignature,
SQLiteCloudValue,
+ SQLiteDataTypes,
+)
+from sqlitecloud.exceptions import (
+ SQLiteCloudException,
+ get_sqlitecloud_error_with_extended_code,
)
from sqlitecloud.resultset import (
SQLITECLOUD_RESULT_TYPE,
+ SQLiteCloudOperationResult,
SQLiteCloudResult,
SQLiteCloudResultSet,
)
@@ -81,38 +85,39 @@ def execute(
self, command: str, connection: SQLiteCloudConnect
) -> SQLiteCloudResult:
"""
- Execute a query on the SQLite Cloud server.
+ Execute a command on the SQLite Cloud server.
"""
+ command = self._internal_serialize_command(command)
+
return self._internal_run_command(connection, command)
+ def execute_statement(
+ self,
+ query: str,
+ bindings: Tuple[SQLiteDataTypes],
+ # bindings: Union[Tuple[SQLiteDataTypes], Dict[str, SQLiteDataTypes]],
+ connection: SQLiteCloudConnect,
+ ) -> Union[SQLiteCloudResult, SQLiteCloudOperationResult]:
+ """
+ Execute the statement on the SQLite Cloud server.
+ It supports only the `qmark` style for parameter binding.
+ """
+ command = self._internal_serialize_command(
+ [query] + list(bindings), zero_string=True
+ )
+
+ result = self._internal_run_command(connection, command)
+
+ if result.tag != SQLITECLOUD_RESULT_TYPE.RESULT_ARRAY:
+ return result
+
+ return SQLiteCloudOperationResult(result)
+
def send_blob(self, blob: bytes, conn: SQLiteCloudConnect) -> SQLiteCloudResult:
"""
Send a blob to the SQLite Cloud server.
"""
- try:
- conn.isblob = True
- return self._internal_run_command(conn, blob)
- finally:
- conn.isblob = False
-
- def prepare_statement(
- self,
- query: str,
- parameters: Union[
- Tuple[SQLiteCloudDataTypes], Dict[Union[str, int], SQLiteCloudDataTypes]
- ],
- ) -> str:
- # If parameters is a dictionary, replace the keys in the query with the values
- if isinstance(parameters, dict):
- for key, value in parameters.items():
- query = query.replace(":" + str(key), self.escape_sql_parameter(value))
-
- # If parameters is a tuple, replace each '?' in the query with a value from the tuple
- elif isinstance(parameters, tuple):
- for value in parameters:
- query = query.replace("?", self.escape_sql_parameter(value), 1)
-
- return query
+ return self._internal_run_command(conn, self._internal_serialize_command(blob))
def is_connected(
self, connection: SQLiteCloudConnect, main_socket: bool = True
@@ -131,33 +136,6 @@ def is_connected(
return True
- def escape_sql_parameter(self, param):
- if param is None or param is None:
- return "NULL"
-
- if isinstance(param, bool):
- return "1" if param else "0"
-
- if isinstance(param, str):
- # replace single quote with two single quotes
- param = param.replace("'", "''")
- return f"'{param}'"
-
- if isinstance(param, (int, float)):
- return str(param)
-
- # serialize buffer as X'...' hex encoded string
- if isinstance(param, bytes):
- return f"X'{param.hex()}'"
-
- if isinstance(param, dict) or isinstance(param, list):
- # serialize json then escape single quotes
- json_string = json.dumps(param)
- json_string = json_string.replace("'", "''")
- return f"'{json_string}'"
-
- raise SQLiteCloudException(f"Unsupported parameter type: {type(param)}")
-
def _internal_connect(
self, hostname: str, port: int, config: SQLiteCloudConfig
) -> socket:
@@ -213,7 +191,9 @@ def _internal_setup_pubsub(
connection.config,
)
- self._internal_run_command(connection, buffer, False)
+ self._internal_run_command(
+ connection, self._internal_serialize_command(buffer.decode()), False
+ )
thread = threading.Thread(
target=self._internal_pubsub_thread, args=(connection,)
)
@@ -330,7 +310,9 @@ def upload_database(
command = f"UPLOAD DATABASE '{dbname}' {keyarg}{keyvalue}"
# execute command on server side
- result = self._internal_run_command(connection, command)
+ result = self._internal_run_command(
+ connection, self._internal_serialize_command(command)
+ )
if not result.data[0]:
raise SQLiteCloudException(
"An error occurred while initializing the upload of the database."
@@ -366,7 +348,9 @@ def upload_database(
# Upload completed
break
except Exception as e:
- self._internal_run_command(connection, "UPLOAD ABORT")
+ self._internal_run_command(
+ connection, self._internal_serialize_command("UPLOAD ABORT")
+ )
raise e
def download_database(
@@ -393,7 +377,10 @@ def download_database(
"""
exists_cmd = " IF EXISTS" if if_exists else ""
result = self._internal_run_command(
- connection, f"DOWNLOAD DATABASE {dbname}{exists_cmd};"
+ connection,
+ self._internal_serialize_command(
+ f"DOWNLOAD DATABASE {dbname}{exists_cmd};"
+ ),
)
if result.nrows == 0:
@@ -410,7 +397,9 @@ def download_database(
try:
while progress_size < db_size:
- result = self._internal_run_command(connection, "DOWNLOAD STEP")
+ result = self._internal_run_command(
+ connection, self._internal_serialize_command("DOWNLOAD STEP")
+ )
# res is BLOB, decode it
data = result.data[0]
@@ -424,7 +413,9 @@ def download_database(
if data_len == 0:
break
except Exception as e:
- self._internal_run_command(connection, "DOWNLOAD ABORT")
+ self._internal_run_command(
+ connection, self._internal_serialize_command("DOWNLOAD ABORT")
+ )
raise e
def _internal_config_apply(
@@ -433,51 +424,54 @@ def _internal_config_apply(
if config.timeout > 0:
connection.socket.settimeout(config.timeout)
- buffer = ""
+ command = ""
# it must be executed before authentication command
if config.non_linearizable:
- buffer += "SET CLIENT KEY NONLINEARIZABLE TO 1;"
+ command += "SET CLIENT KEY NONLINEARIZABLE TO 1;"
if config.account.apikey:
- buffer += f"AUTH APIKEY {config.account.apikey};"
+ command += f"AUTH APIKEY {config.account.apikey};"
if config.account.username and config.account.password:
- command = "HASH" if config.account.password_hashed else "PASSWORD"
- buffer += f"AUTH USER {config.account.username} {command} {config.account.password};"
+ option = "HASH" if config.account.password_hashed else "PASSWORD"
+ command += f"AUTH USER {config.account.username} {option} {config.account.password};"
if config.account.dbname:
if config.create and not config.memory:
- buffer += f"CREATE DATABASE {config.account.dbname} IF NOT EXISTS;"
- buffer += f"USE DATABASE {config.account.dbname};"
+ command += f"CREATE DATABASE {config.account.dbname} IF NOT EXISTS;"
+ command += f"USE DATABASE {config.account.dbname};"
if config.compression:
- buffer += "SET CLIENT KEY COMPRESSION TO 1;"
+ command += "SET CLIENT KEY COMPRESSION TO 1;"
if config.zerotext:
- buffer += "SET CLIENT KEY ZEROTEXT TO 1;"
+ command += "SET CLIENT KEY ZEROTEXT TO 1;"
if config.noblob:
- buffer += "SET CLIENT KEY NOBLOB TO 1;"
+ command += "SET CLIENT KEY NOBLOB TO 1;"
if config.maxdata:
- buffer += f"SET CLIENT KEY MAXDATA TO {config.maxdata};"
+ command += f"SET CLIENT KEY MAXDATA TO {config.maxdata};"
if config.maxrows:
- buffer += f"SET CLIENT KEY MAXROWS TO {config.maxrows};"
+ command += f"SET CLIENT KEY MAXROWS TO {config.maxrows};"
if config.maxrowset:
- buffer += f"SET CLIENT KEY MAXROWSET TO {config.maxrowset};"
+ command += f"SET CLIENT KEY MAXROWSET TO {config.maxrowset};"
- if len(buffer) > 0:
- self._internal_run_command(connection, buffer)
+ if len(command) > 0:
+ self._internal_run_command(
+ connection, self._internal_serialize_command(command)
+ )
def _internal_run_command(
self,
connection: SQLiteCloudConnect,
- command: Union[str, bytes],
+ command: bytes,
main_socket: bool = True,
) -> SQLiteCloudResult:
+ """Send serialized command to the server and read the response."""
if not self.is_connected(connection, main_socket):
raise SQLiteCloudException(
"The connection is closed.",
@@ -490,31 +484,24 @@ def _internal_run_command(
def _internal_socket_write(
self,
connection: SQLiteCloudConnect,
- command: Union[str, bytes],
+ command: bytes,
main_socket: bool = True,
) -> None:
- # compute header
- delimit = "$" if connection.isblob else "+"
- buffer = command.encode() if isinstance(command, str) else command
- buffer_len = len(buffer)
- header = f"{delimit}{buffer_len} "
-
- sock = connection.socket if main_socket else connection.pubsub_socket
-
- # write header
- try:
- sock.sendall(header.encode())
- except Exception as exc:
- raise SQLiteCloudException(
- "An error occurred while writing header data.",
- SQLITECLOUD_INTERNAL_ERRCODE.NETWORK,
- ) from exc
+ """
+ Write to the socket the command serialized with the SCSP protocol.
+ Args:
+ connection (SQLiteCloudConnect): The connection object to the SQLite Cloud server.
+ command (bytes): The command to send.
+ main_socket (bool): If True, write to the main socket, otherwise write to the pubsub socket.
+ """
# write buffer
- if buffer_len == 0:
+ if len(command) == 0:
return
try:
- sock.sendall(buffer)
+ sock = connection.socket if main_socket else connection.pubsub_socket
+
+ sock.sendall(command)
except Exception as exc:
raise SQLiteCloudException(
"An error occurred while writing data.",
@@ -608,30 +595,36 @@ def _internal_parse_number(
sqlitecloud_number = SQLiteCloudNumber()
sqlitecloud_number.value = 0
extvalue = 0
- isext = False
+ offcode = 0
+ isext = 0
blen = len(buffer)
# from 1 to skip the first command type character
for i in range(index, blen):
c = chr(buffer[i])
- # check for optional extended error code (ERRCODE:EXTERRCODE)
+ # check for optional extended error code (ERRCODE:EXTERRCODE:OFFCODE)
if c == ":":
- isext = True
+ isext += 1
continue
# check for end of value
if c == " ":
sqlitecloud_number.cstart = i + 1
sqlitecloud_number.extcode = extvalue
+ sqlitecloud_number.offcode = offcode
return sqlitecloud_number
val = int(c) if c.isdigit() else 0
- # compute numeric value
- if isext:
+ if isext == 1:
+ # XERRCODE
extvalue = (extvalue * 10) + val
+ elif isext == 2:
+ # OFFCODE
+ offcode = (offcode * 10) + val
else:
+ # generic value or ERRCODE
sqlitecloud_number.value = (sqlitecloud_number.value * 10) + val
sqlitecloud_number.value = 0
@@ -692,7 +685,9 @@ def _internal_parse_buffer(
clone = buffer[cstart : cstart + len_]
if cmd == SQLITECLOUD_CMD.COMMAND.value:
- return self._internal_run_command(connection, clone)
+ return self._internal_run_command(
+ connection, self._internal_serialize_command(clone.decode())
+ )
elif cmd == SQLITECLOUD_CMD.PUBSUB.value:
return SQLiteCloudResult(
SQLITECLOUD_RESULT_TYPE.RESULT_OK,
@@ -718,7 +713,7 @@ def _internal_parse_buffer(
return SQLiteCloudResult(tag, clone)
elif cmd == SQLITECLOUD_CMD.ERROR.value:
- # -LEN ERRCODE:EXTCODE ERRMSG
+ # -LEN ERRCODE:EXTCODE:OFFCODE ERRMSG
sqlite_number = self._internal_parse_number(buffer)
len_ = sqlite_number.value
cstart = sqlite_number.cstart
@@ -733,7 +728,9 @@ def _internal_parse_buffer(
len_ -= cstart2
errmsg = clone[cstart2:]
- raise SQLiteCloudException(errmsg.decode(), errcode, xerrcode)
+ raise get_sqlitecloud_error_with_extended_code(
+ errmsg.decode(), errcode, xerrcode
+ )(errmsg.decode(), errcode, xerrcode)
elif cmd in [SQLITECLOUD_CMD.ROWSET.value, SQLITECLOUD_CMD.ROWSET_CHUNK.value]:
# CMD_ROWSET: *LEN 0:VERSION ROWS COLS DATA
@@ -862,6 +859,7 @@ def _internal_parse_value(self, buffer: bytes, index: int = 0) -> SQLiteCloudVal
if cellsize is not None:
cellsize = 2
+ sqlitecloud_value.value = None
sqlitecloud_value.len = len
sqlitecloud_value.cellsize = cellsize
@@ -877,7 +875,11 @@ def _internal_parse_value(self, buffer: bytes, index: int = 0) -> SQLiteCloudVal
len = nlen - 2
cellsize = nlen
- sqlitecloud_value.value = (buffer[index + 1 : index + 1 + len]).decode()
+ value = (buffer[index + 1 : index + 1 + len]).decode()
+
+ sqlitecloud_value.value = (
+ int(value) if c == SQLITECLOUD_CMD.INT.value else float(value)
+ )
sqlitecloud_value.len
sqlitecloud_value.cellsize = cellsize
@@ -886,7 +888,12 @@ def _internal_parse_value(self, buffer: bytes, index: int = 0) -> SQLiteCloudVal
len = blen - 1 if c == SQLITECLOUD_CMD.ZEROSTRING.value else blen
cellsize = blen + cstart - index
- sqlitecloud_value.value = (buffer[cstart : cstart + len]).decode()
+ value = buffer[cstart : cstart + len]
+
+ if c == SQLITECLOUD_CMD.STRING.value or c == SQLITECLOUD_CMD.ZEROSTRING.value:
+ value = value.decode()
+
+ sqlitecloud_value.value = value
sqlitecloud_value.len = len
sqlitecloud_value.cellsize = cellsize
@@ -1060,3 +1067,49 @@ def _internal_parse_rowset_values(
sqlitecloud_value = self._internal_parse_value(buffer, start)
start += sqlitecloud_value.cellsize
rowset.data.append(sqlitecloud_value.value)
+
+ def _internal_serialize_command(
+ self, data: Any, zero_string: bool = False
+ ) -> bytes:
+ if isinstance(data, str):
+ cmd = SQLITECLOUD_CMD.STRING.value
+ if zero_string:
+ cmd = SQLITECLOUD_CMD.ZEROSTRING.value
+ data += "\x00"
+
+ data = data.encode()
+ header = f"{cmd}{len(data)} ".encode()
+
+ return header + data
+
+ if isinstance(data, int):
+ return f"{SQLITECLOUD_CMD.INT.value}{data} ".encode()
+
+ if isinstance(data, float):
+ return f"{SQLITECLOUD_CMD.FLOAT.value}{data} ".encode()
+
+ if isinstance(data, bytes):
+ header = f"{SQLITECLOUD_CMD.BLOB.value}{len(data)} ".encode()
+ return header + data
+
+ if data is None:
+ return f"{SQLITECLOUD_CMD.NULL.value} ".encode()
+
+ if isinstance(data, List):
+ return self._internal_serialize_array(data, zero_string=zero_string)
+
+ raise SQLiteCloudException(
+ f"Unsupported data type for serialization: {type(data)}"
+ )
+
+ def _internal_serialize_array(self, data: List, zero_string: bool = False) -> str:
+ n = len(data)
+ serialized_data: bytes = f"{n} ".encode()
+ for i in range(n):
+ # the query must be zero-terminated
+ zs = i == 0 or zero_string
+ serialized_data += self._internal_serialize_command(data[i], zero_string=zs)
+
+ header = f"{SQLITECLOUD_CMD.ARRAY.value}{len(serialized_data)} ".encode()
+
+ return header + serialized_data
diff --git a/src/sqlitecloud/exceptions.py b/src/sqlitecloud/exceptions.py
new file mode 100644
index 0000000..ed97601
--- /dev/null
+++ b/src/sqlitecloud/exceptions.py
@@ -0,0 +1,187 @@
+# DBAPI 2.0 Exceptions Hierachy
+#
+# Exception
+# |__Warning
+# |__Error
+# |__InterfaceError
+# |__DatabaseError
+# |__DataError
+# |__OperationalError
+# |__IntegrityError
+# |__InternalError
+# |__ProgrammingError
+# |__NotSupportedError
+
+
+class SQLiteCloudWarning(Exception):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message)
+ self.errmsg = f"Warning: {message}"
+ self.errcode = code
+ self.xerrcode = xerrcode
+
+
+class SQLiteCloudError(Exception):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message)
+ self.errmsg = message
+ self.errcode = code
+ self.xerrcode = xerrcode
+
+
+class SQLiteCloudInterfaceError(SQLiteCloudError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message, code, xerrcode)
+
+
+class SQLiteCloudDatabaseError(SQLiteCloudError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message, code, xerrcode)
+
+
+class SQLiteCloudDataError(SQLiteCloudDatabaseError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message, code, xerrcode)
+
+
+class SQLiteCloudOperationalError(SQLiteCloudDatabaseError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message, code, xerrcode)
+
+
+class SQLiteCloudIntegrityError(SQLiteCloudDatabaseError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message, code, xerrcode)
+
+
+class SQLiteCloudInternalError(SQLiteCloudDatabaseError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message, code, xerrcode)
+
+
+class SQLiteCloudProgrammingError(SQLiteCloudDatabaseError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message, code, xerrcode)
+
+
+class SQLiteCloudNotSupportedError(SQLiteCloudDatabaseError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(
+ f"The feature is not implemented in SQLite Cloud\n{message}", code, xerrcode
+ )
+
+
+class SQLiteCloudException(SQLiteCloudError):
+ def __init__(self, message: str, code: int = -1, xerrcode: int = 0) -> None:
+ super().__init__(message, code, xerrcode)
+
+
+def get_sqlitecloud_error_with_extended_code(
+ message: str, code: int, xerrcode: int
+) -> None:
+ """Mapping of sqlite error codes: https://www.sqlite.org/rescode.html"""
+
+ # define base error codes and their corresponding exceptions
+ base_error_mapping = {
+ 1: SQLiteCloudOperationalError, # SQLITE_ERROR
+ 2: SQLiteCloudInternalError, # SQLITE_INTERNAL
+ 3: SQLiteCloudOperationalError, # SQLITE_PERM
+ 4: SQLiteCloudOperationalError, # SQLITE_ABORT
+ 5: SQLiteCloudOperationalError, # SQLITE_BUSY
+ 6: SQLiteCloudOperationalError, # SQLITE_LOCKED
+ 7: SQLiteCloudDatabaseError, # SQLITE_NOMEM
+ 8: SQLiteCloudOperationalError, # SQLITE_READONLY
+ 9: SQLiteCloudOperationalError, # SQLITE_INTERRUPT
+ 10: SQLiteCloudOperationalError, # SQLITE_IOERR
+ 11: SQLiteCloudDatabaseError, # SQLITE_CORRUPT
+ 12: SQLiteCloudOperationalError, # SQLITE_NOTFOUND
+ 13: SQLiteCloudOperationalError, # SQLITE_FULL
+ 14: SQLiteCloudOperationalError, # SQLITE_CANTOPEN
+ 15: SQLiteCloudOperationalError, # SQLITE_PROTOCOL
+ 16: SQLiteCloudDatabaseError, # SQLITE_EMPTY
+ 17: SQLiteCloudOperationalError, # SQLITE_SCHEMA
+ 18: SQLiteCloudDataError, # SQLITE_TOOBIG
+ 19: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT
+ 20: SQLiteCloudProgrammingError, # SQLITE_MISMATCH
+ 21: SQLiteCloudProgrammingError, # SQLITE_MISUSE
+ 22: SQLiteCloudOperationalError, # SQLITE_NOLFS
+ 23: SQLiteCloudOperationalError, # SQLITE_AUTH
+ 24: SQLiteCloudDatabaseError, # SQLITE_FORMAT
+ 25: SQLiteCloudDataError, # SQLITE_RANGE
+ 26: SQLiteCloudDatabaseError, # SQLITE_NOTADB
+ 27: SQLiteCloudWarning, # SQLITE_NOTICE
+ 28: SQLiteCloudWarning, # SQLITE_WARNING
+ 100: SQLiteCloudWarning, # SQLITE_ROW (not an error)
+ 101: SQLiteCloudWarning, # SQLITE_DONE (not an error)
+ }
+
+ # define extended error codes and their corresponding exceptions
+ extended_error_mapping = {
+ 257: SQLiteCloudOperationalError, # SQLITE_ERROR_MISSING_COLLSEQ
+ 279: SQLiteCloudOperationalError, # SQLITE_AUTH_USER
+ 266: SQLiteCloudOperationalError, # SQLITE_IOERR_READ
+ 522: SQLiteCloudOperationalError, # SQLITE_IOERR_SHORT_READ
+ 778: SQLiteCloudOperationalError, # SQLITE_IOERR_WRITE
+ 1034: SQLiteCloudOperationalError, # SQLITE_IOERR_FSYNC
+ 1290: SQLiteCloudOperationalError, # SQLITE_IOERR_DIR_FSYNC
+ 1546: SQLiteCloudOperationalError, # SQLITE_IOERR_TRUNCATE
+ 1802: SQLiteCloudOperationalError, # SQLITE_IOERR_FSTAT
+ 2058: SQLiteCloudOperationalError, # SQLITE_IOERR_UNLOCK
+ 2314: SQLiteCloudOperationalError, # SQLITE_IOERR_RDLOCK
+ 2570: SQLiteCloudOperationalError, # SQLITE_IOERR_DELETE
+ 2826: SQLiteCloudOperationalError, # SQLITE_IOERR_BLOCKED
+ 3082: SQLiteCloudOperationalError, # SQLITE_IOERR_NOMEM
+ 3338: SQLiteCloudOperationalError, # SQLITE_IOERR_ACCESS
+ 3594: SQLiteCloudOperationalError, # SQLITE_IOERR_CHECKRESERVEDLOCK
+ 3850: SQLiteCloudOperationalError, # SQLITE_IOERR_LOCK
+ 4106: SQLiteCloudOperationalError, # SQLITE_IOERR_CLOSE
+ 4362: SQLiteCloudOperationalError, # SQLITE_IOERR_DIR_CLOSE
+ 4618: SQLiteCloudOperationalError, # SQLITE_IOERR_SHMOPEN
+ 4874: SQLiteCloudOperationalError, # SQLITE_IOERR_SHMSIZE
+ 5130: SQLiteCloudOperationalError, # SQLITE_IOERR_SHMLOCK
+ 5386: SQLiteCloudOperationalError, # SQLITE_IOERR_SHMMAP
+ 5642: SQLiteCloudOperationalError, # SQLITE_IOERR_SEEK
+ 5898: SQLiteCloudOperationalError, # SQLITE_IOERR_DELETE_NOENT
+ 6154: SQLiteCloudOperationalError, # SQLITE_IOERR_MMAP
+ 6410: SQLiteCloudOperationalError, # SQLITE_IOERR_GETTEMPPATH
+ 6666: SQLiteCloudOperationalError, # SQLITE_IOERR_CONVPATH
+ 6922: SQLiteCloudOperationalError, # SQLITE_IOERR_VNODE
+ 7178: SQLiteCloudOperationalError, # SQLITE_IOERR_AUTH
+ 262: SQLiteCloudOperationalError, # SQLITE_LOCKED_SHAREDCACHE
+ 261: SQLiteCloudOperationalError, # SQLITE_BUSY_RECOVERY
+ 517: SQLiteCloudOperationalError, # SQLITE_BUSY_SNAPSHOT
+ 270: SQLiteCloudOperationalError, # SQLITE_CANTOPEN_NOTEMPDIR
+ 526: SQLiteCloudOperationalError, # SQLITE_CANTOPEN_ISDIR
+ 782: SQLiteCloudOperationalError, # SQLITE_CANTOPEN_FULLPATH
+ 1038: SQLiteCloudOperationalError, # SQLITE_CANTOPEN_CONVPATH
+ 264: SQLiteCloudOperationalError, # SQLITE_READONLY_RECOVERY
+ 520: SQLiteCloudOperationalError, # SQLITE_READONLY_CANTLOCK
+ 776: SQLiteCloudOperationalError, # SQLITE_READONLY_ROLLBACK
+ 1032: SQLiteCloudOperationalError, # SQLITE_READONLY_DBMOVED
+ 1544: SQLiteCloudOperationalError, # SQLITE_READONLY_DIRECTORY
+ 516: SQLiteCloudOperationalError, # SQLITE_ABORT_ROLLBACK
+ 275: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_CHECK
+ 531: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_COMMITHOOK
+ 787: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_FOREIGNKEY
+ 1043: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_FUNCTION
+ 1299: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_NOTNULL
+ 1555: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_PRIMARYKEY
+ 1811: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_TRIGGER
+ 2067: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_UNIQUE
+ 2323: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_VTAB
+ 2579: SQLiteCloudIntegrityError, # SQLITE_CONSTRAINT_ROWID
+ 779: SQLiteCloudDatabaseError, # SQLITE_CORRUPT_INDEX
+ 523: SQLiteCloudProgrammingError, # SQLITE_CORRUPT_SEQUENCE
+ 3091: SQLiteCloudDataError, # SQLITE_CONSTRAINT_DATATYPE
+ 2835: SQLiteCloudDataError, # SQLITE_CONSTRAINT_PINNED
+ 539: SQLiteCloudWarning, # SQLITE_NOTICE_RECOVER_WAL
+ 283: SQLiteCloudWarning, # SQLITE_NOTICE_RECOVER_ROLLBACK
+ 284: SQLiteCloudWarning, # SQLITE_WARNING_AUTOINDEX
+ }
+
+ error_mapping = {**base_error_mapping, **extended_error_mapping}
+
+ # retrieve the corresponding exception based on the error code
+ exception = error_mapping.get(xerrcode, error_mapping.get(code, SQLiteCloudError))
+
+ return exception
diff --git a/src/sqlitecloud/resultset.py b/src/sqlitecloud/resultset.py
index 6fcc481..f4602ee 100644
--- a/src/sqlitecloud/resultset.py
+++ b/src/sqlitecloud/resultset.py
@@ -60,35 +60,23 @@ def _compute_index(self, row: int, col: int) -> int:
return -1
return row * self.ncols + col
- def get_value(self, row: int, col: int, convert: bool = True) -> Optional[any]:
+ def get_value(self, row: int, col: int) -> Optional[any]:
index = self._compute_index(row, col)
if index < 0 or not self.data or index >= len(self.data):
return None
- value = self.data[index]
- return self._convert(value, col) if convert else value
+ return self.data[index]
def get_name(self, col: int) -> Optional[str]:
if col < 0 or col >= self.ncols:
return None
return self.colname[col]
- def _convert(self, value: str, col: int) -> any:
- if col < 0 or col >= len(self.decltype):
- return value
-
- decltype = self.decltype[col]
- if decltype == SQLITECLOUD_VALUE_TYPE.INTEGER.value:
- return int(value)
- if decltype == SQLITECLOUD_VALUE_TYPE.FLOAT.value:
- return float(value)
- if decltype == SQLITECLOUD_VALUE_TYPE.BLOB.value:
- # values are received as bytes before being strings
- return bytes(value)
- if decltype == SQLITECLOUD_VALUE_TYPE.NULL.value:
+ def get_decltype(self, col: int) -> Optional[str]:
+ if col < 0 or col >= self.ncols or col >= len(self.decltype):
return None
- return value
+ return self.decltype[col]
class SQLiteCloudResultSet:
@@ -126,3 +114,34 @@ def get_name(self, col: int) -> Optional[str]:
def get_result(self) -> Optional[any]:
return self.get_value(0, 0)
+
+
+class SQLiteCloudOperationResult:
+ """Result of a DML operation in a SQLite statement."""
+
+ def __init__(self, result: SQLiteCloudResult) -> None:
+ self._result = result
+
+ @property
+ def type(self) -> int:
+ return self._result.data[0][0]
+
+ @property
+ def index(self) -> int:
+ return self._result.data[0][1]
+
+ @property
+ def rowid(self) -> int:
+ return self._result.data[0][2]
+
+ @property
+ def changes(self) -> int:
+ return self._result.data[0][3]
+
+ @property
+ def total_changes(self) -> int:
+ return self._result.data[0][4]
+
+ @property
+ def finalized(self) -> int:
+ return self._result.data[0][5]
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index 8d45371..d942f77 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -1,4 +1,6 @@
import os
+import sqlite3
+from copy import deepcopy
import pytest
from dotenv import load_dotenv
@@ -13,6 +15,29 @@ def load_env_vars():
load_dotenv(".env")
+@pytest.fixture(autouse=True)
+def reset_module_state():
+ original_sqlc_adapters = deepcopy(sqlitecloud.adapters)
+ original_sqlc_converters = deepcopy(sqlitecloud.converters)
+
+ original_sql_adapters = deepcopy(sqlite3.adapters)
+ original_sql_converters = deepcopy(sqlite3.converters)
+
+ yield
+
+ sqlitecloud.adapters.clear()
+ sqlitecloud.converters.clear()
+
+ sqlite3.adapters.clear()
+ sqlite3.converters.clear()
+
+ sqlitecloud.adapters.update(original_sqlc_adapters)
+ sqlitecloud.converters.update(original_sqlc_converters)
+
+ sqlite3.adapters.update(original_sql_adapters)
+ sqlite3.converters.update(original_sql_converters)
+
+
@pytest.fixture()
def sqlitecloud_connection():
account = SQLiteCloudAccount()
@@ -35,6 +60,22 @@ def sqlitecloud_connection():
@pytest.fixture()
def sqlitecloud_dbapi2_connection():
+ # fixture and declaration are split to be able
+ # to create multiple instances of the connection
+ # when calling the getter function directly from
+ # the test.
+ # Fixtures are both cached and cannot be called
+ # directly whithin the test.
+ connection_generator = get_sqlitecloud_dbapi2_connection()
+
+ connection = next(connection_generator)
+
+ yield connection
+
+ close_generator(connection_generator)
+
+
+def get_sqlitecloud_dbapi2_connection(detect_types: int = 0):
account = SQLiteCloudAccount()
account.username = os.getenv("SQLITE_USER")
account.password = os.getenv("SQLITE_PASSWORD")
@@ -42,10 +83,41 @@ def sqlitecloud_dbapi2_connection():
account.hostname = os.getenv("SQLITE_HOST")
account.port = int(os.getenv("SQLITE_PORT"))
- connection = sqlitecloud.connect(account)
+ connection = sqlitecloud.connect(account, detect_types=detect_types)
assert isinstance(connection, sqlitecloud.Connection)
yield connection
connection.close()
+
+
+@pytest.fixture()
+def sqlite3_connection():
+ connection_generator = get_sqlite3_connection()
+
+ connection = next(connection_generator)
+
+ yield connection
+
+ close_generator(connection_generator)
+
+
+def get_sqlite3_connection(detect_types: int = 0):
+ # set isolation_level=None to enable autocommit
+ # and to be aligned with the behavior of SQLite Cloud
+ connection = sqlite3.connect(
+ os.path.join(os.path.dirname(__file__), "./assets/chinook.sqlite"),
+ isolation_level=None,
+ detect_types=detect_types,
+ )
+ yield connection
+
+ connection.close()
+
+
+def close_generator(generator):
+ try:
+ next(generator)
+ except StopIteration:
+ pass
diff --git a/src/tests/integration/test_client.py b/src/tests/integration/test_client.py
index 3680e1d..70a514c 100644
--- a/src/tests/integration/test_client.py
+++ b/src/tests/integration/test_client.py
@@ -1,4 +1,5 @@
import os
+import random
import time
import pytest
@@ -9,7 +10,11 @@
SQLITECLOUD_INTERNAL_ERRCODE,
SQLiteCloudAccount,
SQLiteCloudConnect,
+)
+from sqlitecloud.exceptions import (
+ SQLiteCloudError,
SQLiteCloudException,
+ SQLiteCloudOperationalError,
)
from sqlitecloud.resultset import SQLITECLOUD_RESULT_TYPE
@@ -55,7 +60,7 @@ def test_connection_without_credentials_and_apikey(self):
client = SQLiteCloudClient(cloud_account=account)
- with pytest.raises(SQLiteCloudException):
+ with pytest.raises(SQLiteCloudError):
client.open_connection()
def test_connect_with_string(self):
@@ -123,7 +128,7 @@ def test_select(self, sqlitecloud_connection):
def test_column_not_found(self, sqlitecloud_connection):
connection, client = sqlitecloud_connection
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudOperationalError) as e:
client.exec_query("SELECT not_a_column FROM albums", connection)
assert e.value.errcode == 1
@@ -266,7 +271,7 @@ def test_blob_zero_length(self, sqlitecloud_connection):
def test_error(self, sqlitecloud_connection):
connection, client = sqlitecloud_connection
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudError) as e:
client.exec_query("TEST ERROR", connection)
assert e.value.errcode == 66666
@@ -275,7 +280,7 @@ def test_error(self, sqlitecloud_connection):
def test_ext_error(self, sqlitecloud_connection):
connection, client = sqlitecloud_connection
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudError) as e:
client.exec_query("TEST EXTERROR", connection)
assert e.value.errcode == 66666
@@ -295,8 +300,8 @@ def test_array(self, sqlitecloud_connection):
assert isinstance(result_array, list)
assert len(result_array) == 5
assert result_array[0] == "Hello World"
- assert result_array[1] == "123456"
- assert result_array[2] == "3.1415"
+ assert result_array[1] == 123456
+ assert result_array[2] == 3.1415
assert result_array[3] is None
def test_rowset(self, sqlitecloud_connection):
@@ -310,6 +315,19 @@ def test_rowset(self, sqlitecloud_connection):
assert result.get_name(0) == "key"
assert result.get_name(1) == "value"
+ def test_rowset_data_types(self, sqlitecloud_connection):
+ connection, client = sqlitecloud_connection
+
+ bindings = ("hello world", 15175, 3.14, b"bytes world", None)
+ result = client.exec_statement("SELECT ?, ?, ?, ?, ?", bindings, connection)
+
+ assert SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET == result.tag
+ assert result.get_value(0, 0) == "hello world"
+ assert result.get_value(0, 1) == 15175
+ assert result.get_value(0, 2) == 3.14
+ assert result.get_value(0, 3) == b"bytes world"
+ assert result.get_value(0, 4) is None
+
def test_max_rows_option(self):
account = SQLiteCloudAccount()
account.hostname = os.getenv("SQLITE_HOST")
@@ -340,7 +358,7 @@ def test_max_rowset_option_to_fail_when_rowset_is_bigger(self):
connection = client.open_connection()
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudError) as e:
client.exec_query("SELECT * FROM albums", connection)
client.disconnect(connection)
@@ -419,7 +437,7 @@ def test_serialized_operations(self, sqlitecloud_connection):
assert 2 == rowset.ncols
assert "count" == rowset.get_name(0)
assert "string" == rowset.get_name(1)
- assert str(i) == rowset.get_value(0, 0)
+ assert i == rowset.get_value(0, 0)
assert rowset.version in [1, 2]
def test_query_timeout(self):
@@ -504,7 +522,7 @@ def test_select_results_with_no_column_name(self, sqlitecloud_connection):
assert rowset.ncols == 2
assert rowset.get_name(0) == "42"
assert rowset.get_name(1) == "'hello'"
- assert rowset.get_value(0, 0) == "42"
+ assert rowset.get_value(0, 0) == 42
assert rowset.get_value(0, 1) == "hello"
def test_select_long_formatted_string(self, sqlitecloud_connection):
@@ -616,6 +634,7 @@ def test_stress_test_20x_batched_selects(self, sqlitecloud_connection):
query_ms < self.EXPECT_SPEED_MS
), f"{num_queries}x batched selects, {query_ms}ms per query"
+ @pytest.mark.slow
def test_big_rowset(self):
account = SQLiteCloudAccount()
account.hostname = os.getenv("SQLITE_HOST")
@@ -623,31 +642,33 @@ def test_big_rowset(self):
account.dbname = os.getenv("SQLITE_DB")
client = SQLiteCloudClient(cloud_account=account)
+ client.config.timeout = 120
connection = client.open_connection()
+ table_name = "TestCompress" + str(random.randint(0, 99999))
try:
client.exec_query(
- "CREATE TABLE IF NOT EXISTS TestCompress (id INTEGER PRIMARY KEY, name TEXT)",
+ f"CREATE TABLE {table_name} (id INTEGER PRIMARY KEY, name TEXT)",
connection,
)
- client.exec_query("DELETE FROM TestCompress", connection)
- nRows = 1000
+ nRows = 5000
sql = ""
for i in range(nRows):
- sql += f"INSERT INTO TestCompress (name) VALUES ('Test {i}'); "
+ sql += f"INSERT INTO {table_name} (name) VALUES ('Test-{i}'); "
client.exec_query(sql, connection)
rowset = client.exec_query(
- "SELECT * from TestCompress",
+ f"SELECT * from {table_name}",
connection,
)
assert rowset.nrows == nRows
finally:
+ client.exec_query(f"DROP TABLE IF EXISTS {table_name}", connection)
client.disconnect(connection)
def test_compression_single_column(self):
@@ -720,18 +741,6 @@ def test_rowset_chunk_compressed(self, sqlitecloud_connection):
assert 147 == len(rowset.data)
assert "key" == rowset.get_name(0)
- def test_exec_statement_with_named_placeholder(self, sqlitecloud_connection):
- connection, client = sqlitecloud_connection
-
- result = client.exec_statement(
- "SELECT * FROM albums WHERE AlbumId = :id and Title = :title",
- {"id": 1, "title": "For Those About To Rock We Salute You"},
- connection,
- )
-
- assert result.nrows == 1
- assert result.get_value(0, 0) == 1
-
def test_exec_statement_with_qmarks(self, sqlitecloud_connection):
connection, client = sqlitecloud_connection
diff --git a/src/tests/integration/test_dbapi2.py b/src/tests/integration/test_dbapi2.py
index 53b2a18..fa1e793 100644
--- a/src/tests/integration/test_dbapi2.py
+++ b/src/tests/integration/test_dbapi2.py
@@ -4,11 +4,8 @@
import pytest
import sqlitecloud
-from sqlitecloud.datatypes import (
- SQLITECLOUD_INTERNAL_ERRCODE,
- SQLiteCloudAccount,
- SQLiteCloudException,
-)
+from sqlitecloud.datatypes import SQLiteCloudAccount
+from sqlitecloud.exceptions import SQLiteCloudError, SQLiteCloudProgrammingError
class TestDBAPI2:
@@ -49,11 +46,11 @@ def test_disconnect(self):
assert isinstance(connection, sqlitecloud.Connection)
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudProgrammingError) as e:
connection.execute("SELECT 1")
- assert e.value.errcode == SQLITECLOUD_INTERNAL_ERRCODE.NETWORK
- assert e.value.errmsg == "The connection is closed."
+ assert e.value.errcode == 1
+ assert e.value.errmsg == "The cursor is closed."
def test_select(self, sqlitecloud_dbapi2_connection):
connection = sqlitecloud_dbapi2_connection
@@ -77,7 +74,7 @@ def test_connection_execute(self, sqlitecloud_dbapi2_connection):
def test_column_not_found(self, sqlitecloud_dbapi2_connection):
connection = sqlitecloud_dbapi2_connection
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudError) as e:
connection.execute("SELECT not_a_column FROM albums")
assert e.value.errcode == 1
@@ -126,7 +123,7 @@ def test_integer(self, sqlitecloud_dbapi2_connection):
def test_error(self, sqlitecloud_dbapi2_connection):
connection = sqlitecloud_dbapi2_connection
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudError) as e:
connection.execute("TEST ERROR")
assert e.value.errcode == 66666
@@ -143,6 +140,21 @@ def test_execute_with_named_placeholder(self, sqlitecloud_dbapi2_connection):
assert cursor.rowcount == 1
assert cursor.fetchone() == (1, "For Those About To Rock We Salute You", 1)
+ def test_execute_with_named_placeholder_and_a_fake_one_which_is_not_given(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ """ "Expect the converter from name to qmark placeholder to not be fooled by the
+ fake name with the colon in it."""
+ connection = sqlitecloud_dbapi2_connection
+
+ cursor = connection.execute(
+ "SELECT * FROM albums WHERE AlbumId = :id and Title != 'special:name'",
+ {"id": 1},
+ )
+
+ assert cursor.rowcount == 1
+ assert cursor.fetchone() == (1, "For Those About To Rock We Salute You", 1)
+
def test_execute_with_qmarks(self, sqlitecloud_dbapi2_connection):
connection = sqlitecloud_dbapi2_connection
@@ -246,3 +258,186 @@ def test_row_factory(self, sqlitecloud_dbapi2_connection):
assert row["AlbumId"] == 1
assert row["Title"] == "For Those About To Rock We Salute You"
assert row["ArtistId"] == 1
+
+ def test_row_object_for_factory_string_representation(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ connection.row_factory = sqlitecloud.Row
+
+ cursor = connection.execute('SELECT "foo" as Bar, "john" Doe')
+
+ row = cursor.fetchone()
+
+ assert str(row) == "Bar: foo\nDoe: john"
+
+ def test_last_rowid_and_rowcount_with_select(self, sqlitecloud_dbapi2_connection):
+ connection = sqlitecloud_dbapi2_connection
+
+ cursor = connection.execute("SELECT * FROM genres LIMIT 3")
+
+ assert cursor.fetchone() is not None
+ assert cursor.lastrowid is None
+ assert cursor.rowcount == 3
+
+ def test_last_rowid_and_rowcount_with_execute_update(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ new_name = "Jazz" + str(uuid.uuid4())
+ genreId = 2
+
+ cursor = connection.execute(
+ "UPDATE genres SET Name = ? WHERE GenreId = ?",
+ (new_name, genreId),
+ )
+
+ assert cursor.fetchone() is None
+ assert cursor.lastrowid is None
+ assert cursor.rowcount == 1
+
+ def test_last_rowid_and_rowcount_with_execute_insert(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ new_name = "Jazz" + str(uuid.uuid4())
+
+ cursor = connection.execute(
+ "INSERT INTO genres (Name) VALUES (?)",
+ (new_name,),
+ )
+
+ last_result = connection.execute(
+ "SELECT GenreId FROM genres WHERE Name = ?", (new_name,)
+ )
+
+ assert cursor.fetchone() is None
+ assert cursor.lastrowid == last_result.fetchone()[0]
+ assert cursor.rowcount == 1
+
+ def test_last_rowid_and_rowcount_with_execute_delete(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ new_name = "Jazz" + str(uuid.uuid4())
+
+ cursor_select = connection.execute(
+ "INSERT INTO genres (Name) VALUES (?)",
+ (new_name,),
+ )
+
+ cursor = connection.execute("DELETE FROM genres WHERE Name = ?", (new_name,))
+
+ assert cursor.fetchone() is None
+ assert cursor.lastrowid == cursor_select.lastrowid
+ assert cursor.rowcount == 1
+
+ def test_last_rowid_and_rowcount_with_multiple_updates(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ new_name = "Jazz" + str(uuid.uuid4())
+
+ cursor = connection.execute(
+ "UPDATE genres SET Name = ? WHERE GenreId = ? or GenreId = ?",
+ (new_name, 2, 3),
+ )
+
+ assert cursor.fetchone() is None
+ assert cursor.lastrowid is None
+ assert cursor.rowcount == 2
+
+ def test_last_rowid_and_rowcount_with_multiple_deletes(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ new_name1 = "Jazz" + str(uuid.uuid4())
+ new_name2 = "Jazz" + str(uuid.uuid4())
+
+ cursor = connection.executemany(
+ "INSERT INTO genres (Name) VALUES (?)",
+ [(new_name1,), (new_name2,)],
+ )
+
+ cursor = connection.execute(
+ "DELETE FROM genres WHERE Name = ? or Name = ?", (new_name1, new_name2)
+ )
+
+ assert cursor.fetchone() is None
+ assert cursor.lastrowid > 0
+ assert cursor.rowcount == 2
+
+ def test_last_rowid_and_rowcount_with_executemany_updates(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ new_name1 = "Jazz" + str(uuid.uuid4())
+ new_name2 = "Jazz" + str(uuid.uuid4())
+ genreId = 2
+
+ cursor = connection.executemany(
+ "UPDATE genres SET Name = ? WHERE GenreId = ?;",
+ [(new_name1, genreId), (new_name2, genreId)],
+ )
+
+ assert cursor.fetchone() is None
+ assert cursor.lastrowid is None
+ assert cursor.rowcount == 1
+
+ def test_last_rowid_and_rowcount_with_executemany_inserts(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ new_name1 = "Jazz" + str(uuid.uuid4())
+ new_name2 = "Jazz" + str(uuid.uuid4())
+
+ cursor = connection.executemany(
+ "INSERT INTO genres (Name) VALUES (?)",
+ [(new_name1,), (new_name2,)],
+ )
+
+ last_result = connection.execute(
+ "SELECT GenreId FROM genres WHERE Name = ?", (new_name2,)
+ )
+
+ assert cursor.fetchone() is None
+ assert cursor.lastrowid == last_result.fetchone()[0]
+ assert cursor.rowcount == 1
+
+ def test_last_rowid_and_rowcount_with_executemany_deletes(
+ self, sqlitecloud_dbapi2_connection
+ ):
+ connection = sqlitecloud_dbapi2_connection
+
+ new_name1 = "Jazz" + str(uuid.uuid4())
+ new_name2 = "Jazz" + str(uuid.uuid4())
+
+ cursor_insert = connection.executemany(
+ "INSERT INTO genres (Name) VALUES (?)",
+ [(new_name1,), (new_name2,)],
+ )
+
+ cursor = connection.executemany(
+ "DELETE FROM genres WHERE Name = ?", [(new_name1,), (new_name2,)]
+ )
+
+ assert cursor.fetchone() is None
+ assert cursor.lastrowid == cursor_insert.lastrowid
+ assert cursor.rowcount == 1
+
+ def test_connection_is_connected(self, sqlitecloud_dbapi2_connection):
+ connection = sqlitecloud_dbapi2_connection
+
+ assert connection.is_connected()
+
+ connection.close()
+
+ assert not connection.is_connected()
diff --git a/src/tests/integration/test_download.py b/src/tests/integration/test_download.py
index 7c7c8af..3120fd2 100644
--- a/src/tests/integration/test_download.py
+++ b/src/tests/integration/test_download.py
@@ -4,7 +4,7 @@
import pytest
from sqlitecloud import download
-from sqlitecloud.datatypes import SQLITECLOUD_ERRCODE, SQLiteCloudException
+from sqlitecloud.exceptions import SQLiteCloudError
class TestDownload:
@@ -25,8 +25,7 @@ def test_download_missing_database(self, sqlitecloud_connection):
temp_file = tempfile.mkstemp(prefix="missing")[1]
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudError) as e:
download.download_db(connection, "missing.sqlite", temp_file)
- assert e.value.errcode == SQLITECLOUD_ERRCODE.COMMAND.value
assert e.value.errmsg == "Database missing.sqlite does not exist."
diff --git a/src/tests/integration/test_driver.py b/src/tests/integration/test_driver.py
index c6b7a7d..ece2755 100644
--- a/src/tests/integration/test_driver.py
+++ b/src/tests/integration/test_driver.py
@@ -1,6 +1,9 @@
+import random
import tempfile
+import uuid
from sqlitecloud.driver import Driver
+from sqlitecloud.resultset import SQLiteCloudOperationResult, SQLiteCloudResult
class TestDriver:
@@ -23,3 +26,90 @@ def test_download_missing_database_without_error_when_expected(
lambda x, y, z, k: None,
if_exists=if_exists,
)
+
+ def test_prepare_statement_select(self, sqlitecloud_connection):
+ driver = Driver()
+
+ connection, _ = sqlitecloud_connection
+
+ query = "SELECT AlbumId FROM Albums WHERE AlbumId = ?"
+ bindings = [1]
+
+ result: SQLiteCloudResult = driver.execute_statement(
+ query, bindings, connection
+ )
+
+ assert result.nrows == 1
+ assert result.ncols == 1
+ assert result.get_name(0) == "AlbumId"
+ assert result.get_value(0, 0) == 1
+
+ def test_prepare_statement_insert(self, sqlitecloud_connection):
+ driver = Driver()
+
+ connection, _ = sqlitecloud_connection
+
+ name = "MyGenre" + str(uuid.uuid4())
+ query = "INSERT INTO genres (Name) VALUES (?)"
+ bindings = [name]
+
+ result = driver.execute_statement(query, bindings, connection)
+
+ result_select = driver.execute_statement(
+ "SELECT GenreId FROM genres WHERE Name = ?", (name,), connection
+ )
+
+ assert isinstance(result, SQLiteCloudOperationResult)
+ assert result.type == 10
+ assert result.index == 0
+ assert result.rowid == result_select.get_value(0, 0)
+ assert result.changes == 1
+ assert result.total_changes == 1
+ assert result.finalized == 1
+
+ def test_prepare_statement_delete(self, sqlitecloud_connection):
+ driver = Driver()
+
+ connection, _ = sqlitecloud_connection
+
+ name = "MyGenre" + str(random.randint(0, 1000))
+ result_insert = driver.execute_statement(
+ "INSERT INTO genres (Name) VALUES (?)", [name], connection
+ )
+
+ result = driver.execute_statement(
+ "DELETE FROM genres WHERE Name = ?", (name,), connection
+ )
+
+ assert isinstance(result, SQLiteCloudOperationResult)
+ assert result.type == 10
+ assert result.index == 0
+ assert result.rowid == result_insert.rowid
+ assert result.changes == 1
+ assert result.total_changes == 2 # insert + delete
+ assert result.finalized == 1
+
+ def test_prepare_statement_update(self, sqlitecloud_connection):
+ driver = Driver()
+
+ connection, _ = sqlitecloud_connection
+
+ name = "MyGenre" + str(random.randint(0, 1000))
+ result_insert = driver.execute_statement(
+ "INSERT INTO genres (Name) VALUES (?)", [name], connection
+ )
+
+ new_name = "AnotherMyGenre" + str(random.randint(0, 1000))
+ result = driver.execute_statement(
+ "UPDATE genres SET Name = ? WHERE GenreId = ?",
+ (new_name, result_insert.rowid),
+ connection,
+ )
+
+ assert isinstance(result, SQLiteCloudOperationResult)
+ assert result.type == 10
+ assert result.index == 0
+ assert result.rowid == result_insert.rowid
+ assert result.changes == 1
+ assert result.total_changes == 2 # insert + update
+ assert result.finalized == 1
diff --git a/src/tests/integration/test_pandas.py b/src/tests/integration/test_pandas.py
index 799627b..34ae524 100644
--- a/src/tests/integration/test_pandas.py
+++ b/src/tests/integration/test_pandas.py
@@ -22,7 +22,8 @@ def test_insert_from_dataframe(self, sqlitecloud_dbapi2_connection):
}
)
- conn.executemany("DROP TABLE IF EXISTS ?", [("PRICES",), ("TICKER_MAPPING",)])
+ for table in ["PRICES", "TICKER_MAPPING"]:
+ conn.execute(f"DROP TABLE IF EXISTS {table}")
# arg if_exists="replace" raises the error
dfprices.to_sql("PRICES", conn, index=False)
diff --git a/src/tests/integration/test_pubsub.py b/src/tests/integration/test_pubsub.py
index 03d18c9..a21399b 100644
--- a/src/tests/integration/test_pubsub.py
+++ b/src/tests/integration/test_pubsub.py
@@ -3,11 +3,8 @@
import pytest
-from sqlitecloud.datatypes import (
- SQLITECLOUD_ERRCODE,
- SQLITECLOUD_PUBSUB_SUBJECT,
- SQLiteCloudException,
-)
+from sqlitecloud.datatypes import SQLITECLOUD_ERRCODE, SQLITECLOUD_PUBSUB_SUBJECT
+from sqlitecloud.exceptions import SQLiteCloudError
from sqlitecloud.pubsub import SQLiteCloudPubSub
from sqlitecloud.resultset import SQLITECLOUD_RESULT_TYPE, SQLiteCloudResultSet
@@ -81,7 +78,7 @@ def test_create_channel_to_fail_if_exists(self, sqlitecloud_connection):
pubsub.create_channel(connection, channel_name, if_not_exists=True)
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudError) as e:
pubsub.create_channel(connection, channel_name, if_not_exists=False)
assert (
@@ -136,15 +133,16 @@ def assert_callback(conn, result, data):
assert pubsub.is_connected(connection)
connection2 = client.open_connection()
- pubsub2 = SQLiteCloudPubSub()
- pubsub2.notify_channel(connection2, channel, "message-in-a-bottle")
+ try:
+ pubsub2 = SQLiteCloudPubSub()
+ pubsub2.notify_channel(connection2, channel, "message-in-a-bottle")
- client.disconnect(connection2)
+ # wait for callback to be called
+ flag.wait(30)
- # wait for callback to be called
- flag.wait(30)
-
- assert callback_called
+ assert callback_called
+ finally:
+ client.disconnect(connection2)
def test_listen_table_for_update(self, sqlitecloud_connection):
connection, client = sqlitecloud_connection
diff --git a/src/tests/integration/test_sqlalchemy.py b/src/tests/integration/test_sqlalchemy.py
new file mode 100644
index 0000000..25bc33c
--- /dev/null
+++ b/src/tests/integration/test_sqlalchemy.py
@@ -0,0 +1,88 @@
+import os
+import sys
+import uuid
+
+import pytest
+import sqlalchemy as db
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.dialects import registry
+from sqlalchemy.orm import backref, declarative_base, relationship, sessionmaker
+
+module_dir = os.path.abspath("sqlalchemy-sqlitecloud")
+if module_dir not in sys.path:
+ sys.path.insert(0, module_dir)
+
+registry.register("sqlitecloud", "sqlalchemy_sqlitecloud.base", "SQLiteCloudDialect")
+
+Base = declarative_base()
+
+
+class Artist(Base):
+ __tablename__ = "artists"
+
+ ArtistId = Column("ArtistId", Integer, primary_key=True)
+ Name = Column("Name", String)
+ Albums = relationship("Album", backref=backref("artist"))
+
+
+class Album(Base):
+ __tablename__ = "albums"
+
+ AlbumId = Column("AlbumId", Integer, primary_key=True)
+ ArtistId = Column("ArtistId", Integer, ForeignKey("artists.ArtistId"))
+ Title = Column("Title", String)
+
+
+@pytest.fixture()
+def sqlitecloud_connection_string():
+ connection_string = os.getenv("SQLITE_CONNECTION_STRING")
+ database = os.getenv("SQLITE_DB")
+ apikey = os.getenv("SQLITE_API_KEY")
+
+ engine = db.create_engine(f"{connection_string}/{database}?apikey={apikey}")
+ Session = sessionmaker(bind=engine)
+ session = Session()
+
+ yield session
+
+ session.close()
+ engine.dispose()
+
+
+@pytest.fixture()
+def sqlite_connection_string():
+ engine = db.create_engine("sqlite:///src/tests/assets/chinook.sqlite")
+ Session = sessionmaker(bind=engine)
+ session = Session()
+
+ yield session
+
+ session.close()
+ engine.dispose()
+
+
+@pytest.mark.parametrize(
+ "session", ["sqlitecloud_connection_string", "sqlite_connection_string"]
+)
+def test_insert_and_select(session, request):
+ session = request.getfixturevalue(session)
+
+ name = "Mattew" + str(uuid.uuid4())
+ query = db.insert(Artist).values(Name=name)
+ result_insert = session.execute(query)
+
+ title = "The Album" + str(uuid.uuid4())
+ query = db.insert(Album).values(ArtistId=result_insert.lastrowid, Title=title)
+ session.execute(query)
+
+ query = (
+ db.select(Artist, Album)
+ .join(Album, Artist.ArtistId == Album.ArtistId)
+ .where(Artist.ArtistId == result_insert.lastrowid)
+ )
+
+ result = session.execute(query).fetchone()
+
+ assert result[0].ArtistId == result_insert.lastrowid
+ assert result[0].Name == name
+ assert result[1].Title == title
diff --git a/src/tests/integration/test_sqlite3_parity.py b/src/tests/integration/test_sqlite3_parity.py
deleted file mode 100644
index bc71fb9..0000000
--- a/src/tests/integration/test_sqlite3_parity.py
+++ /dev/null
@@ -1,245 +0,0 @@
-import os
-import sqlite3
-
-import pytest
-
-from sqlitecloud.datatypes import SQLiteCloudException
-
-
-class TestSQLite3FeatureParity:
- @pytest.fixture()
- def sqlite3_connection(self):
- connection = sqlite3.connect(
- os.path.join(os.path.dirname(__file__), "../assets/chinook.sqlite")
- )
- yield connection
- connection.close()
-
- def test_connection_close(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- sqlitecloud_connection.close()
- sqlite3_connection.close()
-
- with pytest.raises(SQLiteCloudException) as e:
- sqlitecloud_connection.execute("SELECT 1")
-
- assert isinstance(e.value, SQLiteCloudException)
-
- with pytest.raises(sqlite3.ProgrammingError) as e:
- sqlite3_connection.execute("SELECT 1")
-
- assert isinstance(e.value, sqlite3.ProgrammingError)
-
- @pytest.mark.skip(
- reason="SQLite Cloud does not convert to int a column without an explicit SQLite Type"
- )
- def test_ping_select(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- sqlitecloud_cursor = sqlitecloud_connection.execute("SELECT 1")
- sqlite3_cursor = sqlite3_connection.execute("SELECT 1")
-
- sqlitecloud_cursor = sqlitecloud_cursor.fetchall()
- sqlite3_cursor = sqlite3_cursor.fetchall()
-
- assert sqlitecloud_cursor == sqlite3_cursor
-
- def test_create_table_and_insert_many(
- self, sqlitecloud_dbapi2_connection, sqlite3_connection
- ):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- create_table_query = "CREATE TABLE IF NOT EXISTS sqlitetest (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"
- sqlitecloud_connection.execute(create_table_query)
- sqlite3_connection.execute(create_table_query)
-
- truncate_table_query = "DELETE FROM sqlitetest"
- sqlitecloud_connection.execute(truncate_table_query)
- sqlite3_connection.execute(truncate_table_query)
-
- insert_query = "INSERT INTO sqlitetest (name, age) VALUES (?, ?)"
- params = [("Alice", 25), ("Bob", 30)]
- sqlitecloud_connection.executemany(insert_query, params)
- sqlite3_connection.executemany(insert_query, params)
-
- select_query = "SELECT * FROM sqlitetest"
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
- sqlite3_cursor = sqlite3_connection.execute(select_query)
-
- sqlitecloud_results = sqlitecloud_cursor.fetchall()
- sqlite3_results = sqlite3_cursor.fetchall()
-
- assert sqlitecloud_results == sqlite3_results
-
- def test_execute_with_question_mark_style(
- self, sqlitecloud_dbapi2_connection, sqlite3_connection
- ):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- select_query = "SELECT * FROM albums WHERE AlbumId = ?"
- params = (1,)
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query, params)
- sqlite3_cursor = sqlite3_connection.execute(select_query, params)
-
- sqlitecloud_results = sqlitecloud_cursor.fetchall()
- sqlite3_results = sqlite3_cursor.fetchall()
-
- assert sqlitecloud_results == sqlite3_results
-
- def test_execute_with_named_param_style(
- self, sqlitecloud_dbapi2_connection, sqlite3_connection
- ):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- select_query = "SELECT * FROM albums WHERE AlbumId = :id"
- params = {"id": 1}
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query, params)
- sqlite3_cursor = sqlite3_connection.execute(select_query, params)
-
- sqlitecloud_results = sqlitecloud_cursor.fetchall()
- sqlite3_results = sqlite3_cursor.fetchall()
-
- assert sqlitecloud_results == sqlite3_results
-
- @pytest.mark.skip(
- reason="Rowcount does not contain the number of inserted rows yet"
- )
- def test_insert_result(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- insert_query = "INSERT INTO albums (Title, ArtistId) VALUES (?, ?)"
- params = ("Test Album", 1)
- sqlitecloud_cursor = sqlitecloud_connection.execute(insert_query, params)
- sqlite3_cursor = sqlite3_connection.execute(insert_query, params)
-
- assert sqlitecloud_cursor.rowcount == sqlite3_cursor.rowcount
-
- def test_close_cursor_raises_exception(
- self, sqlitecloud_dbapi2_connection, sqlite3_connection
- ):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- select_query = "SELECT 1"
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
- sqlite3_cursor = sqlite3_connection.execute(select_query)
-
- sqlitecloud_cursor.close()
- sqlite3_cursor.close()
-
- with pytest.raises(SQLiteCloudException) as e:
- sqlitecloud_cursor.fetchall()
-
- assert isinstance(e.value, SQLiteCloudException)
-
- with pytest.raises(sqlite3.ProgrammingError) as e:
- sqlite3_cursor.fetchall()
-
- def test_row_factory(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- def simple_factory(cursor, row):
- return {
- description[0]: row[i]
- for i, description in enumerate(cursor.description)
- }
-
- sqlitecloud_connection.row_factory = simple_factory
- sqlite3_connection.row_factory = simple_factory
-
- select_query = "SELECT * FROM albums WHERE AlbumId = 1"
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
- sqlite3_cursor = sqlite3_connection.execute(select_query)
-
- sqlitecloud_results = sqlitecloud_cursor.fetchall()
- sqlite3_results = sqlite3_cursor.fetchall()
-
- assert sqlitecloud_results == sqlite3_results
- assert sqlitecloud_results[0]["Title"] == sqlite3_results[0]["Title"]
-
- def test_description(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- select_query = "SELECT * FROM albums WHERE AlbumId = 1"
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
- sqlite3_cursor = sqlite3_connection.execute(select_query)
-
- assert sqlitecloud_cursor.description == sqlite3_cursor.description
- assert sqlitecloud_cursor.description[1][0] == "Title"
- assert sqlite3_cursor.description[1][0] == "Title"
-
- def test_fetch_one(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- select_query = "SELECT * FROM albums WHERE AlbumId = 1"
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
- sqlite3_cursor = sqlite3_connection.execute(select_query)
-
- sqlitecloud_result = sqlitecloud_cursor.fetchone()
- sqlite3_result = sqlite3_cursor.fetchone()
-
- assert sqlitecloud_result == sqlite3_result
-
- sqlitecloud_result = sqlitecloud_cursor.fetchone()
- sqlite3_result = sqlite3_cursor.fetchone()
-
- assert sqlitecloud_result is None
- assert sqlite3_result is None
-
- def test_fatchmany(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- select_query = "SELECT * FROM albums"
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
- sqlite3_cursor = sqlite3_connection.execute(select_query)
-
- sqlitecloud_results = sqlitecloud_cursor.fetchmany(2)
- sqlite3_results = sqlite3_cursor.fetchmany(2)
-
- assert len(sqlitecloud_results) == 2
- assert len(sqlite3_results) == 2
- assert sqlitecloud_results == sqlite3_results
-
- def test_fetchmany_more_then_available(
- self, sqlitecloud_dbapi2_connection, sqlite3_connection
- ):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- select_query = "SELECT * FROM albums LIMIT 3"
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
- sqlite3_cursor = sqlite3_connection.execute(select_query)
-
- sqlitecloud_results = sqlitecloud_cursor.fetchmany(100)
- sqlite3_results = sqlite3_cursor.fetchmany(100)
-
- assert sqlitecloud_results == sqlite3_results
- assert len(sqlitecloud_results) == 3
- assert len(sqlite3_results) == 3
-
- sqlitecloud_results = sqlitecloud_cursor.fetchmany(100)
- sqlite3_results = sqlite3_cursor.fetchmany(100)
-
- assert sqlitecloud_results == sqlite3_results
- assert len(sqlitecloud_results) == 0
- assert len(sqlite3_results) == 0
-
- def test_fetchall(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
- sqlitecloud_connection = sqlitecloud_dbapi2_connection
-
- select_query = "SELECT * FROM albums LIMIT 5"
- sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
- sqlite3_cursor = sqlite3_connection.execute(select_query)
-
- sqlitecloud_results = sqlitecloud_cursor.fetchall()
- sqlite3_results = sqlite3_cursor.fetchall()
-
- assert sqlitecloud_results == sqlite3_results
- assert len(sqlitecloud_results) == 5
- assert len(sqlite3_results) == 5
-
- sqlitecloud_results = sqlitecloud_cursor.fetchall()
- sqlite3_results = sqlite3_cursor.fetchall()
-
- assert sqlitecloud_results == sqlite3_results
- assert len(sqlitecloud_results) == 0
- assert len(sqlite3_results) == 0
diff --git a/src/tests/integration/test_sqlite_parity.py b/src/tests/integration/test_sqlite_parity.py
new file mode 100644
index 0000000..7459bcb
--- /dev/null
+++ b/src/tests/integration/test_sqlite_parity.py
@@ -0,0 +1,1336 @@
+import random
+import sqlite3
+import string
+import sys
+import time
+import uuid
+from datetime import date, datetime
+
+import pytest
+
+import sqlitecloud
+from sqlitecloud.exceptions import SQLiteCloudProgrammingError
+from tests.conftest import (
+ close_generator,
+ get_sqlite3_connection,
+ get_sqlitecloud_dbapi2_connection,
+)
+
+
+class TestSQLiteFeatureParity:
+ @pytest.mark.parametrize(
+ "connection, expected",
+ [
+ ("sqlitecloud_dbapi2_connection", SQLiteCloudProgrammingError),
+ ("sqlite3_connection", sqlite3.ProgrammingError),
+ ],
+ )
+ def test_connection_close(self, connection, expected, request):
+ connection = request.getfixturevalue(connection)
+
+ connection.close()
+
+ with pytest.raises(expected) as e:
+ connection.execute("SELECT 1")
+
+ assert isinstance(e.value, expected)
+
+ @pytest.mark.parametrize(
+ "connection, expected",
+ [
+ ("sqlitecloud_dbapi2_connection", SQLiteCloudProgrammingError),
+ ("sqlite3_connection", sqlite3.ProgrammingError),
+ ],
+ )
+ def test_cursor_close(self, connection, expected, request):
+ connection = request.getfixturevalue(connection)
+
+ cursor = connection.cursor()
+
+ cursor.close()
+
+ with pytest.raises(expected) as e:
+ cursor.execute("SELECT 1")
+
+ assert isinstance(e.value, expected)
+
+ @pytest.mark.parametrize(
+ "connection", ["sqlitecloud_dbapi2_connection", "sqlite3_connection"]
+ )
+ def test_ping_select(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ cursor = connection.execute("SELECT 1")
+
+ cursor = cursor.fetchall()
+
+ assert cursor == [(1,)]
+
+ @pytest.mark.parametrize(
+ "connection", ["sqlitecloud_dbapi2_connection", "sqlite3_connection"]
+ )
+ def test_create_table_and_insert_many(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ table = "sqlitetest" + str(random.randint(0, 99999))
+ try:
+ create_table_query = f"CREATE TABLE IF NOT EXISTS {table} (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"
+ connection.execute(create_table_query)
+
+ insert_query = f"INSERT INTO {table} (name, age) VALUES (?, ?)"
+ params = [("Alice", 25), ("Bob", 30)]
+ connection.executemany(insert_query, params)
+
+ select_query = f"SELECT * FROM {table}"
+ cursor = connection.execute(select_query)
+
+ results = cursor.fetchall()
+
+ assert len(results) == 2
+ assert results[0][1] == "Alice"
+ assert results[0][2] == 25
+ assert results[1][1] == "Bob"
+ assert results[1][2] == 30
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {table}")
+
+ @pytest.mark.parametrize(
+ "connection", ["sqlitecloud_dbapi2_connection", "sqlite3_connection"]
+ )
+ def test_executemany_with_a_iterator(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ class IterChars:
+ def __init__(self):
+ self.count = ord("a")
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ if self.count > ord("z"):
+ raise StopIteration
+ self.count += 1
+ return (chr(self.count - 1),)
+
+ try:
+ connection.execute("DROP TABLE IF EXISTS characters")
+ cursor = connection.execute("CREATE TABLE IF NOT EXISTS characters(c)")
+
+ theIter = IterChars()
+ cursor.executemany("INSERT INTO characters(c) VALUES (?)", theIter)
+
+ cursor.execute("SELECT c FROM characters")
+
+ results = cursor.fetchall()
+ assert len(results) == 26
+ finally:
+ connection.execute("DROP TABLE IF EXISTS characters")
+
+ @pytest.mark.parametrize(
+ "connection", ["sqlitecloud_dbapi2_connection", "sqlite3_connection"]
+ )
+ def test_executemany_with_yield_generator(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ def char_generator():
+ for c in string.ascii_lowercase:
+ yield (c,)
+
+ try:
+ connection.execute("DROP TABLE IF EXISTS characters")
+ cursor = connection.execute("CREATE TABLE IF NOT EXISTS characters(c)")
+
+ cursor.executemany("INSERT INTO characters(c) VALUES (?)", char_generator())
+
+ cursor.execute("SELECT c FROM characters")
+
+ results = cursor.fetchall()
+ assert len(results) == 26
+ finally:
+ connection.execute("DROP TABLE IF EXISTS characters")
+
+ def test_execute_with_question_mark_style(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ sqlitecloud_connection = sqlitecloud_dbapi2_connection
+
+ select_query = "SELECT * FROM albums WHERE AlbumId = ?"
+ params = (1,)
+ sqlitecloud_cursor = sqlitecloud_connection.execute(select_query, params)
+ sqlite3_cursor = sqlite3_connection.execute(select_query, params)
+
+ sqlitecloud_results = sqlitecloud_cursor.fetchall()
+ sqlite3_results = sqlite3_cursor.fetchall()
+
+ assert sqlitecloud_results == sqlite3_results
+
+ @pytest.mark.parametrize(
+ "connection", ["sqlitecloud_dbapi2_connection", "sqlite3_connection"]
+ )
+ def test_execute_with_named_param_style(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ select_query = "SELECT * FROM albums WHERE AlbumId = :id and Title = :title and AlbumId = :id"
+ params = {"id": 1, "title": "For Those About To Rock We Salute You"}
+
+ cursor = connection.execute(select_query, params)
+
+ results = cursor.fetchall()
+
+ assert len(results) == 1
+ assert results[0][0] == 1
+
+ @pytest.mark.parametrize(
+ "connection", ["sqlitecloud_dbapi2_connection", "sqlite3_connection"]
+ )
+ def test_executemany_with_named_param_style(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ select_query = "INSERT INTO customers (FirstName, Email, LastName) VALUES (:name, :email, :name)"
+ params = [
+ {"name": "pippo", "email": "pippo@disney.com"},
+ {"name": "pluto", "email": "pluto@disney.com"},
+ ]
+
+ connection.executemany(select_query, params)
+
+ assert connection.total_changes == 2
+
+ def test_insert_result(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
+ sqlitecloud_connection = sqlitecloud_dbapi2_connection
+
+ insert_query = "INSERT INTO albums (Title, ArtistId) VALUES (?, ?)"
+ params = ("Test Album", 1)
+ sqlitecloud_cursor = sqlitecloud_connection.execute(insert_query, params)
+ sqlite3_cursor = sqlite3_connection.execute(insert_query, params)
+
+ assert sqlitecloud_cursor.rowcount == sqlite3_cursor.rowcount
+
+ def test_close_cursor_raises_exception(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ sqlitecloud_connection = sqlitecloud_dbapi2_connection
+
+ select_query = "SELECT 1"
+ sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
+ sqlite3_cursor = sqlite3_connection.execute(select_query)
+
+ sqlitecloud_cursor.close()
+ sqlite3_cursor.close()
+
+ with pytest.raises(SQLiteCloudProgrammingError) as e:
+ sqlitecloud_cursor.fetchall()
+
+ assert isinstance(e.value, SQLiteCloudProgrammingError)
+
+ with pytest.raises(sqlite3.ProgrammingError) as e:
+ sqlite3_cursor.fetchall()
+
+ @pytest.mark.parametrize(
+ "connection", ["sqlitecloud_dbapi2_connection", "sqlite3_connection"]
+ )
+ def test_row_factory(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ def simple_factory(cursor, row):
+ return {
+ description[0]: row[i]
+ for i, description in enumerate(cursor.description)
+ }
+
+ connection.row_factory = simple_factory
+
+ select_query = "SELECT AlbumId, Title, ArtistId FROM albums WHERE AlbumId = 1"
+ cursor = connection.execute(select_query)
+
+ results = cursor.fetchall()
+
+ assert results[0]["AlbumId"] == 1
+ assert results[0]["Title"] == "For Those About To Rock We Salute You"
+ assert results[0]["ArtistId"] == 1
+ assert connection.row_factory == cursor.row_factory
+
+ @pytest.mark.parametrize(
+ "connection", ["sqlitecloud_dbapi2_connection", "sqlite3_connection"]
+ )
+ def test_cursor_row_factory_as_instance_variable(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ cursor = connection.execute("SELECT 1")
+ cursor.row_factory = lambda c, r: list(r)
+
+ cursor2 = connection.execute("SELECT 1")
+
+ assert cursor.row_factory != cursor2.row_factory
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ ("sqlitecloud_dbapi2_connection", sqlitecloud),
+ ("sqlite3_connection", sqlite3),
+ ],
+ )
+ def test_row_factory_with_row_object(self, connection, module, request):
+ connection = request.getfixturevalue(connection)
+
+ connection.row_factory = module.Row
+
+ select_query = "SELECT AlbumId, Title, ArtistId FROM albums WHERE AlbumId = 1"
+ cursor = connection.execute(select_query)
+
+ row = cursor.fetchone()
+
+ assert row["AlbumId"] == 1
+ assert row["Title"] == "For Those About To Rock We Salute You"
+ assert row[1] == row["Title"]
+ assert row["Title"] == row["title"]
+ assert row.keys() == ["AlbumId", "Title", "ArtistId"]
+ assert len(row) == 3
+ assert next(iter(row)) == 1 # AlbumId
+ assert not row != row
+ assert row == row
+
+ cursor = connection.execute(
+ "SELECT AlbumId, Title, ArtistId FROM albums WHERE AlbumId = 2"
+ )
+ other_row = cursor.fetchone()
+
+ assert row != other_row
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_description(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ select_query = "SELECT AlbumId, Title, ArtistId FROM albums WHERE AlbumId = 1"
+ cursor = connection.execute(select_query)
+
+ assert cursor.description[0][0] == "AlbumId"
+ assert cursor.description[1][0] == "Title"
+ assert cursor.description[2][0] == "ArtistId"
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ @pytest.mark.parametrize(
+ "value",
+ [
+ ("'hello world'", "'hello world'"),
+ ('"hello" "world"', "world"),
+ ('"hello" "my world"', "my world"),
+ ],
+ )
+ def test_cursor_description_with_column_alias(self, connection, value, request):
+ connection = request.getfixturevalue(connection)
+
+ cursor = connection.execute(f"SELECT {value[0]}")
+
+ assert cursor.description[0][0] == value[1]
+
+ # Only for py3.6
+ @pytest.mark.skipif(
+ sys.version_info >= (3, 7), reason="Different behavior in py>=3.7"
+ )
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ @pytest.mark.parametrize(
+ "value",
+ [
+ ('"hello" as "world [sphere]"', "world"),
+ ('"hello" as "my world [sphere]"', "my world"),
+ ('"hello" "world [sphere]"', "world"),
+ ],
+ )
+ def test_cursor_description_with_explicit_decltype_regardless_detect_type(
+ self, connection, value, request
+ ):
+ """In py3.6 the `[decltype]` in column name is always parsed regardless the PARSE_COLNAMES.
+ See bpo-39652 https://github.com/python/cpython/issues/83833"""
+
+ connection = request.getfixturevalue(connection)
+
+ cursor = connection.execute(f"SELECT {value[0]}")
+
+ assert cursor.description[0][0] == value[1]
+
+ # Only for py>=3.7
+ @pytest.mark.skipif(sys.version_info < (3, 7), reason="Different behavior in py3.6")
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ @pytest.mark.parametrize(
+ "value, expected, parse_colnames",
+ [
+ ('"hello" as "world [sphere]"', "world", True),
+ ('"hello" as "my world [sphere]"', "my world", True),
+ ('"hello" "world [sphere]"', "world", True),
+ ('"hello" as "world [sphere]"', "world [sphere]", False),
+ ('"hello" as "my world [sphere]"', "my world [sphere]", False),
+ ('"hello" "world [sphere]"', "world [sphere]", False),
+ ],
+ )
+ def test_cursor_description_with_explicit_decltype(
+ self, connection, module, value, expected, parse_colnames
+ ):
+ """Since py3.7 the parsed of `[decltype]` disabled when PARSE_COLNAMES.
+ See bpo-39652 https://github.com/python/cpython/issues/83833"""
+ if parse_colnames:
+ connection_gen = connection(module.PARSE_COLNAMES)
+ else:
+ connection_gen = connection()
+
+ connection = next(connection_gen)
+
+ try:
+ cursor = connection.execute(f"SELECT {value}")
+
+ assert cursor.description[0][0] == expected
+ finally:
+ close_generator(connection_gen)
+
+ def test_fetch_one(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
+ sqlitecloud_connection = sqlitecloud_dbapi2_connection
+
+ select_query = "SELECT * FROM albums WHERE AlbumId = 1"
+ sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
+ sqlite3_cursor = sqlite3_connection.execute(select_query)
+
+ sqlitecloud_result = sqlitecloud_cursor.fetchone()
+ sqlite3_result = sqlite3_cursor.fetchone()
+
+ assert sqlitecloud_result == sqlite3_result
+
+ sqlitecloud_result = sqlitecloud_cursor.fetchone()
+ sqlite3_result = sqlite3_cursor.fetchone()
+
+ assert sqlitecloud_result is None
+ assert sqlite3_result is None
+
+ def test_fatchmany(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
+ sqlitecloud_connection = sqlitecloud_dbapi2_connection
+
+ select_query = "SELECT * FROM albums"
+ sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
+ sqlite3_cursor = sqlite3_connection.execute(select_query)
+
+ sqlitecloud_results = sqlitecloud_cursor.fetchmany(2)
+ sqlite3_results = sqlite3_cursor.fetchmany(2)
+
+ assert len(sqlitecloud_results) == 2
+ assert len(sqlite3_results) == 2
+ assert sqlitecloud_results == sqlite3_results
+
+ def test_fetchmany_more_then_available(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ sqlitecloud_connection = sqlitecloud_dbapi2_connection
+
+ select_query = "SELECT * FROM albums LIMIT 3"
+ sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
+ sqlite3_cursor = sqlite3_connection.execute(select_query)
+
+ sqlitecloud_results = sqlitecloud_cursor.fetchmany(100)
+ sqlite3_results = sqlite3_cursor.fetchmany(100)
+
+ assert sqlitecloud_results == sqlite3_results
+ assert len(sqlitecloud_results) == 3
+ assert len(sqlite3_results) == 3
+
+ sqlitecloud_results = sqlitecloud_cursor.fetchmany(100)
+ sqlite3_results = sqlite3_cursor.fetchmany(100)
+
+ assert sqlitecloud_results == sqlite3_results
+ assert len(sqlitecloud_results) == 0
+ assert len(sqlite3_results) == 0
+
+ def test_fetchall(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
+ sqlitecloud_connection = sqlitecloud_dbapi2_connection
+
+ select_query = "SELECT * FROM albums LIMIT 5"
+ sqlitecloud_cursor = sqlitecloud_connection.execute(select_query)
+ sqlite3_cursor = sqlite3_connection.execute(select_query)
+
+ sqlitecloud_results = sqlitecloud_cursor.fetchall()
+ sqlite3_results = sqlite3_cursor.fetchall()
+
+ assert sqlitecloud_results == sqlite3_results
+ assert len(sqlitecloud_results) == 5
+ assert len(sqlite3_results) == 5
+
+ sqlitecloud_results = sqlitecloud_cursor.fetchall()
+ sqlite3_results = sqlite3_cursor.fetchall()
+
+ assert sqlitecloud_results == sqlite3_results
+ assert len(sqlitecloud_results) == 0
+ assert len(sqlite3_results) == 0
+
+ def test_commit_without_any_transaction_does_not_raise_exception(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ for connection in [sqlitecloud_dbapi2_connection, sqlite3_connection]:
+ connection.commit()
+
+ assert True
+
+ def test_rollback_without_any_transaction_does_not_raise_exception(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ for connection in [sqlitecloud_dbapi2_connection, sqlite3_connection]:
+ connection.rollback()
+
+ assert True
+
+ def test_autocommit_mode_enabled_by_default(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ seed = str(int(time.time()))
+
+ sqlitecloud_conn_gen = get_sqlitecloud_dbapi2_connection()
+ sqlite_conn_gen = get_sqlite3_connection()
+
+ connections = [
+ (sqlitecloud_dbapi2_connection, next(sqlitecloud_conn_gen)),
+ (sqlite3_connection, next(sqlite_conn_gen)),
+ ]
+
+ try:
+ for (connection, control_connection) in connections:
+ connection.execute(
+ "INSERT INTO albums (Title, ArtistId) VALUES (? , 1);",
+ (f"Test {seed}",),
+ )
+
+ cursor2 = control_connection.execute(
+ "SELECT * FROM albums WHERE Title = ?", (f"Test {seed}",)
+ )
+ assert cursor2.fetchone() is not None
+ finally:
+ close_generator(sqlitecloud_conn_gen)
+ close_generator(sqlite_conn_gen)
+
+ def test_explicit_transaction_to_commit(
+ self,
+ sqlitecloud_dbapi2_connection: sqlitecloud.Connection,
+ sqlite3_connection: sqlite3.Connection,
+ ):
+ seed = str(uuid.uuid4())
+
+ sqlitecloud_conn_gen = get_sqlitecloud_dbapi2_connection()
+ sqlite_conn_gen = get_sqlite3_connection()
+
+ connections = [
+ (sqlitecloud_dbapi2_connection, next(sqlitecloud_conn_gen)),
+ (sqlite3_connection, next(sqlite_conn_gen)),
+ ]
+
+ try:
+ for (connection, control_connection) in connections:
+ cursor1 = connection.execute("BEGIN;")
+ cursor1.execute(
+ "INSERT INTO albums (Title, ArtistId) VALUES (?, 1);",
+ (f"Test {seed}",),
+ )
+
+ cursor2 = control_connection.execute(
+ "SELECT * FROM albums WHERE Title = ?", (f"Test {seed}",)
+ )
+ assert cursor2.fetchone() is None
+
+ connection.commit()
+
+ cursor2.execute(
+ "SELECT * FROM albums WHERE Title = ?", (f"Test {seed}",)
+ )
+ assert cursor2.fetchone() is not None
+ finally:
+ close_generator(sqlitecloud_conn_gen)
+ close_generator(sqlite_conn_gen)
+
+ def test_explicit_transaction_to_rollback(
+ self,
+ sqlitecloud_dbapi2_connection: sqlitecloud.Connection,
+ sqlite3_connection: sqlite3.Connection,
+ ):
+ seed = str(int(time.time()))
+
+ for connection in [sqlitecloud_dbapi2_connection, sqlite3_connection]:
+ cursor1 = connection.execute("BEGIN;")
+ cursor1.execute(
+ "INSERT INTO albums (Title, ArtistId) VALUES (?, 1);", (f"Test {seed}",)
+ )
+
+ cursor1.execute("SELECT * FROM albums WHERE Title = ?", (f"Test {seed}",))
+ assert cursor1.fetchone() is not None
+
+ connection.rollback()
+
+ cursor1.execute("SELECT * FROM albums WHERE Title = ?", (f"Test {seed}",))
+ assert cursor1.fetchone() is None
+
+ def test_text_factory_with_default_string(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ for connection in [sqlitecloud_dbapi2_connection, sqlite3_connection]:
+ # by default is string: connection.text_factory = str
+
+ austria = "\xd6sterreich"
+
+ cursor = connection.execute("SELECT ?", (austria,))
+ result = cursor.fetchone()
+
+ assert result[0] == austria
+
+ def test_text_factory_with_bytes(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ for connection in [sqlitecloud_dbapi2_connection, sqlite3_connection]:
+ connection.text_factory = bytes
+
+ austria = "\xd6sterreich"
+
+ cursor = connection.execute("SELECT ?", (austria,))
+ result = cursor.fetchone()
+
+ assert type(result[0]) is bytes
+ assert result[0] == austria.encode("utf-8")
+
+ def test_text_factory_with_callable(
+ self, sqlitecloud_dbapi2_connection, sqlite3_connection
+ ):
+ for connection in [sqlitecloud_dbapi2_connection, sqlite3_connection]:
+ connection.text_factory = lambda x: x.decode("utf-8") + "Foo"
+
+ cursor = connection.execute("SELECT ?", ("bar",))
+ result = cursor.fetchone()
+
+ assert result[0] == "barFoo"
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_apply_text_factory_to_int_value_with_text_decltype(
+ self, connection, request
+ ):
+ """Expect the text_factory to be applied when the inserted
+ value is an integer but the declared type for the column is TEXT."""
+ connection = request.getfixturevalue(connection)
+ connection.text_factory = bytes
+
+ tableName = "TestTextFactory" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.execute(f"CREATE TABLE {tableName}(p TEXT)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", (15,))
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert result[0] == b"15"
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_not_apply_text_factory_to_string_value_without_text_decltype(
+ self, connection, request
+ ):
+ """Expect the text_factory to be not applied when the inserted
+ value is a string but the declared type for the column is not TEXT."""
+
+ connection = request.getfixturevalue(connection)
+ connection.text_factory = bytes
+
+ tableName = "TestTextFactory" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.execute(f"CREATE TABLE {tableName}(p INTEGER)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", ("15",))
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert result[0] == 15
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ ("sqlitecloud_dbapi2_connection", sqlitecloud),
+ ("sqlite3_connection", sqlite3),
+ ],
+ )
+ def test_register_adapter(self, connection, module, request):
+ connection = request.getfixturevalue(connection)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def adapt_point(point):
+ return f"{point.x}, {point.y}"
+
+ module.register_adapter(Point, adapt_point)
+
+ p = Point(4.0, -3.2)
+
+ cursor = connection.execute("SELECT ?", (p,))
+
+ result = cursor.fetchone()
+
+ assert result[0] == "4.0, -3.2"
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ ("sqlitecloud_dbapi2_connection", sqlitecloud),
+ ("sqlite3_connection", sqlite3),
+ ],
+ )
+ def test_register_adapter_and_executemany(self, connection, module, request):
+ connection = request.getfixturevalue(connection)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def adapt_point(point):
+ return f"{point.x}, {point.y}"
+
+ module.register_adapter(Point, adapt_point)
+
+ p1 = Point(4.0, -3.2)
+ p2 = Point(2.1, 1.9)
+
+ tableName = "TestParseDeclTypes" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.execute(f"CREATE TABLE {tableName}(p)")
+
+ cursor.executemany(f"INSERT INTO {tableName}(p) VALUES (?)", [(p1,), (p2,)])
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchall()
+
+ assert result[0][0] == "4.0, -3.2"
+ assert result[1][0] == "2.1, 1.9"
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ ("sqlitecloud_dbapi2_connection", sqlitecloud),
+ ("sqlite3_connection", sqlite3),
+ ],
+ )
+ def test_register_adapter_on_dict_parameters(self, connection, module, request):
+ connection = request.getfixturevalue(connection)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def adapt_point(point):
+ return f"{point.x}, {point.y}"
+
+ module.register_adapter(Point, adapt_point)
+
+ p = Point(4.0, -3.2)
+
+ cursor = connection.execute("SELECT :point", {"point": p})
+
+ result = cursor.fetchone()
+
+ assert result[0] == "4.0, -3.2"
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_adapter_date(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ today = date.today()
+ cursor = connection.execute("SELECT ?", (today,))
+
+ result = cursor.fetchone()
+
+ assert result[0] == today.isoformat()
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_adapter_datetime(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ now = datetime.now()
+ cursor = connection.execute("SELECT ?", (now,))
+
+ result = cursor.fetchone()
+
+ assert result[0] == now.isoformat(" ")
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ ("sqlitecloud_dbapi2_connection", sqlitecloud),
+ ("sqlite3_connection", sqlite3),
+ ],
+ )
+ def test_custom_adapter_datetime(self, connection, module, request):
+ connection = request.getfixturevalue(connection)
+
+ def adapt_datetime(ts):
+ return time.mktime(ts.timetuple())
+
+ module.register_adapter(datetime, adapt_datetime)
+
+ now = datetime.now()
+ cursor = connection.execute("SELECT ?", (now,))
+
+ result = cursor.fetchone()
+
+ assert result[0] == adapt_datetime(now)
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_conform_object(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def __conform__(self, protocol):
+ if isinstance(connection, sqlitecloud.Connection):
+ assert protocol is None
+ elif isinstance(connection, sqlite3.Connection):
+ assert protocol is sqlite3.PrepareProtocol
+ else:
+ pytest.fail("Unknown connection type")
+
+ return f"{self.x};{self.y}"
+
+ p = Point(4.0, -3.2)
+ cursor = connection.execute("SELECT ?", (p,))
+
+ result = cursor.fetchone()
+
+ assert result[0] == "4.0;-3.2"
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ ("sqlitecloud_dbapi2_connection", sqlitecloud),
+ ("sqlite3_connection", sqlite3),
+ ],
+ )
+ def test_adapters_to_have_precedence_over_conform_object(
+ self, connection, module, request
+ ):
+ connection = request.getfixturevalue(connection)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def __conform__(self, protocol):
+ # 4.0;1.1
+ return f"{self.x};{self.y}"
+
+ def adapt_point(point):
+ # 4.0, 1.1
+ return f"{point.x}, {point.y}"
+
+ module.register_adapter(Point, adapt_point)
+
+ p = Point(4.0, -3.2)
+ cursor = connection.execute("SELECT ?", (p,))
+
+ result = cursor.fetchone()
+
+ assert result[0] == "4.0, -3.2"
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (
+ get_sqlitecloud_dbapi2_connection,
+ sqlitecloud,
+ ),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_parse_decltypes(self, connection, module):
+ connection_gen = connection(module.PARSE_DECLTYPES)
+
+ connection = next(connection_gen)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def __repr__(self):
+ return f"{self.x};{self.y}"
+
+ def convert_point(s):
+ x, y = list(map(float, s.split(b";")))
+ return Point(x, y)
+
+ module.register_converter("point", convert_point)
+
+ p = Point(4.0, -3.2)
+
+ tableName = "TestParseDeclTypes" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.execute(f"CREATE TABLE {tableName}(p point)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", (str(p),))
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert isinstance(result[0], Point)
+ assert result[0].x == p.x
+ assert result[0].y == p.y
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+ close_generator(connection_gen)
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_parse_decltypes_when_decltype_is_not_registered(self, connection, module):
+ connection = next(connection(module.PARSE_DECLTYPES))
+
+ tableName = "TestParseDeclTypes" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.execute(f"CREATE TABLE {tableName}(p point)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", ("1.0,2.0",))
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert result[0] == "1.0,2.0"
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_register_converter_case_insensitive(self, connection, module):
+ conn_gen = connection(module.PARSE_DECLTYPES)
+ connection = next(conn_gen)
+
+ module.register_converter("integer", lambda x: int(x.decode("utf-8")) + 7)
+
+ mynumber = 10
+
+ tableName = "TestParseDeclTypes" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.execute(f"CREATE TABLE {tableName}(p INTEGER)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", (mynumber,))
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert result[0] == 17
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+ close_generator(conn_gen)
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_registered_converter_on_text_decltype_replaces_text_factory(
+ self, connection, module
+ ):
+ """Expect the registered converter to the TEXT decltype to be used in place of the text_factory."""
+ conn_gen = connection(module.PARSE_DECLTYPES)
+ connection = next(conn_gen)
+
+ module.register_converter("TEXT", lambda x: x.decode("utf-8") + "Foo")
+ connection.text_factory = bytes
+
+ pippo = "Pippo"
+
+ tableName = "TestParseDeclTypes" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.execute(f"CREATE TABLE {tableName}(p TEXT)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", (pippo,))
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert result[0] == pippo + "Foo"
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+ close_generator(conn_gen)
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_parse_native_decltype(self, connection, module):
+ conn_gen = connection(module.PARSE_DECLTYPES)
+ connection = next(conn_gen)
+
+ module.register_converter("INTEGER", lambda x: int(x.decode("utf-8")) + 10)
+
+ mynumber = 10
+
+ tableName = "TestParseDeclTypes" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.execute(f"CREATE TABLE {tableName}(p INTEGER)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", (mynumber,))
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert result[0] == 20
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+ close_generator(conn_gen)
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_register_adapters_and_converters_for_date_and_datetime_by_default(
+ self, connection, module
+ ):
+ conn_gen = connection(module.PARSE_DECLTYPES)
+ connection = next(conn_gen)
+
+ tableName = "TestParseDeclTypes" + str(random.randint(0, 99999))
+ try:
+ today = date.today()
+ now = datetime.now()
+
+ cursor = connection.execute(
+ f"CREATE TABLE {tableName}(d DATE, t timestamp)"
+ )
+
+ cursor.execute(
+ f"INSERT INTO {tableName}(d, t) VALUES (:date, :timestamp)",
+ {"date": today, "timestamp": now},
+ )
+ cursor.execute(f"SELECT d, t FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert isinstance(result[0], date)
+ assert isinstance(result[1], datetime)
+ assert result[0] == today
+ assert result[1] == now
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+ close_generator(conn_gen)
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_adapt_and_convert_custom_decltype(self, connection, module):
+ conn_gen = connection(module.PARSE_DECLTYPES)
+ connection = next(conn_gen)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def adapt_point(point):
+ return f"{point.x};{point.y}".encode("ascii")
+
+ def convert_point(s):
+ x, y = list(map(float, s.split(b";")))
+ return Point(x, y)
+
+ module.register_adapter(Point, adapt_point)
+ module.register_converter("point", convert_point)
+
+ p = Point(4.0, -3.2)
+
+ tableName = "TestParseDeclTypes" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.cursor()
+ cursor.execute(f"CREATE TABLE {tableName}(p point)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", (p,))
+ cursor.execute(f"SELECT p FROM {tableName}")
+
+ result = cursor.fetchone()
+
+ assert isinstance(result[0], Point)
+ assert result[0].x == p.x
+ assert result[0].y == p.y
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+ close_generator(conn_gen)
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_parse_colnames(self, connection, module):
+ conn_gen = connection(module.PARSE_COLNAMES)
+ connection = next(conn_gen)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def __repr__(self):
+ return f"{self.x};{self.y}"
+
+ def convert_point(s):
+ x, y = list(map(float, s.split(b";")))
+ return Point(x, y)
+
+ module.register_converter("point", convert_point)
+
+ p = Point(4.0, -3.2)
+
+ try:
+ cursor = connection.execute('SELECT ? as "p i [point]"', (str(p),))
+
+ result = cursor.fetchone()
+
+ assert isinstance(result[0], Point)
+ assert result[0].x == p.x
+ assert result[0].y == p.y
+ finally:
+ close_generator(conn_gen)
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_parse_colnames_first_and_then_decltypes(self, connection, module):
+ """Expect the PARSE_COLNAMES to have priority over PARSE_DECLTYPES."""
+ conn_gen = connection(module.PARSE_DECLTYPES | module.PARSE_COLNAMES)
+ connection = next(conn_gen)
+
+ class Point:
+ def __init__(self, x, y):
+ self.x, self.y = x, y
+
+ def __repr__(self):
+ return f"{self.x};{self.y}"
+
+ def convert_point(s):
+ x, y = list(map(float, s.split(b";")))
+ return Point(x, y)
+
+ def convert_coordinate(c):
+ x, y = list(map(float, c.split(b";")))
+ return f"lat: {x}, lng: {y}"
+
+ module.register_converter("point", convert_point)
+ module.register_converter("coordinate", convert_coordinate)
+
+ p = Point(4.0, -3.2)
+
+ tableName = "TestParseColnames" + str(random.randint(0, 99999))
+ try:
+ cursor = connection.cursor()
+ cursor.execute(f"CREATE TABLE {tableName}(p point)")
+
+ cursor.execute(f"INSERT INTO {tableName}(p) VALUES (?)", (str(p),))
+ cursor.execute(f'SELECT p, p "lat lng [coordinate]" FROM {tableName}')
+
+ result = cursor.fetchone()
+
+ assert isinstance(result[0], Point)
+ assert result[0].x == p.x
+ assert result[0].y == p.y
+ assert result[1] == "lat: 4.0, lng: -3.2"
+ finally:
+ connection.execute(f"DROP TABLE IF EXISTS {tableName}")
+ close_generator(conn_gen)
+
+ @pytest.mark.parametrize(
+ "connection, module",
+ [
+ (get_sqlitecloud_dbapi2_connection, sqlitecloud),
+ (get_sqlite3_connection, sqlite3),
+ ],
+ )
+ def test_parse_colnames_and_decltypes_when_both_are_not_specified(
+ self, connection, module
+ ):
+ conn_gen = connection(module.PARSE_DECLTYPES | module.PARSE_COLNAMES)
+ connection = next(conn_gen)
+
+ cursor = connection.cursor()
+
+ try:
+ cursor.execute('SELECT 12, 25 "lat lng [coordinate]"')
+
+ result = cursor.fetchone()
+
+ assert result[0] == 12
+ assert result[1] == 25
+ finally:
+ close_generator(conn_gen)
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_transaction_context_manager_on_success(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ connection.execute("BEGIN")
+ with connection:
+ cursor = connection.execute(
+ "INSERT INTO albums (Title, ArtistId) VALUES ('Test Album 1', 1)"
+ )
+ id1 = cursor.lastrowid
+ cursor = connection.execute(
+ "INSERT INTO albums (Title, ArtistId) VALUES ('Test Album 2', 1)"
+ )
+ id2 = cursor.lastrowid
+
+ cursor = connection.execute(
+ "SELECT * FROM albums WHERE AlbumId IN (?, ?)", (id1, id2)
+ )
+ result = cursor.fetchall()
+
+ assert len(result) == 2
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_transaction_context_manager_on_failure(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ try:
+ connection.execute("BEGIN")
+ with connection:
+ cursor = connection.execute(
+ "INSERT INTO albums (Title, ArtistId) VALUES ('Test Album 1', 1)"
+ )
+ id1 = cursor.lastrowid
+ connection.execute("insert into pippodd (p) values (1)")
+ except Exception:
+ assert True
+
+ cursor = connection.execute("SELECT * FROM albums WHERE AlbumId = ?", (id1,))
+ result = cursor.fetchone()
+
+ assert result is None
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ "sqlitecloud_dbapi2_connection",
+ "sqlite3_connection",
+ ],
+ )
+ def test_connection_total_changes(self, connection, request):
+ connection = request.getfixturevalue(connection)
+
+ new_name1 = "Jazz" + str(uuid.uuid4())
+ new_name2 = "Jazz" + str(uuid.uuid4())
+ new_name3 = "Jazz" + str(uuid.uuid4())
+
+ connection.executemany(
+ "INSERT INTO genres (Name) VALUES (?)",
+ [(new_name1,), (new_name2,)],
+ )
+ assert connection.total_changes == 2
+
+ connection.execute(
+ "SELECT * FROM genres WHERE Name IN (?, ?)", (new_name1, new_name2)
+ )
+ assert connection.total_changes == 2
+
+ connection.execute(
+ "UPDATE genres SET Name = ? WHERE Name = ?", (new_name3, new_name1)
+ )
+ assert connection.total_changes == 3
+
+ connection.execute(
+ "DELETE FROM genres WHERE Name in (?, ?, ?)",
+ (new_name1, new_name2, new_name3),
+ )
+ assert connection.total_changes == 5
diff --git a/src/tests/unit/test_dbapi2.py b/src/tests/unit/test_dbapi2.py
index 2ab873b..51bc977 100644
--- a/src/tests/unit/test_dbapi2.py
+++ b/src/tests/unit/test_dbapi2.py
@@ -3,13 +3,9 @@
import sqlitecloud
from sqlitecloud import Cursor
-from sqlitecloud.datatypes import (
- SQLiteCloudAccount,
- SQLiteCloudConfig,
- SQLiteCloudException,
-)
+from sqlitecloud.datatypes import SQLiteCloudAccount, SQLiteCloudConfig
from sqlitecloud.dbapi2 import Connection
-from sqlitecloud.driver import Driver
+from sqlitecloud.exceptions import SQLiteCloudProgrammingError
from sqlitecloud.resultset import SQLITECLOUD_RESULT_TYPE, SQLiteCloudResult
@@ -104,45 +100,6 @@ def test_rowcount_with_no_resultset(self, mocker):
assert cursor.rowcount == -1
- def test_execute_escaped(self, mocker: MockerFixture):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
- execute_mock = mocker.patch.object(Driver, "execute")
-
- sql = "SELECT * FROM users WHERE name = ?"
- parameters = ("John's",)
-
- cursor.execute(sql, parameters)
-
- assert (
- execute_mock.call_args[0][0] == "SELECT * FROM users WHERE name = 'John''s'"
- )
-
- def test_executemany(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
- execute_mock = mocker.patch.object(cursor, "execute")
-
- sql = "INSERT INTO users (name, age) VALUES (?, ?)"
- seq_of_parameters = [("John", 25), ("Jane", 30), ("Bob", 40)]
-
- cursor.executemany(sql, seq_of_parameters)
-
- execute_mock.assert_called_once_with(
- "INSERT INTO users (name, age) VALUES ('John', 25);INSERT INTO users (name, age) VALUES ('Jane', 30);INSERT INTO users (name, age) VALUES ('Bob', 40);"
- )
-
- def test_executemany_escaped(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
- execute_mock = mocker.patch.object(cursor, "execute")
-
- sql = "INSERT INTO users (name, age) VALUES (?, ?)"
- seq_of_parameters = [("O'Conner", 25)]
-
- cursor.executemany(sql, seq_of_parameters)
-
- execute_mock.assert_called_once_with(
- "INSERT INTO users (name, age) VALUES ('O''Conner', 25);"
- )
-
def test_fetchone_with_no_resultset(self, mocker):
cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
@@ -157,7 +114,10 @@ def test_fetchone_with_result(self, mocker):
assert cursor.fetchone() is None
def test_fetchone_with_rowset(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
+ connection = mocker.patch("sqlitecloud.Connection")
+ connection.text_factory = str
+
+ cursor = Cursor(connection)
result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
result.ncols = 1
@@ -195,7 +155,10 @@ def test_fetchmany_with_result(self, mocker):
assert cursor.fetchmany() == []
def test_fetchmany_with_rowset_and_default_size(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
+ connection = mocker.patch("sqlitecloud.Connection")
+ connection.text_factory = str
+
+ cursor = Cursor(connection)
result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
result.ncols = 1
@@ -207,7 +170,10 @@ def test_fetchmany_with_rowset_and_default_size(self, mocker):
assert cursor.fetchmany(None) == [("myname1",)]
def test_fetchmany_twice_to_retrieve_whole_rowset(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
+ connection = mocker.patch("sqlitecloud.Connection")
+ connection.text_factory = str
+
+ cursor = Cursor(connection)
result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
result.ncols = 1
@@ -220,7 +186,10 @@ def test_fetchmany_twice_to_retrieve_whole_rowset(self, mocker):
assert cursor.fetchmany() == []
def test_fetchmany_with_size_higher_than_rowcount(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
+ connection = mocker.patch("sqlitecloud.Connection")
+ connection.text_factory = str
+
+ cursor = Cursor(connection)
result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
result.ncols = 1
@@ -245,7 +214,10 @@ def test_fetchall_with_result(self, mocker):
assert cursor.fetchall() == []
def test_fetchall_with_rowset(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
+ connection = mocker.patch("sqlitecloud.Connection")
+ connection.text_factory = str
+
+ cursor = Cursor(connection)
result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
result.ncols = 1
@@ -257,7 +229,10 @@ def test_fetchall_with_rowset(self, mocker):
assert cursor.fetchall() == [("myname1",), ("myname2",), ("myname3",)]
def test_fetchall_twice_and_expect_empty_list(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
+ connection = mocker.patch("sqlitecloud.Connection")
+ connection.text_factory = str
+
+ cursor = Cursor(connection)
result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
result.ncols = 1
@@ -270,7 +245,10 @@ def test_fetchall_twice_and_expect_empty_list(self, mocker):
assert cursor.fetchall() == []
def test_fetchall_to_return_remaining_rows(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
+ connection = mocker.patch("sqlitecloud.Connection")
+ connection.text_factory = str
+
+ cursor = Cursor(connection)
result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
result.ncols = 1
@@ -283,7 +261,10 @@ def test_fetchall_to_return_remaining_rows(self, mocker):
assert cursor.fetchall() == [("myname2",)]
def test_iterator(self, mocker):
- cursor = Cursor(mocker.patch("sqlitecloud.Connection"))
+ connection = mocker.patch("sqlitecloud.Connection")
+ connection.text_factory = str
+
+ cursor = Cursor(connection)
result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
result.ncols = 1
@@ -327,7 +308,7 @@ def test_close_raises_expected_exception_on_any_further_operation(
cursor.close()
- with pytest.raises(SQLiteCloudException) as e:
+ with pytest.raises(SQLiteCloudProgrammingError) as e:
getattr(cursor, method)(*args)
assert e.value.args[0] == "The cursor is closed."
diff --git a/src/tests/unit/test_driver.py b/src/tests/unit/test_driver.py
index bb713e7..8e09d70 100644
--- a/src/tests/unit/test_driver.py
+++ b/src/tests/unit/test_driver.py
@@ -37,11 +37,11 @@ def test_parse_number(self, number_data):
("+11 Hello World", "Hello World", 11, 15),
("!6 Hello0", "Hello", 5, 9),
("+0 ", "", 0, 3),
- (":5678 ", "5678", 0, 6),
- (":0 ", "0", 0, 3),
- (",3.14 ", "3.14", 0, 6),
- (",0 ", "0", 0, 3),
- (",0.0 ", "0.0", 0, 5),
+ (":5678 ", 5678, 0, 6),
+ (":0 ", 0, 0, 3),
+ (",3.14 ", 3.14, 0, 6),
+ (",0 ", 0, 0, 3),
+ (",0.0 ", 0.0, 0, 5),
("_ ", None, 0, 2),
],
ids=[
@@ -73,7 +73,7 @@ def test_parse_value(self, value_data):
def test_parse_array(self):
driver = Driver()
buffer = b"=5 +11 Hello World:123456 ,3.1415 _ $10 0123456789"
- expected_list = ["Hello World", "123456", "3.1415", None, "0123456789"]
+ expected_list = ["Hello World", 123456, 3.1415, None, b"0123456789"]
result = driver._internal_parse_array(buffer)
@@ -92,128 +92,6 @@ def test_parse_rowset_signature(self):
assert 1 == result.nrows
assert 2 == result.ncols
- def test_prepare_statement_with_tuple_parameters(self):
- driver = Driver()
-
- query = "SELECT * FROM users WHERE age > ? AND name = ?"
- parameters = (18, "John")
-
- expected_result = "SELECT * FROM users WHERE age > 18 AND name = 'John'"
- result = driver.prepare_statement(query, parameters)
-
- assert expected_result == result
-
- def test_prepare_statement_with_dict_parameters(self):
- driver = Driver()
-
- query = "INSERT INTO users (name, age) VALUES (:name, :age)"
- parameters = {"name": "Alice", "age": 25}
-
- expected_result = "INSERT INTO users (name, age) VALUES ('Alice', 25)"
- result = driver.prepare_statement(query, parameters)
-
- assert expected_result == result
-
- def test_prepare_statement_with_missing_parameters_does_not_raise_exception(self):
- driver = Driver()
-
- query = "UPDATE users SET name = :name, age = :age WHERE id = :id"
- parameters = {"name": "Bob"}
-
- expected_result = "UPDATE users SET name = 'Bob', age = :age WHERE id = :id"
-
- result = driver.prepare_statement(query, parameters)
-
- assert expected_result == result
-
- def test_prepare_statement_with_extra_parameters(self):
- driver = Driver()
-
- query = "SELECT * FROM users WHERE age > :age"
- parameters = {"age": 30, "name": "Alice"}
-
- expected_result = "SELECT * FROM users WHERE age > 30"
-
- result = driver.prepare_statement(query, parameters)
-
- assert expected_result == result
-
- def test_prepare_statement_with_sql_injection_threat(self):
- driver = Driver()
-
- query = "SELECT * FROM phone WHERE name = ?"
- parameter = ("Jack's phone; DROP TABLE phone;",)
-
- expected_result = (
- "SELECT * FROM phone WHERE name = 'Jack''s phone; DROP TABLE phone;'"
- )
- result = driver.prepare_statement(query, parameter)
-
- assert expected_result == result
-
- def test_escape_sql_parameter_with_string(self):
- driver = Driver()
- param = "John's SQL"
-
- expected_result = "'John''s SQL'"
- result = driver.escape_sql_parameter(param)
-
- assert expected_result == result
-
- def test_escape_sql_parameter_with_integer(self):
- driver = Driver()
- param = 123
-
- expected_result = "123"
- result = driver.escape_sql_parameter(param)
-
- assert expected_result == result
-
- def test_escape_sql_parameter_with_float(self):
- driver = Driver()
- param = 3.14
-
- expected_result = "3.14"
- result = driver.escape_sql_parameter(param)
-
- assert expected_result == result
-
- def test_escape_sql_parameter_with_none(self):
- driver = Driver()
- param = None
-
- expected_result = "NULL"
- result = driver.escape_sql_parameter(param)
-
- assert expected_result == result
-
- def test_escape_sql_parameter_with_bool(self):
- driver = Driver()
- param = True
-
- expected_result = "1"
- result = driver.escape_sql_parameter(param)
-
- assert expected_result == result
-
- def test_escape_sql_parameter_with_bytes(self):
- driver = Driver()
- param = b"Hello"
-
- expected_result = "X'48656c6c6f'"
- result = driver.escape_sql_parameter(param)
-
- assert expected_result == result
-
- def test_escape_sql_parameter_with_dict(self):
- driver = Driver()
- param = {"name": "O'Conner", "age": 25}
-
- expected_result = '\'{"name": "O\'\'Conner", "age": 25}\''
- driver.escape_sql_parameter(param)
-
- assert expected_result
-
def test_nonlinearizable_command_before_auth_with_account(
self, mocker: MockerFixture
):
@@ -231,7 +109,7 @@ def test_nonlinearizable_command_before_auth_with_account(
driver.connect("myhost", 8860, config)
expected_buffer = (
- "SET CLIENT KEY NONLINEARIZABLE TO 1;AUTH USER pippo PASSWORD pluto;"
+ b"SET CLIENT KEY NONLINEARIZABLE TO 1;AUTH USER pippo PASSWORD pluto;"
)
run_command_mock.assert_called_once()
@@ -252,7 +130,7 @@ def test_nonlinearizable_command_before_auth_with_apikey(
driver.connect("myhost", 8860, config)
- expected_buffer = "SET CLIENT KEY NONLINEARIZABLE TO 1;AUTH APIKEY abc123;"
+ expected_buffer = b"SET CLIENT KEY NONLINEARIZABLE TO 1;AUTH APIKEY abc123;"
run_command_mock.assert_called_once()
assert expected_buffer in run_command_mock.call_args[0][1]
@@ -269,7 +147,35 @@ def test_compression_enabled_by_default(self, mocker: MockerFixture):
driver.connect("myhost", 8860, config)
- expected_buffer = "SET CLIENT KEY COMPRESSION TO 1;"
+ expected_buffer = b"SET CLIENT KEY COMPRESSION TO 1;"
run_command_mock.assert_called_once()
assert expected_buffer in run_command_mock.call_args[0][1]
+
+ @pytest.mark.parametrize(
+ "data, expected, zero_string",
+ [
+ ("hello world", b"+11 hello world", False),
+ ("hello world", b"!12 hello world\0", True),
+ (123, b":123 ", False),
+ (3.14, b",3.14 ", False),
+ (b"hello", b"$5 hello", False),
+ (None, b"_ ", False),
+ (
+ ["SELECT ?, ?, ?, ?, ?", "world", 123, 3.14, None, b"hello"],
+ b"=57 6 !21 SELECT ?, ?, ?, ?, ?\x00!6 world\x00:123 ,3.14 _ $5 hello",
+ True,
+ ),
+ (
+ ["SELECT ?", "'hello world'"],
+ b"=31 2 !9 SELECT ?\x00+13 'hello world'",
+ False,
+ ),
+ ],
+ )
+ def test_internal_serialize_command(self, data, zero_string, expected):
+ driver = Driver()
+
+ serialized = driver._internal_serialize_command(data, zero_string=zero_string)
+
+ assert serialized == expected
diff --git a/src/tests/unit/test_resultset.py b/src/tests/unit/test_resultset.py
index 18a43f2..8090120 100644
--- a/src/tests/unit/test_resultset.py
+++ b/src/tests/unit/test_resultset.py
@@ -2,7 +2,6 @@
from sqlitecloud.resultset import (
SQLITECLOUD_RESULT_TYPE,
- SQLITECLOUD_VALUE_TYPE,
SQLiteCloudResult,
SQLiteCloudResultSet,
)
@@ -83,30 +82,8 @@ def test_get_value_with_convert_false(self):
result.data = ["John", "42"]
result.decltype = ["TEXT", "INTEGER"]
- assert "John" == result.get_value(0, 0, convert=False)
- assert "42" == result.get_value(0, 1, convert=False)
-
- @pytest.mark.parametrize(
- "value_type, value, expected_value",
- [
- (SQLITECLOUD_VALUE_TYPE.INTEGER.value, "24", 24),
- (SQLITECLOUD_VALUE_TYPE.FLOAT.value, "3.14", 3.14),
- (SQLITECLOUD_VALUE_TYPE.TEXT.value, "John", "John"),
- (SQLITECLOUD_VALUE_TYPE.BLOB.value, b"hello", b"hello"),
- (SQLITECLOUD_VALUE_TYPE.NULL.value, "NULL", None),
- ],
- )
- def test_get_value_to_convert_text(self, value_type, value, expected_value):
- result = SQLiteCloudResult(SQLITECLOUD_RESULT_TYPE.RESULT_ROWSET)
- result.nrows = 1
- result.ncols = 1
- result.colname = ["mycol"]
- result.data = [value]
- result.decltype = [value_type]
-
- result_set = SQLiteCloudResultSet(result)
-
- assert expected_value == result_set.get_value(0, 0)
+ assert "John" == result.get_value(0, 0)
+ assert "42" == result.get_value(0, 1)
class TestSqliteCloudResultSet: