sqlite2duckdb 0.4.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sqlite2duckdb
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A tool to convert sqlite database to duckdb database
5
5
  Project-URL: Homepage, https://github.com/dridk/sqlite2duckdb
6
6
  Project-URL: Issues, https://github.com/dridk/sqlite2duckdb/issues
@@ -32,9 +32,8 @@ A tool for converting a [sqlite](https://www.sqlite.org/) database into a [duckd
32
32
  Sqlite is an embedded online database designed for transactional reading and writing.
33
33
  Duckdb is also an embedded database, but column-oriented, designed for analytical process with a very high reading efficiency.
34
34
 
35
- For more details [https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
35
+ For more details [Medium post](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
36
36
 
37
- Requires Python >= 3.9 and duckdb >= 1.1.0 (indexes are only copied from that version on).
38
37
 
39
38
  ## Installation
40
39
 
@@ -77,10 +76,6 @@ options:
77
76
  -v, --version show program's version number and exit
78
77
  ```
79
78
 
80
- The tool never overwrites an existing target silently. On a terminal it asks for
81
- confirmation; anywhere else (a script, a CI job, a pipe) it exits with code 1 and tells you
82
- to pass `--force`. Progress is written to stderr, so stdout stays free for pipelines.
83
-
84
79
  ### Examples
85
80
 
86
81
  ```bash
@@ -94,38 +89,27 @@ uvx sqlite2duckdb --force source.db target.db # overwrite target.db without as
94
89
  from sqlite2duckdb import sqlite_to_duckdb
95
90
 
96
91
  result = sqlite_to_duckdb("source.sqlite", "target.duckdb")
97
- print(result.tables, result.elapsed)
92
+ print(result.tables, result.views, result.elapsed)
98
93
  ```
99
94
 
100
- `sqlite_to_duckdb(sqlite_db, duck_db, *, overwrite=False)` accepts `str` or `pathlib.Path`
101
- and returns a `ConversionResult` (`target`, `tables`, `elapsed`). It raises
102
- `FileNotFoundError` if the source is missing and `FileExistsError` if the target already
103
- exists and `overwrite` is False. If the conversion fails halfway, the partially written
104
- target file is removed rather than left behind. Progress is reported through the standard
105
- `logging` module (logger `sqlite2duckdb.sqlite_to_duckdb`), never printed.
106
-
107
95
  ## What is converted
108
96
 
109
97
  | | |
110
98
  |---|---|
111
99
  | Tables and data | ✅ |
112
- | Primary keys, NOT NULL constraints, indexes | ✅ |
113
- | UNIQUE, FOREIGN KEY and CHECK constraints | ❌ |
114
- | Views | ❌ (silently dropped) |
115
-
116
- Duckdb's sqlite extension does not expose the last two on the attached database, so they
117
- cannot be copied. Reading them back from `sqlite_master` would be needed.
100
+ | Primary keys, NOT NULL and UNIQUE constraints | ✅ |
101
+ | Indexes | ✅ |
102
+ | Views | ✅ best effort |
103
+ | FOREIGN KEY and CHECK constraints | ❌ |
118
104
 
119
- Tables are recreated from the DDL duckdb derives for the attached database, then filled
120
- from it, and the indexes are read back from `sqlite_master`. This is what makes sqlite
121
- files that quote their DDL with `[brackets]` (chinook.db, MS Access exports) convert
122
- correctly: duckdb's own parser rejects that syntax, so the quoting is translated first.
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.
123
107
 
124
- ## Todo
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.
125
112
 
126
- - [ ] Custom type mapping
127
- - [x] Primary keys, NOT NULL constraints and indexes
128
- - [ ] Views, and UNIQUE / FOREIGN KEY / CHECK constraints
129
113
 
130
114
  ## Contributing
131
115
 
@@ -11,9 +11,8 @@ A tool for converting a [sqlite](https://www.sqlite.org/) database into a [duckd
11
11
  Sqlite is an embedded online database designed for transactional reading and writing.
12
12
  Duckdb is also an embedded database, but column-oriented, designed for analytical process with a very high reading efficiency.
13
13
 
14
- For more details [https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
14
+ For more details [Medium post](https://towardsdatascience.com/forget-about-sqlite-use-duckdb-instead-and-thank-me-later-df76ee9bb777)
15
15
 
16
- Requires Python >= 3.9 and duckdb >= 1.1.0 (indexes are only copied from that version on).
17
16
 
18
17
  ## Installation
19
18
 
@@ -56,10 +55,6 @@ options:
56
55
  -v, --version show program's version number and exit
57
56
  ```
58
57
 
59
- The tool never overwrites an existing target silently. On a terminal it asks for
60
- confirmation; anywhere else (a script, a CI job, a pipe) it exits with code 1 and tells you
61
- to pass `--force`. Progress is written to stderr, so stdout stays free for pipelines.
62
-
63
58
  ### Examples
64
59
 
65
60
  ```bash
@@ -73,38 +68,27 @@ uvx sqlite2duckdb --force source.db target.db # overwrite target.db without as
73
68
  from sqlite2duckdb import sqlite_to_duckdb
74
69
 
75
70
  result = sqlite_to_duckdb("source.sqlite", "target.duckdb")
76
- print(result.tables, result.elapsed)
71
+ print(result.tables, result.views, result.elapsed)
77
72
  ```
78
73
 
79
- `sqlite_to_duckdb(sqlite_db, duck_db, *, overwrite=False)` accepts `str` or `pathlib.Path`
80
- and returns a `ConversionResult` (`target`, `tables`, `elapsed`). It raises
81
- `FileNotFoundError` if the source is missing and `FileExistsError` if the target already
82
- exists and `overwrite` is False. If the conversion fails halfway, the partially written
83
- target file is removed rather than left behind. Progress is reported through the standard
84
- `logging` module (logger `sqlite2duckdb.sqlite_to_duckdb`), never printed.
85
-
86
74
  ## What is converted
87
75
 
88
76
  | | |
89
77
  |---|---|
90
78
  | Tables and data | ✅ |
91
- | Primary keys, NOT NULL constraints, indexes | ✅ |
92
- | UNIQUE, FOREIGN KEY and CHECK constraints | ❌ |
93
- | Views | ❌ (silently dropped) |
94
-
95
- Duckdb's sqlite extension does not expose the last two on the attached database, so they
96
- cannot be copied. Reading them back from `sqlite_master` would be needed.
79
+ | Primary keys, NOT NULL and UNIQUE constraints | ✅ |
80
+ | Indexes | ✅ |
81
+ | Views | ✅ best effort |
82
+ | FOREIGN KEY and CHECK constraints | ❌ |
97
83
 
98
- Tables are recreated from the DDL duckdb derives for the attached database, then filled
99
- from it, and the indexes are read back from `sqlite_master`. This is what makes sqlite
100
- files that quote their DDL with `[brackets]` (chinook.db, MS Access exports) convert
101
- correctly: duckdb's own parser rejects that syntax, so the quoting is translated first.
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.
102
86
 
103
- ## Todo
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.
104
91
 
105
- - [ ] Custom type mapping
106
- - [x] Primary keys, NOT NULL constraints and indexes
107
- - [ ] Views, and UNIQUE / FOREIGN KEY / CHECK constraints
108
92
 
109
93
  ## Contributing
110
94
 
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "sqlite2duckdb"
8
- version = "0.4.0"
8
+ version = "0.5.0"
9
9
  authors = [{name="Sacha Schutz", email="sacha.schutz@pm.me"}]
10
10
  description = "A tool to convert sqlite database to duckdb database"
11
11
  readme = "README.md"
@@ -20,6 +20,7 @@ class ConversionResult:
20
20
  tables: int
21
21
  elapsed: float
22
22
  """Wall clock duration of the conversion, in seconds."""
23
+ views: int = 0
23
24
 
24
25
 
25
26
  def _quote_identifier(name: str) -> str:
@@ -96,6 +97,88 @@ def _copy_indexes(conn: duckdb.DuckDBPyConnection, sqlite_path: str) -> None:
96
97
  logger.warning("Could not recreate index %s: %s", name, error)
97
98
 
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
+
99
182
  def _copy_tables(
100
183
  conn: duckdb.DuckDBPyConnection, tables: list[tuple[str, str]]
101
184
  ) -> None:
@@ -118,9 +201,13 @@ def sqlite_to_duckdb(
118
201
  ) -> ConversionResult:
119
202
  """Copy a sqlite database into a new duckdb database.
120
203
 
121
- Tables, data, primary keys, NOT NULL constraints and indexes are copied.
122
- Views and UNIQUE / FOREIGN KEY / CHECK constraints are not: duckdb's sqlite
123
- extension does not expose them on the attached database.
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.
124
211
 
125
212
  Raises FileNotFoundError if `sqlite_db` does not exist, and FileExistsError
126
213
  if `duck_db` already exists and `overwrite` is False.
@@ -157,6 +244,10 @@ def sqlite_to_duckdb(
157
244
 
158
245
  _copy_tables(conn, tables)
159
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)
160
251
 
161
252
  conn.sql("DETACH __other")
162
253
  except BaseException:
@@ -171,4 +262,6 @@ def sqlite_to_duckdb(
171
262
  elapsed = time.perf_counter() - start_time
172
263
  logger.info("Done in %s !", _format_duration(elapsed))
173
264
 
174
- return ConversionResult(target=duck_path, tables=len(tables), elapsed=elapsed)
265
+ return ConversionResult(
266
+ target=duck_path, tables=len(tables), elapsed=elapsed, views=views
267
+ )
@@ -49,3 +49,13 @@ def duckdb_path(tmp_path):
49
49
  @pytest.fixture(scope="module")
50
50
  def bracket_index_sqlite(tmp_path_factory):
51
51
  return _module_db(tmp_path_factory, "bindex", utils.build_bracket_index_sqlite)
52
+
53
+
54
+ @pytest.fixture(scope="module")
55
+ def views_sqlite(tmp_path_factory):
56
+ return _module_db(tmp_path_factory, "views", utils.build_views_sqlite)
57
+
58
+
59
+ @pytest.fixture(scope="module")
60
+ def unique_sqlite(tmp_path_factory):
61
+ return _module_db(tmp_path_factory, "unique", utils.build_unique_sqlite)
@@ -95,6 +95,11 @@ def test_bracket_quoted_view(bracket_sqlite, duckdb_path):
95
95
  (2, 7),
96
96
  ]
97
97
 
98
+ # The bracket quoted view built on it comes across too.
99
+ assert d_conn.sql(
100
+ 'SELECT * FROM "Order Subtotals" ORDER BY OrderID'
101
+ ).fetchall() == [(1, 5), (2, 7)]
102
+
98
103
 
99
104
  def test_dotted_column_names(dotted_column_sqlite, duckdb_path):
100
105
  """Regression test for issue #4: a STRICT table with numeric-looking column
@@ -0,0 +1,87 @@
1
+ """Views and UNIQUE constraints, which duckdb's sqlite extension does not expose
2
+ on an attached database and which have to be read back from sqlite_master."""
3
+
4
+ import logging
5
+
6
+ import duckdb
7
+ import pytest
8
+
9
+ from sqlite2duckdb import sqlite_to_duckdb
10
+
11
+
12
+ def test_views_are_copied(views_sqlite, duckdb_path):
13
+ result = sqlite_to_duckdb(views_sqlite, duckdb_path)
14
+
15
+ d_conn = duckdb.connect(str(duckdb_path))
16
+
17
+ assert d_conn.sql("SELECT * FROM by_region ORDER BY region").fetchall() == [
18
+ ("north", 15),
19
+ ("south", 7),
20
+ ]
21
+ assert result.views == 3
22
+
23
+
24
+ def test_view_built_on_another_view(views_sqlite, duckdb_path):
25
+ sqlite_to_duckdb(views_sqlite, duckdb_path)
26
+
27
+ d_conn = duckdb.connect(str(duckdb_path))
28
+
29
+ assert d_conn.sql("SELECT * FROM big_regions").fetchall() == [("north",)]
30
+
31
+
32
+ def test_bracket_quoted_view_is_translated(views_sqlite, duckdb_path):
33
+ sqlite_to_duckdb(views_sqlite, duckdb_path)
34
+
35
+ d_conn = duckdb.connect(str(duckdb_path))
36
+
37
+ assert d_conn.sql('SELECT COUNT(*) FROM "north sales"').fetchone() == (2,)
38
+
39
+
40
+ def test_view_duckdb_cannot_parse_is_skipped_with_a_warning(
41
+ views_sqlite, duckdb_path, caplog
42
+ ):
43
+ with caplog.at_level(logging.WARNING, logger="sqlite2duckdb.sqlite_to_duckdb"):
44
+ sqlite_to_duckdb(views_sqlite, duckdb_path)
45
+
46
+ assert "matched" in caplog.text
47
+
48
+ d_conn = duckdb.connect(str(duckdb_path))
49
+ views = {
50
+ row[0]
51
+ for row in d_conn.sql(
52
+ "SELECT view_name FROM duckdb_views() WHERE NOT internal"
53
+ ).fetchall()
54
+ }
55
+
56
+ assert "matched" not in views
57
+ # The rest of the database must still be intact.
58
+ assert d_conn.sql("SELECT COUNT(*) FROM sales").fetchone() == (3,)
59
+
60
+
61
+ def test_unique_constraints_are_enforced(unique_sqlite, duckdb_path):
62
+ sqlite_to_duckdb(unique_sqlite, duckdb_path)
63
+
64
+ d_conn = duckdb.connect(str(duckdb_path))
65
+
66
+ # Column level UNIQUE, recorded by sqlite as an autoindex with no SQL.
67
+ with pytest.raises(duckdb.ConstraintException):
68
+ d_conn.sql("INSERT INTO members VALUES (2, 'ada@example.com', 'x', 'y')")
69
+
70
+ # Table level UNIQUE over two columns.
71
+ with pytest.raises(duckdb.ConstraintException):
72
+ d_conn.sql(
73
+ "INSERT INTO members VALUES (3, 'other@example.com', 'ada', 'lovelace')"
74
+ )
75
+
76
+ # An explicit CREATE UNIQUE INDEX, which does carry its SQL.
77
+ with pytest.raises(duckdb.ConstraintException):
78
+ d_conn.sql("INSERT INTO members VALUES (4, 'x@example.com', 'x', 'lovelace')")
79
+
80
+
81
+ def test_non_unique_rows_still_insert(unique_sqlite, duckdb_path):
82
+ sqlite_to_duckdb(unique_sqlite, duckdb_path)
83
+
84
+ d_conn = duckdb.connect(str(duckdb_path))
85
+ d_conn.sql("INSERT INTO members VALUES (5, 'grace@example.com', 'grace', 'hopper')")
86
+
87
+ assert d_conn.sql("SELECT COUNT(*) FROM members").fetchone() == (2,)
@@ -200,3 +200,53 @@ def build_bracket_index_sqlite(path):
200
200
  conn.close()
201
201
 
202
202
  return path
203
+
204
+
205
+ def build_views_sqlite(path):
206
+ """Views, including one chained on another and one duckdb cannot bind."""
207
+
208
+ conn = sqlite3.connect(path)
209
+ conn.executescript(
210
+ """
211
+ CREATE TABLE sales (id INTEGER, region TEXT, amount INTEGER);
212
+ INSERT INTO sales VALUES (1, 'north', 10), (2, 'north', 5), (3, 'south', 7);
213
+
214
+ CREATE VIEW by_region AS
215
+ SELECT region, SUM(amount) AS total FROM sales GROUP BY region;
216
+
217
+ CREATE VIEW big_regions AS
218
+ SELECT region FROM by_region WHERE total > 8;
219
+
220
+ CREATE VIEW [north sales] AS SELECT * FROM sales WHERE [region] = 'north';
221
+
222
+ -- MATCH is sqlite only, and duckdb's parser rejects it outright.
223
+ CREATE VIEW matched AS SELECT * FROM sales WHERE region MATCH 'north';
224
+ """
225
+ )
226
+ conn.commit()
227
+ conn.close()
228
+
229
+ return path
230
+
231
+
232
+ def build_unique_sqlite(path):
233
+ """The three ways sqlite records uniqueness."""
234
+
235
+ conn = sqlite3.connect(path)
236
+ conn.executescript(
237
+ """
238
+ CREATE TABLE members (
239
+ id INTEGER PRIMARY KEY,
240
+ email TEXT UNIQUE,
241
+ first TEXT,
242
+ last TEXT,
243
+ UNIQUE (first, last)
244
+ );
245
+ INSERT INTO members VALUES (1, 'ada@example.com', 'ada', 'lovelace');
246
+ CREATE UNIQUE INDEX idx_members_last ON members (last);
247
+ """
248
+ )
249
+ conn.commit()
250
+ conn.close()
251
+
252
+ return path
File without changes