Skip to content

Commit 3dd0582

Browse files
Split load_spatialite into load_spatialite_driver and init_spatialite (#459)
* Split load_spatialite into load_spatialite_driver and init_spatialite * Add transaction and journal_mode arguments and improve docs * Skip PyPy when journal_mode='OFF'
1 parent 850347d commit 3dd0582

File tree

5 files changed

+208
-50
lines changed

5 files changed

+208
-50
lines changed

geoalchemy2/admin/dialects/geopackage.py

+31-14
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
See GeoPackage specifications here: http://www.geopackage.org/spec/
44
"""
5-
import os
65
import re
76

87
from sqlalchemy import text
@@ -19,6 +18,7 @@
1918
from geoalchemy2.admin.dialects.common import setup_create_drop
2019
from geoalchemy2.admin.dialects.sqlite import _SQLITE_FUNCTIONS
2120
from geoalchemy2.admin.dialects.sqlite import get_col_dim
21+
from geoalchemy2.admin.dialects.sqlite import load_spatialite_driver
2222
from geoalchemy2.types import Geography
2323
from geoalchemy2.types import Geometry
2424
from geoalchemy2.types import _DummyGeometry
@@ -37,31 +37,48 @@ class GeoPackageDialect(SQLiteDialect_pysqlite):
3737
registry.register("gpkg", "geoalchemy2.admin.dialects.geopackage", "GeoPackageDialect")
3838

3939

40-
def load_spatialite_gpkg(dbapi_conn, connection_record):
41-
"""Load SpatiaLite extension in GeoPackage.
40+
def load_geopackage_driver(dbapi_conn, *args):
41+
"""Load SpatiaLite extension in GeoPackage connection and set VirtualGpkg and Amphibious modes.
4242
43-
The path to the SpatiaLite module should be set in the `SPATIALITE_LIBRARY_PATH` environment
44-
variable.
43+
.. Warning::
44+
The path to the SpatiaLite module should be set in the `SPATIALITE_LIBRARY_PATH`
45+
environment variable.
4546
46-
.. Note::
47+
Args:
48+
dbapi_conn: The DBAPI connection.
49+
"""
50+
load_spatialite_driver(dbapi_conn, *args)
51+
52+
dbapi_conn.execute("SELECT AutoGpkgStart();")
53+
dbapi_conn.execute("SELECT EnableGpkgAmphibiousMode();")
54+
55+
56+
def init_geopackage(dbapi_conn, *args):
57+
"""Initialize GeoPackage tables.
4758
59+
Args:
60+
dbapi_conn: The DBAPI connection.
61+
62+
.. Warning::
4863
No EPSG SRID is loaded in the `gpkg_spatial_ref_sys` table after initialization but
4964
it is possible to load other EPSG SRIDs afterwards using the
5065
`gpkgInsertEpsgSRID(srid)`.
5166
Nevertheless, SRIDs of newly created tables are automatically added.
5267
"""
53-
if "SPATIALITE_LIBRARY_PATH" not in os.environ:
54-
raise RuntimeError("The SPATIALITE_LIBRARY_PATH environment variable is not set.")
55-
dbapi_conn.enable_load_extension(True)
56-
dbapi_conn.load_extension(os.environ["SPATIALITE_LIBRARY_PATH"])
57-
dbapi_conn.enable_load_extension(False)
58-
5968
if not dbapi_conn.execute("SELECT CheckGeoPackageMetaData();").fetchone()[0]:
6069
# This only works on the main database
6170
dbapi_conn.execute("SELECT gpkgCreateBaseTables();")
6271

63-
dbapi_conn.execute("SELECT AutoGpkgStart();")
64-
dbapi_conn.execute("SELECT EnableGpkgAmphibiousMode();")
72+
73+
def load_spatialite_gpkg(*args, **kwargs):
74+
"""Load SpatiaLite extension in GeoPackage and initialize internal tables.
75+
76+
See :func:`geoalchemy2.admin.dialects.geopackage.load_geopackage_driver` and
77+
:func:`geoalchemy2.admin.dialects.geopackage.init_geopackage` functions for details about
78+
arguments.
79+
"""
80+
load_geopackage_driver(*args)
81+
init_geopackage(*args, **kwargs)
6582

6683

6784
def _get_spatialite_attrs(bind, table_name, col_name):

geoalchemy2/admin/dialects/sqlite.py

+101-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""This module defines specific functions for SQLite dialect."""
22
import os
3+
from typing import Optional
34

45
from sqlalchemy import text
56
from sqlalchemy.ext.compiler import compiles
@@ -16,36 +17,119 @@
1617
from geoalchemy2.types import _DummyGeometry
1718

1819

19-
def load_spatialite(dbapi_conn, connection_record, init_mode=None):
20-
"""Load SpatiaLite extension in SQLite DB.
20+
def load_spatialite_driver(dbapi_conn, *args):
21+
"""Load SpatiaLite extension in SQLite connection.
2122
22-
The path to the SpatiaLite module should be set in the `SPATIALITE_LIBRARY_PATH` environment
23-
variable.
23+
.. Warning::
24+
The path to the SpatiaLite module should be set in the `SPATIALITE_LIBRARY_PATH`
25+
environment variable.
2426
25-
The init_mode argument can be `'NONE'` to load all EPSG SRIDs, `'WGS84'` to load only the ones
26-
related to WGS84 or `'EMPTY'` to not load any EPSG SRID.
27-
28-
.. Note::
29-
30-
It is possible to load other EPSG SRIDs afterwards using the `InsertEpsgSrid(srid)`.
27+
Args:
28+
dbapi_conn: The DBAPI connection.
3129
"""
3230
if "SPATIALITE_LIBRARY_PATH" not in os.environ:
3331
raise RuntimeError("The SPATIALITE_LIBRARY_PATH environment variable is not set.")
3432
dbapi_conn.enable_load_extension(True)
3533
dbapi_conn.load_extension(os.environ["SPATIALITE_LIBRARY_PATH"])
3634
dbapi_conn.enable_load_extension(False)
3735

38-
init_mode_values = [None, "WGS84", "EMPTY"]
36+
37+
def init_spatialite(
38+
dbapi_conn,
39+
*args,
40+
transaction: bool = False,
41+
init_mode: Optional[str] = None,
42+
journal_mode: Optional[str] = None,
43+
):
44+
"""Initialize internal SpatiaLite tables.
45+
46+
Args:
47+
dbapi_conn: The DBAPI connection.
48+
init_mode: Can be `'NONE'` to load all EPSG SRIDs, `'WGS84'` to load only the ones related
49+
to WGS84 or `'EMPTY'` to not load any EPSG SRID.
50+
51+
.. Note::
52+
53+
It is possible to load other EPSG SRIDs afterwards using `InsertEpsgSrid(srid)`.
54+
55+
transaction: If set to `True` the whole operation will be handled as a single Transaction
56+
(faster). The default value is `False` (slower, but safer).
57+
journal_mode: Change the journal mode to the given value. This can make the table creation
58+
much faster. The possible values are the following: 'DELETE', 'TRUNCATE', 'PERSIST',
59+
'MEMORY', 'WAL' and 'OFF'. See https://www.sqlite.org/pragma.html#pragma_journal_mode
60+
for more details.
61+
62+
.. Warning::
63+
Some values, like 'MEMORY' or 'OFF', can lead to corrupted databases if the process
64+
is interrupted during initialization.
65+
66+
.. Note::
67+
The original value is restored after the initialization.
68+
69+
.. Note::
70+
When using this function as a listener it is not possible to pass the `transaction`,
71+
`init_mode` or `journal_mode` arguments directly. To do this you can either create another
72+
function that calls `load_spatialite` with an hard-coded `init_mode` or just use a lambda::
73+
74+
>>> sqlalchemy.event.listen(
75+
... engine,
76+
... "connect",
77+
... lambda x, y: load_spatialite(
78+
... x,
79+
... y,
80+
... transaction=True,
81+
... init_mode="EMPTY",
82+
... journal_mode="OFF",
83+
... )
84+
... )
85+
"""
86+
func_args = []
87+
88+
# Check the value of the 'transaction' parameter
89+
if not isinstance(transaction, (bool, int)):
90+
raise ValueError("The 'transaction' argument must be True or False.")
91+
else:
92+
func_args.append(str(transaction))
93+
94+
# Check the value of the 'init_mode' parameter
95+
init_mode_values = ["WGS84", "EMPTY"]
3996
if isinstance(init_mode, str):
4097
init_mode = init_mode.upper()
41-
if init_mode not in init_mode_values:
42-
raise ValueError("The 'init_mode' must be in {}".format(init_mode_values))
98+
if init_mode is not None:
99+
if init_mode not in init_mode_values:
100+
raise ValueError("The 'init_mode' argument must be one of {}.".format(init_mode_values))
101+
func_args.append(f"'{init_mode}'")
102+
103+
# Check the value of the 'journal_mode' parameter
104+
journal_mode_values = ["DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"]
105+
if isinstance(journal_mode, str):
106+
journal_mode = journal_mode.upper()
107+
if journal_mode is not None:
108+
if journal_mode not in journal_mode_values:
109+
raise ValueError(
110+
"The 'journal_mode' argument must be one of {}.".format(journal_mode_values)
111+
)
43112

44113
if dbapi_conn.execute("SELECT CheckSpatialMetaData();").fetchone()[0] < 1:
45-
if init_mode is not None:
46-
dbapi_conn.execute("SELECT InitSpatialMetaData('{}');".format(init_mode))
47-
else:
48-
dbapi_conn.execute("SELECT InitSpatialMetaData();")
114+
if journal_mode is not None:
115+
current_journal_mode = dbapi_conn.execute("PRAGMA journal_mode").fetchone()[0]
116+
dbapi_conn.execute("PRAGMA journal_mode = {}".format(journal_mode))
117+
118+
dbapi_conn.execute("SELECT InitSpatialMetaData({});".format(", ".join(func_args)))
119+
120+
if journal_mode is not None:
121+
dbapi_conn.execute("PRAGMA journal_mode = {}".format(current_journal_mode))
122+
123+
124+
def load_spatialite(*args, **kwargs):
125+
"""Load SpatiaLite extension in SQLite DB and initialize internal tables.
126+
127+
See :func:`geoalchemy2.admin.dialects.sqlite.load_spatialite_driver` and
128+
:func:`geoalchemy2.admin.dialects.sqlite.init_spatialite` functions for details about
129+
arguments.
130+
"""
131+
load_spatialite_driver(*args)
132+
init_spatialite(*args, **kwargs)
49133

50134

51135
def _get_spatialite_attrs(bind, table_name, col_name):

tests/__init__.py

+6-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import platform
23
import re
34
import shutil
45
import sys
@@ -17,8 +18,6 @@
1718
from geoalchemy2 import load_spatialite
1819
from geoalchemy2 import load_spatialite_gpkg
1920

20-
# from geoalchemy2 import load_spatialite_gpkg
21-
2221

2322
class test_only_with_dialects:
2423
def __init__(self, *dialects):
@@ -76,6 +75,11 @@ def skip_pg12_sa1217(bind):
7675
pytest.skip("Reflection for PostgreSQL-12 is only supported by sqlalchemy>=1.2.17")
7776

7877

78+
def skip_pypy(msg=None):
79+
if platform.python_implementation() == "PyPy":
80+
pytest.skip(msg if msg is not None else "Incompatible with PyPy")
81+
82+
7983
def select(args):
8084
if version.parse(SA_VERSION) < version.parse("1.4"):
8185
return raw_select(args)
@@ -101,15 +105,6 @@ def copy_and_connect_sqlite_db(input_db, tmp_db, engine_echo, dialect):
101105
db_url, echo=engine_echo, execution_options={"schema_translate_map": {"gis": None}}
102106
)
103107

104-
if input_db.lower().endswith("spatialite_lt_4.sqlite"):
105-
engine._spatialite_version = 3
106-
elif input_db.lower().endswith("spatialite_ge_4.sqlite"):
107-
engine._spatialite_version = 4
108-
elif input_db.lower().endswith(".gpkg"):
109-
engine._spatialite_version = -1
110-
else:
111-
engine._spatialite_version = None
112-
113108
if dialect == "gpkg":
114109
listen(engine, "connect", load_spatialite_gpkg)
115110
else:

tests/conftest.py

-1
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,6 @@ def engine(tmpdir, db_url, _engine_echo):
172172
# For other dialects the engine is directly returned
173173
engine = create_engine(db_url, echo=_engine_echo)
174174
engine.update_execution_options(search_path=["gis", "public"])
175-
engine._spatialite_version = None
176175
return engine
177176

178177

tests/test_functional_sqlite.py

+70-7
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from . import select
2626
from . import skip_case_insensitivity
27+
from . import skip_pypy
2728
from . import test_only_with_dialects
2829
from .schema_fixtures import TransformedGeometry
2930

@@ -207,8 +208,31 @@ def test_all_indexes(self, conn, TableWithIndexes, setup_tables):
207208

208209
class TestMiscellaneous:
209210
@test_only_with_dialects("sqlite-spatialite3", "sqlite-spatialite4")
210-
@pytest.mark.parametrize("init_mode", [None, "WGS84", "EMPTY"])
211-
def test_load_spatialite(self, tmpdir, _engine_echo, init_mode, check_spatialite):
211+
@pytest.mark.parametrize(
212+
[
213+
"transaction",
214+
"init_mode",
215+
"journal_mode",
216+
],
217+
[
218+
pytest.param(False, "WGS84", None),
219+
pytest.param(False, "WGS84", "OFF"),
220+
pytest.param(False, "EMPTY", None),
221+
pytest.param(False, "EMPTY", "OFF"),
222+
pytest.param(True, None, None),
223+
pytest.param(True, None, "OFF"),
224+
pytest.param(True, "WGS84", None),
225+
pytest.param(True, "WGS84", "OFF"),
226+
pytest.param(True, "EMPTY", None),
227+
pytest.param(True, "EMPTY", "OFF"),
228+
],
229+
)
230+
def test_load_spatialite(
231+
self, tmpdir, _engine_echo, check_spatialite, transaction, init_mode, journal_mode
232+
):
233+
if journal_mode == "OFF":
234+
skip_pypy("The journal mode can not be OFF with PyPy.")
235+
212236
# Create empty DB
213237
tmp_db = tmpdir / "test_spatial_db.sqlite"
214238
db_url = f"sqlite:///{tmp_db}"
@@ -219,13 +243,21 @@ def test_load_spatialite(self, tmpdir, _engine_echo, init_mode, check_spatialite
219243

220244
assert not conn.execute(text("PRAGMA main.table_info('geometry_columns')")).fetchall()
221245
assert not conn.execute(text("PRAGMA main.table_info('spatial_ref_sys')")).fetchall()
246+
assert conn.execute(text("PRAGMA journal_mode")).fetchone()[0].upper() == "DELETE"
222247

223-
load_spatialite(conn.connection.dbapi_connection, None, init_mode)
248+
load_spatialite(
249+
conn.connection.dbapi_connection,
250+
transaction=transaction,
251+
init_mode=init_mode,
252+
journal_mode=journal_mode,
253+
)
224254

225255
assert conn.execute(text("SELECT CheckSpatialMetaData();")).scalar() == 3
226256
assert conn.execute(text("PRAGMA main.table_info('geometry_columns')")).fetchall()
227257
assert conn.execute(text("PRAGMA main.table_info('spatial_ref_sys')")).fetchall()
228258

259+
assert conn.execute(text("PRAGMA journal_mode")).fetchone()[0].upper() == "DELETE"
260+
229261
# Check that spatial_ref_sys table was properly populated
230262
nb_srid = conn.execute(text("""SELECT COUNT(*) FROM spatial_ref_sys;""")).scalar()
231263
if init_mode is None:
@@ -235,16 +267,47 @@ def test_load_spatialite(self, tmpdir, _engine_echo, init_mode, check_spatialite
235267
elif init_mode == "EMPTY":
236268
assert nb_srid == 0
237269

270+
# Check that the journal mode is properly reset even when an error is returned by the
271+
# InitSpatialMetaData() function
272+
assert conn.execute(text("PRAGMA journal_mode")).fetchone()[0].upper() == "DELETE"
273+
274+
load_spatialite(
275+
conn.connection.dbapi_connection,
276+
transaction=transaction,
277+
init_mode=init_mode,
278+
journal_mode=journal_mode,
279+
)
280+
281+
assert conn.execute(text("PRAGMA journal_mode")).fetchone()[0].upper() == "DELETE"
282+
283+
@test_only_with_dialects("sqlite-spatialite3", "sqlite-spatialite4")
284+
def test_load_spatialite_unknown_transaction(self, conn):
285+
with pytest.raises(ValueError, match=r"The 'transaction' argument must be True or False\."):
286+
load_spatialite(conn.connection.dbapi_connection, transaction="UNKNOWN MODE")
287+
238288
@test_only_with_dialects("sqlite-spatialite3", "sqlite-spatialite4")
239-
def test_load_spatialite_unknown_init_type(self, monkeypatch, conn):
240-
with pytest.raises(ValueError):
241-
load_spatialite(conn.connection.dbapi_connection, None, "UNKNOWN TYPE")
289+
def test_load_spatialite_unknown_init_type(self, conn):
290+
with pytest.raises(
291+
ValueError, match=r"The 'init_mode' argument must be one of \['WGS84', 'EMPTY'\]\."
292+
):
293+
load_spatialite(conn.connection.dbapi_connection, init_mode="UNKNOWN TYPE")
294+
295+
@test_only_with_dialects("sqlite-spatialite3", "sqlite-spatialite4")
296+
def test_load_spatialite_unknown_journal_mode(self, conn):
297+
with pytest.raises(
298+
ValueError,
299+
match=(
300+
r"The 'journal_mode' argument must be one of "
301+
r"\['DELETE', 'TRUNCATE', 'PERSIST', 'MEMORY', 'WAL', 'OFF'\]\."
302+
),
303+
):
304+
load_spatialite(conn.connection.dbapi_connection, journal_mode="UNKNOWN MODE")
242305

243306
@test_only_with_dialects("sqlite-spatialite3", "sqlite-spatialite4")
244307
def test_load_spatialite_no_env_variable(self, monkeypatch, conn):
245308
monkeypatch.delenv("SPATIALITE_LIBRARY_PATH")
246309
with pytest.raises(RuntimeError):
247-
load_spatialite(conn.connection.dbapi_connection, None)
310+
load_spatialite(conn.connection.dbapi_connection)
248311

249312

250313
class TestInsertionORM:

0 commit comments

Comments
 (0)