sqlite2duckdb 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlite2duckdb-0.3.0 → sqlite2duckdb-0.5.0}/.gitignore +3 -0
- sqlite2duckdb-0.5.0/PKG-INFO +128 -0
- sqlite2duckdb-0.5.0/README.md +107 -0
- sqlite2duckdb-0.5.0/pyproject.toml +60 -0
- sqlite2duckdb-0.5.0/sqlite2duckdb/__init__.py +14 -0
- sqlite2duckdb-0.5.0/sqlite2duckdb/__main__.py +76 -0
- sqlite2duckdb-0.5.0/sqlite2duckdb/sqlite_to_duckdb.py +267 -0
- sqlite2duckdb-0.5.0/tests/conftest.py +61 -0
- sqlite2duckdb-0.5.0/tests/test_brackets.py +29 -0
- sqlite2duckdb-0.5.0/tests/test_cli.py +79 -0
- sqlite2duckdb-0.5.0/tests/test_convert.py +215 -0
- sqlite2duckdb-0.5.0/tests/test_examples.py +94 -0
- sqlite2duckdb-0.5.0/tests/test_views_and_unique.py +87 -0
- sqlite2duckdb-0.5.0/tests/utils.py +252 -0
- sqlite2duckdb-0.3.0/Makefile +0 -12
- sqlite2duckdb-0.3.0/PKG-INFO +0 -74
- sqlite2duckdb-0.3.0/README.md +0 -59
- sqlite2duckdb-0.3.0/pyproject.toml +0 -29
- sqlite2duckdb-0.3.0/requirements.txt +0 -1
- sqlite2duckdb-0.3.0/sqlite2duckdb/__init__.py +0 -4
- sqlite2duckdb-0.3.0/sqlite2duckdb/__main__.py +0 -38
- sqlite2duckdb-0.3.0/sqlite2duckdb/sqlite_to_duckdb.py +0 -47
- sqlite2duckdb-0.3.0/tests/test_convert.py +0 -20
- sqlite2duckdb-0.3.0/tests/utils.py +0 -68
- {sqlite2duckdb-0.3.0 → sqlite2duckdb-0.5.0}/LICENSE +0 -0
|
@@ -158,3 +158,6 @@ cython_debug/
|
|
|
158
158
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
159
159
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
160
160
|
#.idea/
|
|
161
|
+
|
|
162
|
+
# Test databases generated at the repo root
|
|
163
|
+
/*.db
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sqlite2duckdb
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: A tool to convert sqlite database to duckdb database
|
|
5
|
+
Project-URL: Homepage, https://github.com/dridk/sqlite2duckdb
|
|
6
|
+
Project-URL: Issues, https://github.com/dridk/sqlite2duckdb/issues
|
|
7
|
+
Author-email: Sacha Schutz <sacha.schutz@pm.me>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: database,duckdb,olap,oltp,sqlite
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: duckdb>=1.1.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# sqlite2duckdb
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+

|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
A tool for converting a [sqlite](https://www.sqlite.org/) database into a [duckdb](https://duckdb.org/) database
|
|
29
|
+
|
|
30
|
+
## Description
|
|
31
|
+
|
|
32
|
+
Sqlite is an embedded online database designed for transactional reading and writing.
|
|
33
|
+
Duckdb is also an embedded database, but column-oriented, designed for analytical process with a very high reading efficiency.
|
|
34
|
+
|
|
35
|
+
For more details [Medium post](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
With [uv](https://docs.astral.sh/uv/), no installation is required. `uvx` downloads and runs the tool in one go:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uvx sqlite2duckdb source.db target.db
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
To keep it around:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv tool install sqlite2duckdb
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or with pip:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install sqlite2duckdb
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
### As a command line
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
usage: sqlite2duckdb [-f] <sqlite_path> <duckdb_path>
|
|
64
|
+
|
|
65
|
+
Convert Sqlite database to Duckdb database
|
|
66
|
+
|
|
67
|
+
positional arguments:
|
|
68
|
+
sqlite_path sqlite file path
|
|
69
|
+
duckdb_path duckdb file path
|
|
70
|
+
|
|
71
|
+
options:
|
|
72
|
+
-h, --help show this help message and exit
|
|
73
|
+
-f, --force overwrite the duckdb file if it already exists
|
|
74
|
+
-q, --quiet only report errors
|
|
75
|
+
--verbose report every step
|
|
76
|
+
-v, --version show program's version number and exit
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Examples
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uvx sqlite2duckdb source.db target.db
|
|
83
|
+
uvx sqlite2duckdb --force source.db target.db # overwrite target.db without asking
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### From python
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from sqlite2duckdb import sqlite_to_duckdb
|
|
90
|
+
|
|
91
|
+
result = sqlite_to_duckdb("source.sqlite", "target.duckdb")
|
|
92
|
+
print(result.tables, result.views, result.elapsed)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## What is converted
|
|
96
|
+
|
|
97
|
+
| | |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Tables and data | ✅ |
|
|
100
|
+
| Primary keys, NOT NULL and UNIQUE constraints | ✅ |
|
|
101
|
+
| Indexes | ✅ |
|
|
102
|
+
| Views | ✅ best effort |
|
|
103
|
+
| FOREIGN KEY and CHECK constraints | ❌ |
|
|
104
|
+
|
|
105
|
+
A view whose SQL uses something duckdb has no equivalent for (`MATCH`, `julianday()`) is
|
|
106
|
+
skipped with a warning instead of failing the conversion.
|
|
107
|
+
|
|
108
|
+
FOREIGN KEY and CHECK are not copied, though not for lack of support: duckdb accepts and
|
|
109
|
+
enforces both in `CREATE TABLE`. It checks foreign keys row by row, so a self-referencing
|
|
110
|
+
table cannot be bulk loaded, and there is no `ALTER TABLE ADD CONSTRAINT` to add them once
|
|
111
|
+
the data is in.
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
## Contributing
|
|
115
|
+
|
|
116
|
+
The project uses [uv](https://docs.astral.sh/uv/) for everything:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
make dev # uv sync — installs duckdb plus the test deps (pytest, faker, ruff)
|
|
120
|
+
make test # uv run pytest
|
|
121
|
+
make lint # uv run ruff check . && uv run ruff format --check .
|
|
122
|
+
make build # uv build
|
|
123
|
+
make publish # uv publish (PyPI trusted publishing, also run on tags by CI)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### See also
|
|
127
|
+
|
|
128
|
+
- [Harlequin](https://github.com/tconbeer/harlequin): A nice duckdb IDE for your terminal
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# sqlite2duckdb
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
A tool for converting a [sqlite](https://www.sqlite.org/) database into a [duckdb](https://duckdb.org/) database
|
|
8
|
+
|
|
9
|
+
## Description
|
|
10
|
+
|
|
11
|
+
Sqlite is an embedded online database designed for transactional reading and writing.
|
|
12
|
+
Duckdb is also an embedded database, but column-oriented, designed for analytical process with a very high reading efficiency.
|
|
13
|
+
|
|
14
|
+
For more details [Medium post](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
With [uv](https://docs.astral.sh/uv/), no installation is required. `uvx` downloads and runs the tool in one go:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uvx sqlite2duckdb source.db target.db
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To keep it around:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv tool install sqlite2duckdb
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or with pip:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install sqlite2duckdb
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### As a command line
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
usage: sqlite2duckdb [-f] <sqlite_path> <duckdb_path>
|
|
43
|
+
|
|
44
|
+
Convert Sqlite database to Duckdb database
|
|
45
|
+
|
|
46
|
+
positional arguments:
|
|
47
|
+
sqlite_path sqlite file path
|
|
48
|
+
duckdb_path duckdb file path
|
|
49
|
+
|
|
50
|
+
options:
|
|
51
|
+
-h, --help show this help message and exit
|
|
52
|
+
-f, --force overwrite the duckdb file if it already exists
|
|
53
|
+
-q, --quiet only report errors
|
|
54
|
+
--verbose report every step
|
|
55
|
+
-v, --version show program's version number and exit
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Examples
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uvx sqlite2duckdb source.db target.db
|
|
62
|
+
uvx sqlite2duckdb --force source.db target.db # overwrite target.db without asking
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### From python
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from sqlite2duckdb import sqlite_to_duckdb
|
|
69
|
+
|
|
70
|
+
result = sqlite_to_duckdb("source.sqlite", "target.duckdb")
|
|
71
|
+
print(result.tables, result.views, result.elapsed)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## What is converted
|
|
75
|
+
|
|
76
|
+
| | |
|
|
77
|
+
|---|---|
|
|
78
|
+
| Tables and data | ✅ |
|
|
79
|
+
| Primary keys, NOT NULL and UNIQUE constraints | ✅ |
|
|
80
|
+
| Indexes | ✅ |
|
|
81
|
+
| Views | ✅ best effort |
|
|
82
|
+
| FOREIGN KEY and CHECK constraints | ❌ |
|
|
83
|
+
|
|
84
|
+
A view whose SQL uses something duckdb has no equivalent for (`MATCH`, `julianday()`) is
|
|
85
|
+
skipped with a warning instead of failing the conversion.
|
|
86
|
+
|
|
87
|
+
FOREIGN KEY and CHECK are not copied, though not for lack of support: duckdb accepts and
|
|
88
|
+
enforces both in `CREATE TABLE`. It checks foreign keys row by row, so a self-referencing
|
|
89
|
+
table cannot be bulk loaded, and there is no `ALTER TABLE ADD CONSTRAINT` to add them once
|
|
90
|
+
the data is in.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
## Contributing
|
|
94
|
+
|
|
95
|
+
The project uses [uv](https://docs.astral.sh/uv/) for everything:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
make dev # uv sync — installs duckdb plus the test deps (pytest, faker, ruff)
|
|
99
|
+
make test # uv run pytest
|
|
100
|
+
make lint # uv run ruff check . && uv run ruff format --check .
|
|
101
|
+
make build # uv build
|
|
102
|
+
make publish # uv publish (PyPI trusted publishing, also run on tags by CI)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### See also
|
|
106
|
+
|
|
107
|
+
- [Harlequin](https://github.com/tconbeer/harlequin): A nice duckdb IDE for your terminal
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "sqlite2duckdb"
|
|
8
|
+
version = "0.5.0"
|
|
9
|
+
authors = [{name="Sacha Schutz", email="sacha.schutz@pm.me"}]
|
|
10
|
+
description = "A tool to convert sqlite database to duckdb database"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
keywords = ["sqlite", "duckdb", "database", "olap", "oltp"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
'duckdb >= 1.1.0'
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/dridk/sqlite2duckdb"
|
|
32
|
+
Issues = "https://github.com/dridk/sqlite2duckdb/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
sqlite2duckdb = "sqlite2duckdb.__main__:main_cli"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest >= 7.0",
|
|
40
|
+
"faker",
|
|
41
|
+
"ruff",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["sqlite2duckdb"]
|
|
46
|
+
|
|
47
|
+
# Allow list rather than a deny list, so that anything new landing in the repo
|
|
48
|
+
# stays out of the distribution unless it is explicitly wanted.
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = ["sqlite2duckdb", "tests", "README.md"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
pythonpath = ["."]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
target-version = "py39"
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
extend-select = ["I", "UP"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
|
|
3
|
+
from sqlite2duckdb.sqlite_to_duckdb import ConversionResult, sqlite_to_duckdb
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = importlib.metadata.version("sqlite2duckdb")
|
|
7
|
+
except importlib.metadata.PackageNotFoundError:
|
|
8
|
+
# Running from a source checkout that was never installed.
|
|
9
|
+
__version__ = "0.0.0.dev0"
|
|
10
|
+
|
|
11
|
+
# Deprecated alias, kept so that existing imports keep working.
|
|
12
|
+
__VERSION__ = __version__
|
|
13
|
+
|
|
14
|
+
__all__ = ["ConversionResult", "__version__", "sqlite_to_duckdb"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from sqlite2duckdb import __version__, sqlite_to_duckdb
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main_cli() -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="sqlite2duckdb",
|
|
14
|
+
description="Convert Sqlite database to Duckdb database",
|
|
15
|
+
usage="sqlite2duckdb [-f] <sqlite_path> <duckdb_path>",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
parser.add_argument("sqlite_path", type=str, help="sqlite file path")
|
|
19
|
+
parser.add_argument("duckdb_path", type=str, help="duckdb file path")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"-f",
|
|
22
|
+
"--force",
|
|
23
|
+
action="store_true",
|
|
24
|
+
help="overwrite the duckdb file if it already exists",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="only report errors")
|
|
27
|
+
parser.add_argument("--verbose", action="store_true", help="report every step")
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"-v", "--version", action="version", version=f"sqlite2duckdb {__version__}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
|
|
34
|
+
if args.quiet:
|
|
35
|
+
level = logging.WARNING
|
|
36
|
+
elif args.verbose:
|
|
37
|
+
level = logging.DEBUG
|
|
38
|
+
else:
|
|
39
|
+
level = logging.INFO
|
|
40
|
+
# Progress goes to stderr so that stdout stays free for pipelines.
|
|
41
|
+
logging.basicConfig(level=level, format="%(message)s", stream=sys.stderr)
|
|
42
|
+
|
|
43
|
+
overwrite = args.force
|
|
44
|
+
if not overwrite and os.path.exists(args.duckdb_path):
|
|
45
|
+
if not sys.stdin.isatty():
|
|
46
|
+
print(
|
|
47
|
+
f"{args.duckdb_path} already exists. Use --force to overwrite it.",
|
|
48
|
+
file=sys.stderr,
|
|
49
|
+
)
|
|
50
|
+
return 1
|
|
51
|
+
try:
|
|
52
|
+
answer = (
|
|
53
|
+
input(
|
|
54
|
+
f"{args.duckdb_path} already exists. do you want to delete this file ? (yes/no): "
|
|
55
|
+
)
|
|
56
|
+
.strip()
|
|
57
|
+
.lower()
|
|
58
|
+
)
|
|
59
|
+
except EOFError:
|
|
60
|
+
print(f"{args.duckdb_path} already exists.", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
if answer not in ("yes", "y"):
|
|
63
|
+
return 1
|
|
64
|
+
overwrite = True
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
sqlite_to_duckdb(args.sqlite_path, args.duckdb_path, overwrite=overwrite)
|
|
68
|
+
except (FileNotFoundError, FileExistsError) as error:
|
|
69
|
+
print(f"error: {error}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
sys.exit(main_cli())
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import duckdb
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ConversionResult:
|
|
17
|
+
"""What a successful conversion produced."""
|
|
18
|
+
|
|
19
|
+
target: str
|
|
20
|
+
tables: int
|
|
21
|
+
elapsed: float
|
|
22
|
+
"""Wall clock duration of the conversion, in seconds."""
|
|
23
|
+
views: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _quote_identifier(name: str) -> str:
|
|
27
|
+
return '"' + name.replace('"', '""') + '"'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format_duration(seconds: float) -> str:
|
|
31
|
+
if seconds < 1:
|
|
32
|
+
return f"{seconds * 1000:.2f} ms"
|
|
33
|
+
return f"{seconds:.2f} s"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _remove_quietly(path: str) -> None:
|
|
37
|
+
for candidate in (path, path + ".wal"):
|
|
38
|
+
try:
|
|
39
|
+
os.remove(candidate)
|
|
40
|
+
except OSError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _brackets_to_quotes(sql: str) -> str:
|
|
45
|
+
"""Rewrite sqlite's [bracket] identifiers into standard "double quoted" ones.
|
|
46
|
+
|
|
47
|
+
Quoted runs are copied verbatim so that a `[` inside a string literal survives.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
out = []
|
|
51
|
+
index = 0
|
|
52
|
+
length = len(sql)
|
|
53
|
+
|
|
54
|
+
while index < length:
|
|
55
|
+
char = sql[index]
|
|
56
|
+
|
|
57
|
+
if char in "'\"`":
|
|
58
|
+
end = index + 1
|
|
59
|
+
while end < length:
|
|
60
|
+
if sql[end] == char:
|
|
61
|
+
if end + 1 < length and sql[end + 1] == char:
|
|
62
|
+
end += 2
|
|
63
|
+
continue
|
|
64
|
+
break
|
|
65
|
+
end += 1
|
|
66
|
+
out.append(sql[index : end + 1])
|
|
67
|
+
index = end + 1
|
|
68
|
+
|
|
69
|
+
elif char == "[":
|
|
70
|
+
end = sql.find("]", index)
|
|
71
|
+
if end == -1:
|
|
72
|
+
out.append(char)
|
|
73
|
+
index += 1
|
|
74
|
+
else:
|
|
75
|
+
out.append(_quote_identifier(sql[index + 1 : end]))
|
|
76
|
+
index = end + 1
|
|
77
|
+
|
|
78
|
+
else:
|
|
79
|
+
out.append(char)
|
|
80
|
+
index += 1
|
|
81
|
+
|
|
82
|
+
return "".join(out)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _copy_indexes(conn: duckdb.DuckDBPyConnection, sqlite_path: str) -> None:
|
|
86
|
+
"""Recreate the source indexes, translating sqlite quoting on the way."""
|
|
87
|
+
|
|
88
|
+
with contextlib.closing(sqlite3.connect(sqlite_path)) as source:
|
|
89
|
+
indexes = source.execute(
|
|
90
|
+
"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL"
|
|
91
|
+
).fetchall()
|
|
92
|
+
|
|
93
|
+
for name, sql in indexes:
|
|
94
|
+
try:
|
|
95
|
+
conn.sql(_brackets_to_quotes(sql))
|
|
96
|
+
except duckdb.Error as error:
|
|
97
|
+
logger.warning("Could not recreate index %s: %s", name, error)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _copy_unique_constraints(
|
|
101
|
+
conn: duckdb.DuckDBPyConnection, sqlite_path: str, table_names: list[str]
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Replay the UNIQUE constraints that sqlite records without any SQL.
|
|
104
|
+
|
|
105
|
+
A column or table level UNIQUE becomes an autoindex whose sqlite_master row
|
|
106
|
+
has a NULL sql, so _copy_indexes cannot see it. Duckdb has no ALTER TABLE ADD
|
|
107
|
+
CONSTRAINT either, so a unique index is how the guarantee is carried over.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
with contextlib.closing(sqlite3.connect(sqlite_path)) as source:
|
|
111
|
+
for table in table_names:
|
|
112
|
+
indexes = source.execute(
|
|
113
|
+
f"PRAGMA index_list({_quote_identifier(table)})"
|
|
114
|
+
).fetchall()
|
|
115
|
+
|
|
116
|
+
for _, index_name, unique, origin, _partial in indexes:
|
|
117
|
+
# 'c' indexes carry their own SQL and are handled by _copy_indexes,
|
|
118
|
+
# and 'pk' is already part of the table DDL.
|
|
119
|
+
if not unique or origin != "u":
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
columns = [
|
|
123
|
+
row[2]
|
|
124
|
+
for row in source.execute(
|
|
125
|
+
f"PRAGMA index_info({_quote_identifier(index_name)})"
|
|
126
|
+
).fetchall()
|
|
127
|
+
]
|
|
128
|
+
if any(column is None for column in columns):
|
|
129
|
+
logger.warning(
|
|
130
|
+
"Skipping unique index %s: it is built on an expression",
|
|
131
|
+
index_name,
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
targets = ", ".join(_quote_identifier(column) for column in columns)
|
|
136
|
+
try:
|
|
137
|
+
conn.sql(
|
|
138
|
+
f"CREATE UNIQUE INDEX {_quote_identifier(index_name)} "
|
|
139
|
+
f"ON {_quote_identifier(table)} ({targets})"
|
|
140
|
+
)
|
|
141
|
+
except duckdb.Error as error:
|
|
142
|
+
logger.warning(
|
|
143
|
+
"Could not recreate unique index %s: %s", index_name, error
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _copy_views(conn: duckdb.DuckDBPyConnection, sqlite_path: str) -> int:
|
|
148
|
+
"""Recreate the source views, and return how many made it across."""
|
|
149
|
+
|
|
150
|
+
with contextlib.closing(sqlite3.connect(sqlite_path)) as source:
|
|
151
|
+
views = source.execute(
|
|
152
|
+
"SELECT name, sql FROM sqlite_master WHERE type = 'view' AND sql IS NOT NULL"
|
|
153
|
+
).fetchall()
|
|
154
|
+
|
|
155
|
+
pending = [(name, _brackets_to_quotes(sql)) for name, sql in views]
|
|
156
|
+
errors: dict[str, duckdb.Error] = {}
|
|
157
|
+
created = 0
|
|
158
|
+
|
|
159
|
+
# A view can sit on top of another one and sqlite_master does not guarantee
|
|
160
|
+
# dependency order, so keep retrying while a pass still makes progress.
|
|
161
|
+
while pending:
|
|
162
|
+
failed = []
|
|
163
|
+
for name, statement in pending:
|
|
164
|
+
try:
|
|
165
|
+
conn.sql(statement)
|
|
166
|
+
except duckdb.Error as error:
|
|
167
|
+
errors[name] = error
|
|
168
|
+
failed.append((name, statement))
|
|
169
|
+
else:
|
|
170
|
+
created += 1
|
|
171
|
+
|
|
172
|
+
if len(failed) == len(pending):
|
|
173
|
+
break
|
|
174
|
+
pending = failed
|
|
175
|
+
|
|
176
|
+
for name, _ in pending:
|
|
177
|
+
logger.warning("Could not recreate view %s: %s", name, errors[name])
|
|
178
|
+
|
|
179
|
+
return created
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _copy_tables(
|
|
183
|
+
conn: duckdb.DuckDBPyConnection, tables: list[tuple[str, str]]
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Recreate each table then fill it from the attached database."""
|
|
186
|
+
|
|
187
|
+
for table_name, ddl in tables:
|
|
188
|
+
# This DDL is regenerated by duckdb from the attached catalog, so unlike
|
|
189
|
+
# sqlite's own it always parses, and it still carries the primary keys
|
|
190
|
+
# and the NOT NULL constraints.
|
|
191
|
+
conn.sql(ddl)
|
|
192
|
+
quoted = _quote_identifier(table_name)
|
|
193
|
+
conn.sql(f"INSERT INTO {quoted} SELECT * FROM __other.{quoted}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def sqlite_to_duckdb(
|
|
197
|
+
sqlite_db: str | os.PathLike[str],
|
|
198
|
+
duck_db: str | os.PathLike[str],
|
|
199
|
+
*,
|
|
200
|
+
overwrite: bool = False,
|
|
201
|
+
) -> ConversionResult:
|
|
202
|
+
"""Copy a sqlite database into a new duckdb database.
|
|
203
|
+
|
|
204
|
+
Tables, data, views, primary keys, NOT NULL and UNIQUE constraints and
|
|
205
|
+
indexes are copied. FOREIGN KEY and CHECK constraints are not: duckdb checks
|
|
206
|
+
foreign keys row by row, so a self-referencing table cannot be bulk loaded,
|
|
207
|
+
and there is no ALTER TABLE ADD CONSTRAINT to add them once the data is in.
|
|
208
|
+
|
|
209
|
+
A view duckdb cannot bind is skipped with a warning rather than failing the
|
|
210
|
+
whole conversion.
|
|
211
|
+
|
|
212
|
+
Raises FileNotFoundError if `sqlite_db` does not exist, and FileExistsError
|
|
213
|
+
if `duck_db` already exists and `overwrite` is False.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
sqlite_path = os.fspath(sqlite_db)
|
|
217
|
+
duck_path = os.fspath(duck_db)
|
|
218
|
+
|
|
219
|
+
if not os.path.exists(sqlite_path):
|
|
220
|
+
raise FileNotFoundError(f"File {sqlite_path} doesn't exist")
|
|
221
|
+
|
|
222
|
+
if os.path.exists(duck_path):
|
|
223
|
+
if not overwrite:
|
|
224
|
+
raise FileExistsError(f"Database {duck_path} already exists")
|
|
225
|
+
_remove_quietly(duck_path)
|
|
226
|
+
|
|
227
|
+
logger.info("Create %s database", duck_path)
|
|
228
|
+
start_time = time.perf_counter()
|
|
229
|
+
|
|
230
|
+
conn = duckdb.connect(duck_path)
|
|
231
|
+
try:
|
|
232
|
+
## Install sqlite
|
|
233
|
+
conn.sql("INSTALL sqlite; LOAD sqlite;")
|
|
234
|
+
|
|
235
|
+
# Bound parameters are not allowed in ATTACH, so escape the quotes ourselves
|
|
236
|
+
source_path = sqlite_path.replace("'", "''")
|
|
237
|
+
conn.sql(f"ATTACH '{source_path}' AS __other (TYPE SQLITE, READ_ONLY)")
|
|
238
|
+
|
|
239
|
+
## Get sqlite Names, along with the CREATE statement duckdb derives for them
|
|
240
|
+
tables = conn.sql(
|
|
241
|
+
"SELECT table_name, sql FROM duckdb_tables WHERE database_name = '__other'"
|
|
242
|
+
).fetchall()
|
|
243
|
+
logger.info("%d table(s) found", len(tables))
|
|
244
|
+
|
|
245
|
+
_copy_tables(conn, tables)
|
|
246
|
+
_copy_indexes(conn, sqlite_path)
|
|
247
|
+
_copy_unique_constraints(conn, sqlite_path, [name for name, _ in tables])
|
|
248
|
+
views = _copy_views(conn, sqlite_path)
|
|
249
|
+
if views:
|
|
250
|
+
logger.info("%d view(s) copied", views)
|
|
251
|
+
|
|
252
|
+
conn.sql("DETACH __other")
|
|
253
|
+
except BaseException:
|
|
254
|
+
# Never leave a half-written database behind: it would trip the
|
|
255
|
+
# "already exists" guard on the next run.
|
|
256
|
+
conn.close()
|
|
257
|
+
_remove_quietly(duck_path)
|
|
258
|
+
raise
|
|
259
|
+
|
|
260
|
+
conn.close()
|
|
261
|
+
|
|
262
|
+
elapsed = time.perf_counter() - start_time
|
|
263
|
+
logger.info("Done in %s !", _format_duration(elapsed))
|
|
264
|
+
|
|
265
|
+
return ConversionResult(
|
|
266
|
+
target=duck_path, tables=len(tables), elapsed=elapsed, views=views
|
|
267
|
+
)
|