From e5cdf420c0f190df61d2820b1f09680850dcb8d5 Mon Sep 17 00:00:00 2001 From: Andrew Olsen Date: Wed, 28 Apr 2021 11:30:38 +1000 Subject: [PATCH 1/6] Add dependency on pymysql, cryptography (for MySQL) --- requirements.txt | 8 +++++++- requirements/requirements.in | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 375a5448d..4acb012b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,9 +11,13 @@ cached-property==1.5.2 certifi==2020.12.5 # via -r requirements/requirements.in cffi==1.14.5 - # via pygit2 + # via + # cryptography + # pygit2 click==7.1.2 # via -r requirements/requirements.in +cryptography==3.4.7 + # via -r requirements/requirements.in greenlet==1.0.0 # via sqlalchemy importlib-metadata==4.0.1 @@ -32,6 +36,8 @@ pycparser==2.20 # via -r requirements/requirements.in pygments==2.8.1 # via -r requirements/requirements.in +pymysql==1.0.2 + # via -r requirements/requirements.in pyodbc==4.0.30 ; platform_system != "Linux" # via -r requirements/requirements.in pyrsistent==0.17.3 diff --git a/requirements/requirements.in b/requirements/requirements.in index 3cacc7f3e..69db3e4ce 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -1,7 +1,9 @@ certifi Click~=7.0 +cryptography jsonschema msgpack~=0.6.1 +pymysql Pygments Rtree~=0.9.4 sqlalchemy From 2ac1df10fa2d403a63e1b18d226d85748365bc62 Mon Sep 17 00:00:00 2001 From: Andrew Olsen Date: Thu, 29 Apr 2021 11:44:39 +1000 Subject: [PATCH 2/6] Added docs/MYSQL_WC.md --- docs/MYSQL_WC.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/MYSQL_WC.md diff --git a/docs/MYSQL_WC.md b/docs/MYSQL_WC.md new file mode 100644 index 000000000..33648175f --- /dev/null +++ b/docs/MYSQL_WC.md @@ -0,0 +1,50 @@ +MySQL Working Copy +----------------------- + +In order to use a [MySQL](https://www.mysql.com/) working copy, you need to have a server running MySQL 8.0 or later. (MySQL 5.6 and later are largely compatible but not officially supported). + +### MySQL partitioning + +MySQL servers are designed so that they can be used for multiple apps simultaneously without those apps interfering with each other. This is usually achieved by storing data from different apps in different databases. + +* A MySQL server contains one or more named databases, which in turn contain tables. A user connected to the server can query tables in any database they have access-rights to without starting a new connection. Two tables can have the same name, as long as they are in different databases. + +MySQL has only a single layer of data separation - the *database*. (Contrast to [PostgreSQL](POSTGIS_WC.md) and [Microsoft SQL Server](SQL_SERVER_WC.md) which have two layers, *database* and *schema*). A Kart MySQL working copy can share a server with any other app, but it expects to be given its own database to manage (just as Kart expects to manage its own GPKG working copy, not share it with data from other apps). Managing the database means that Kart is responsible for initialising that database and importing the data in its initial state, then keeping track of any edits made to that data so that they can be committed. Kart expects that the user will use some other application to modify the data in that database as part of making edits to a Kart working copy. + +This approach differs from other working copy types that only manage a single *schema* within a database. + +### MySQL Connection URI + +A Kart repository with a MySQL working copy needs to be configured with a `mysql://` connection URI. This URI contains how to connect to the server, and the name of the database that should be managed as a working copy by this Kart repository. + +Kart needs a connection URL in the following format: + +`mysql://[user[:password]@][host][:port]/dbname` + +For example, a Kart repo called `airport` might have a URL like the following: + +`mysql://kart_user:password@localhost:1433/airport_kart` + +To configure a Kart repository to use a particular MySQL database as its working copy, specify the `--workingcopy` flag when creating the repository, for example: + +`kart init --workingcopy=mysql://... --import=...` + +The database that Kart is given to manage should be either non-existent or empty at the time Kart is configured, but the server should already be running. + +The database user needs to have full rights to modify objects in the specified database. (eg: via `GRANT ALL PRIVILEGES ON airport_kart.* TO kart_user; FLUSH PRIVILEGES;`). + +### MySQL limitations + +Most geospatial data can be converted to MySQL format without losing any fidelity, but it does have the following limitations. + +#### Three and four dimensional geometries + +Geometries in MySQL are always two-dimensional (meaning they have an X and a Y co-ordinate, or a longitude and a latitude co-ordinate). Three- or four-dimensional geometries, with Z (altitude) or M (measure) co-ordinates, are not supported in MySQL. As a result, Kart datasets containing three- and four-dimensional geometries cannot currently be checked out as MySQL working copies. + +#### Approximated types + +There is one type that Kart supports that has no MySQL equivalent - the `interval`. This type is approximated as `TEXT` in the MySQL working copy. See [APPROXIMATED_TYPES](APPROXIMATED_TYPES.md) for more information. + +#### CRS definitions + +MySQL comes pre-installed with thousands of standard EPSG coordinate reference system definitions. Currently, only the CRS definitions that are already in your MySQL installation are supported - Kart will not create definitions in MySQL to match the custom definitions attached to your Kart datasets. More documentation will be added here when this is supported. From 6468abcf1ada56304d8d5f113cf75a027d668c19 Mon Sep 17 00:00:00 2001 From: Andrew Olsen Date: Thu, 8 Apr 2021 09:43:42 +1200 Subject: [PATCH 3/6] Basic but working implementation of MySQL working copy --- kart/sqlalchemy/create_engine.py | 24 +++ kart/sqlalchemy/upsert.py | 33 +++ kart/working_copy/__init__.py | 11 +- kart/working_copy/db_server.py | 2 +- kart/working_copy/mysql.py | 327 +++++++++++++++++++++++++++++ kart/working_copy/mysql_adapter.py | 222 ++++++++++++++++++++ kart/working_copy/table_defs.py | 33 ++- tests/conftest.py | 53 ++++- tests/test_working_copy_mysql.py | 316 ++++++++++++++++++++++++++++ 9 files changed, 1017 insertions(+), 4 deletions(-) create mode 100644 kart/working_copy/mysql.py create mode 100644 kart/working_copy/mysql_adapter.py create mode 100644 tests/test_working_copy_mysql.py diff --git a/kart/sqlalchemy/create_engine.py b/kart/sqlalchemy/create_engine.py index b723da5c7..db0d15a33 100644 --- a/kart/sqlalchemy/create_engine.py +++ b/kart/sqlalchemy/create_engine.py @@ -119,6 +119,30 @@ def _on_checkout(dbapi_connection, connection_record, connection_proxy): return engine +CANONICAL_MYSQL_SCHEME = "mysql" +INTERNAL_MYSQL_SCHEME = "mysql+pymysql" + + +def mysql_engine(msurl): + def _on_checkout(mysql_conn, connection_record, connection_proxy): + dbcur = mysql_conn.cursor() + dbcur.execute("SET time_zone='UTC';") + dbcur.execute("SET sql_mode = 'ANSI_QUOTES';") + + url = urlsplit(msurl) + if url.scheme != CANONICAL_MYSQL_SCHEME: + raise ValueError("Expecting mysql://") + # url_query = _append_to_query( + # url.query, {"Application Name": "sno"} + # ) + msurl = urlunsplit([INTERNAL_MYSQL_SCHEME, url.netloc, url.path, url.query, ""]) + + engine = sqlalchemy.create_engine(msurl) + sqlalchemy.event.listen(engine, "checkout", _on_checkout) + + return engine + + CANONICAL_SQL_SERVER_SCHEME = "mssql" INTERNAL_SQL_SERVER_SCHEME = "mssql+pyodbc" SQL_SERVER_INSTALL_DOC_URL = ( diff --git a/kart/sqlalchemy/upsert.py b/kart/sqlalchemy/upsert.py index 372cd7b48..c0b3676bf 100644 --- a/kart/sqlalchemy/upsert.py +++ b/kart/sqlalchemy/upsert.py @@ -67,6 +67,39 @@ def compile_upsert_postgresql(upsert_stmt, compiler, **kwargs): return compiler.process(insert_stmt) +@compiles(Upsert, "mysql") +def compile_upsert_mysql(upsert_stmt, compiler, **kwargs): + # See https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html + preparer = compiler.preparer + + def list_cols(col_names, prefix=""): + return ", ".join([prefix + c for c in col_names]) + + values = ", ".join(upsert_stmt.values(compiler)) + table = preparer.format_table(upsert_stmt.table) + all_columns = [preparer.quote(c.name) for c in upsert_stmt.columns] + non_pk_columns = [preparer.quote(c.name) for c in upsert_stmt.non_pk_columns] + + is_gte_version_8 = compiler.dialect.server_version_info[0] >= 8 + + if is_gte_version_8: + # Post 8.0 - don't use VALUES() again to refer to earlier VALUES. + # Instead, alias them. See https://dev.mysql.com/worklog/task/?id=13325 + result = f"INSERT INTO {table} ({list_cols(all_columns)}) " + result += f" VALUES ({values}) AS SOURCE ({list_cols(all_columns)})" + result += " ON DUPLICATE KEY UPDATE " + result += ", ".join([f"{c} = SOURCE.{c}" for c in non_pk_columns]) + + else: + # Pre 8.0 - reuse VALUES to refer to earlier VALUES. + result = f"INSERT INTO {table} ({list_cols(all_columns)}) " + result += f" VALUES ({values})" + result += " ON DUPLICATE KEY UPDATE " + result += ", ".join([f"{c} = VALUES({c})" for c in non_pk_columns]) # 5.7 + + return result + + @compiles(Upsert, "mssql") def compile_upsert_mssql(upsert_stmt, compiler, **kwargs): # See https://docs.microsoft.com/sql/t-sql/statements/merge-transact-sql diff --git a/kart/working_copy/__init__.py b/kart/working_copy/__init__.py index ed5b572b5..481962a90 100644 --- a/kart/working_copy/__init__.py +++ b/kart/working_copy/__init__.py @@ -9,6 +9,7 @@ class WorkingCopyType(Enum): GPKG = auto() POSTGIS = auto() SQL_SERVER = auto() + MYSQL = auto() @classmethod def from_location(cls, location, allow_invalid=False): @@ -17,6 +18,8 @@ def from_location(cls, location, allow_invalid=False): return WorkingCopyType.POSTGIS elif location.startswith("mssql:"): return WorkingCopyType.SQL_SERVER + elif location.startswith("mysql:"): + return WorkingCopyType.MYSQL elif location.lower().endswith(".gpkg"): return WorkingCopyType.GPKG elif allow_invalid: @@ -27,7 +30,8 @@ def from_location(cls, location, allow_invalid=False): "Try one of:\n" " PATH.gpkg\n" " postgresql://[HOST]/DBNAME/DBSCHEMA\n" - " mssql://[HOST]/DBNAME/DBSCHEMA" + " mssql://[HOST]/DBNAME/DBSCHEMA\n" + " mysql://[HOST]/DBNAME" ) @property @@ -44,6 +48,11 @@ def class_(self): from .sqlserver import WorkingCopy_SqlServer return WorkingCopy_SqlServer + elif self is WorkingCopyType.MYSQL: + from .mysql import WorkingCopy_MySql + + return WorkingCopy_MySql + raise RuntimeError("Invalid WorkingCopyType") diff --git a/kart/working_copy/db_server.py b/kart/working_copy/db_server.py index e3b8df08d..d518d3056 100644 --- a/kart/working_copy/db_server.py +++ b/kart/working_copy/db_server.py @@ -90,7 +90,7 @@ def _separate_db_schema(cls, db_uri, expected_path_length=2): """ Removes the DBSCHEMA part off the end of a URI's path, and returns the URI and the DBSCHEMA separately. Useful since generally, it is not necessary (or even possible) to connect to a particular DBSCHEMA directly, - instead, the rest of the URI is used to connect, then the DBSCHEMA is sped + instead, the rest of the URI is used to connect, then the DBSCHEMA is specified in every query / command. """ url = urlsplit(db_uri) url_path = PurePath(url.path) diff --git a/kart/working_copy/mysql.py b/kart/working_copy/mysql.py new file mode 100644 index 000000000..eb317d042 --- /dev/null +++ b/kart/working_copy/mysql.py @@ -0,0 +1,327 @@ +import contextlib +import logging +import time + + +from sqlalchemy.dialects.mysql.base import MySQLIdentifierPreparer +from sqlalchemy.sql.functions import Function +from sqlalchemy.orm import sessionmaker +from sqlalchemy.types import UserDefinedType + +from . import mysql_adapter +from .db_server import DatabaseServer_WorkingCopy +from .table_defs import MySqlKartTables +from kart import crs_util +from kart.geometry import Geometry +from kart.sqlalchemy import text_with_inlined_params +from kart.sqlalchemy.create_engine import mysql_engine + + +class WorkingCopy_MySql(DatabaseServer_WorkingCopy): + """ + MySQL working copy implementation. + + Requirements: + 1. The MySQL server needs to exist + 2. The database user needs to be able to: + - Create the specified database (unless it already exists). + - Create, delete and alter tables and triggers in the specified database. + """ + + WORKING_COPY_TYPE_NAME = "MySQL" + URI_SCHEME = "mysql" + + URI_FORMAT = "//HOST[:PORT]/DBNAME" + URI_VALID_PATH_LENGTHS = (1,) + INVALID_PATH_MESSAGE = "URI path must have one part - the database name" + + def __init__(self, repo, location): + """ + uri: connection string of the form mysql://[user[:password]@][netloc][:port][/dbname][?param1=value1&...] + """ + self.L = logging.getLogger(self.__class__.__qualname__) + + self.repo = repo + self.uri = self.location = location + + self.check_valid_db_uri(self.uri, repo) + self.db_uri, self.db_schema = self._separate_db_schema( + self.uri, expected_path_length=1 + ) + + self.engine = mysql_engine(self.db_uri) + self.sessionmaker = sessionmaker(bind=self.engine) + self.preparer = MySQLIdentifierPreparer(self.engine.dialect) + + self.kart_tables = MySqlKartTables(self.db_schema) + + def _create_table_for_dataset(self, sess, dataset): + table_spec = mysql_adapter.v2_schema_to_mysql_spec(dataset.schema, dataset) + sess.execute( + f"""CREATE TABLE IF NOT EXISTS {self.table_identifier(dataset)} ({table_spec});""" + ) + + def _type_def_for_column_schema(self, col, dataset): + if col.data_type == "geometry": + crs_name = col.extra_type_info.get("geometryCRS") + crs_id = crs_util.get_identifier_int_from_dataset(dataset, crs_name) or 0 + # This user-defined GeometryType adapts Kart's GPKG geometry to SQL Server's native geometry type. + return GeometryType(crs_id) + elif col.data_type == "timestamp": + return TimestampType + else: + # Don't need to specify type information for other columns at present, since we just pass through the values. + return None + + def _write_meta(self, sess, dataset): + """Write the title (as a comment) and the CRS. Other metadata is not stored in a PostGIS WC.""" + self._write_meta_title(sess, dataset) + self._write_meta_crs(sess, dataset) + + def _write_meta_title(self, sess, dataset): + """Write the dataset title as a comment on the table.""" + sess.execute( + f"ALTER TABLE {self.table_identifier(dataset)} COMMENT = :comment", + {"comment": dataset.get_meta_item("title")}, + ) + + def _write_meta_crs(self, sess, dataset): + """Populate the spatial_ref_sys table with data from this dataset.""" + # TODO - MYSQL-PART-2: Actually store CRS, if this is possible. + pass + + def delete_meta(self, dataset): + """Delete any metadata that is only needed by this dataset.""" + # TODO - MYSQL-PART-2: Delete any extra metadata that is not stored in the table itself. + pass + + def _create_spatial_index_post(self, sess, dataset): + # Only implemented as _create_spatial_index_post: + # It is more efficient to write the features first, then index them all in bulk. + + # TODO - MYSQL-PART-2 - We can only create a spatial index if the geometry column is declared + # not-null, but a datasets V2 schema doesn't distinguish between NULL and NOT NULL columns. + # So we don't know if the user would rather have an index, or be able to store NULL values. + return # Find a fix. + + L = logging.getLogger(f"{self.__class__.__qualname__}._create_spatial_index") + + geom_col = dataset.geom_column_name + + L.debug("Creating spatial index for %s.%s", dataset.table_name, geom_col) + t0 = time.monotonic() + + sess.execute( + f"ALTER TABLE {self.table_identifier(dataset)} ADD SPATIAL INDEX({self.quote(geom_col)})" + ) + + L.info("Created spatial index in %ss", time.monotonic() - t0) + + def _drop_spatial_index(self, sess, dataset): + # MySQL deletes the spatial index automatically when the table is deleted. + pass + + def _sno_tracking_name(self, trigger_type, dataset=None): + """Returns the sno-branded name of the trigger reponsible for populating the sno_track table.""" + assert dataset is None + return f"_sno_track_{trigger_type}" + + def _create_triggers(self, sess, dataset): + table_identifier = self.table_identifier(dataset) + pk_column = self.quote(dataset.primary_key) + + sess.execute( + text_with_inlined_params( + f""" + CREATE TRIGGER {self._quoted_tracking_name('ins', dataset)} + AFTER INSERT ON {table_identifier} + FOR EACH ROW + REPLACE INTO {self.KART_TRACK} (table_name, pk) + VALUES (:table_name, NEW.{pk_column}) + """, + {"table_name": dataset.table_name}, + ) + ) + sess.execute( + text_with_inlined_params( + f""" + CREATE TRIGGER {self._quoted_tracking_name('upd', dataset)} + AFTER UPDATE ON {table_identifier} + FOR EACH ROW + REPLACE INTO {self.KART_TRACK} (table_name, pk) + VALUES (:table_name1, OLD.{pk_column}), (:table_name2, NEW.{pk_column}) + """, + {"table_name1": dataset.table_name, "table_name2": dataset.table_name}, + ) + ) + sess.execute( + text_with_inlined_params( + f""" + CREATE TRIGGER {self._quoted_tracking_name('del', dataset)} + AFTER DELETE ON {table_identifier} + FOR EACH ROW + REPLACE INTO {self.KART_TRACK} (table_name, pk) + VALUES (:table_name, OLD.{pk_column}) + """, + {"table_name": dataset.table_name}, + ) + ) + + def _drop_triggers(self, sess, dataset): + sess.execute(f"DROP TRIGGER {self._quoted_tracking_name('ins', dataset)}") + sess.execute(f"DROP TRIGGER {self._quoted_tracking_name('upd', dataset)}") + sess.execute(f"DROP TRIGGER {self._quoted_tracking_name('del', dataset)}") + + @contextlib.contextmanager + def _suspend_triggers(self, sess, dataset): + self._drop_triggers(sess, dataset) + yield + self._create_triggers(sess, dataset) + + def meta_items(self, dataset): + with self.session() as sess: + table_info_sql = """ + SELECT + C.column_name, C.ordinal_position, C.data_type, C.srs_id, + C.character_maximum_length, C.numeric_precision, C.numeric_scale, + KCU.ordinal_position AS pk_ordinal_position + FROM information_schema.columns C + LEFT OUTER JOIN information_schema.key_column_usage KCU + ON (KCU.table_schema = C.table_schema) + AND (KCU.table_name = C.table_name) + AND (KCU.column_name = C.column_name) + WHERE C.table_schema=:table_schema AND C.table_name=:table_name + ORDER BY C.ordinal_position; + """ + r = sess.execute( + table_info_sql, + {"table_schema": self.db_schema, "table_name": dataset.table_name}, + ) + mysql_table_info = list(r) + + spatial_ref_sys_sql = """ + SELECT SRS.* FROM information_schema.st_spatial_reference_systems SRS + LEFT OUTER JOIN information_schema.st_geometry_columns GC ON (GC.srs_id = SRS.srs_id) + WHERE GC.table_schema=:table_schema AND GC.table_name=:table_name; + """ + r = sess.execute( + spatial_ref_sys_sql, + {"table_schema": self.db_schema, "table_name": dataset.table_name}, + ) + mysql_spatial_ref_sys = list(r) + + id_salt = f"{self.db_schema} {dataset.table_name} {self.get_db_tree()}" + schema = mysql_adapter.sqlserver_to_v2_schema( + mysql_table_info, mysql_spatial_ref_sys, id_salt + ) + yield "schema.json", schema.to_column_dicts() + + @classmethod + def try_align_schema_col(cls, old_col_dict, new_col_dict): + old_type = old_col_dict["dataType"] + new_type = new_col_dict["dataType"] + + # Some types have to be approximated as other types in MySQL + if mysql_adapter.APPROXIMATED_TYPES.get(old_type) == new_type: + new_col_dict["dataType"] = new_type = old_type + for key in mysql_adapter.APPROXIMATED_TYPES_EXTRA_TYPE_INFO: + new_col_dict[key] = old_col_dict.get(key) + + # Geometry types don't have to be approximated, except for the Z/M specifiers. + if old_type == "geometry" and new_type == "geometry": + old_gtype = old_col_dict.get("geometryType") + new_gtype = new_col_dict.get("geometryType") + if old_gtype and new_gtype and old_gtype != new_gtype: + if old_gtype.split(" ")[0] == new_gtype: + new_col_dict["geometryType"] = new_gtype = old_gtype + + return new_type == old_type + + _UNSUPPORTED_META_ITEMS = ( + "title", + "description", + "metadata/dataset.json", + "metadata.xml", + ) + + def _remove_hidden_meta_diffs(self, dataset, ds_meta_items, wc_meta_items): + super()._remove_hidden_meta_diffs(dataset, ds_meta_items, wc_meta_items) + + # Nowhere to put these in SQL Server WC + for key in self._UNSUPPORTED_META_ITEMS: + if key in ds_meta_items: + del ds_meta_items[key] + + # Diffing CRS is not yet supported. + for key in list(ds_meta_items.keys()): + if key.startswith("crs/"): + del ds_meta_items[key] + + def _is_meta_update_supported(self, dataset_version, meta_diff): + """ + Returns True if the given meta-diff is supported *without* dropping and rewriting the table. + (Any meta change is supported if we drop and rewrite the table, but of course it is less efficient). + meta_diff - DeltaDiff object containing the meta changes. + """ + # For now, just always drop and rewrite. + return not meta_diff + + +class GeometryType(UserDefinedType): + """UserDefinedType so that V2 geometry is adapted to MySQL binary format.""" + + # TODO: is "axis-order=long-lat" always the correct behaviour? It makes all the tests pass. + AXIS_ORDER = "axis-order=long-lat" + + def __init__(self, crs_id): + self.crs_id = crs_id + + def bind_processor(self, dialect): + # 1. Writing - Python layer - convert Kart geometry to WKB + return lambda geom: geom.to_wkb() + + def bind_expression(self, bindvalue): + # 2. Writing - SQL layer - wrap in call to ST_GeomFromWKB to convert WKB to MySQL binary. + return Function( + "ST_GeomFromWKB", bindvalue, self.crs_id, self.AXIS_ORDER, type_=self + ) + + def column_expression(self, col): + # 3. Reading - SQL layer - wrap in call to ST_AsBinary() to convert MySQL binary to WKB. + return Function("ST_AsBinary", col, self.AXIS_ORDER, type_=self) + + def result_processor(self, dialect, coltype): + # 4. Reading - Python layer - convert WKB to Kart geometry. + return lambda wkb: Geometry.from_wkb(wkb) + + +class DateType(UserDefinedType): + # UserDefinedType to read Dates as text. They are stored in MySQL as Dates but we read them back as text. + def column_expression(self, col): + # Reading - SQL layer - convert date to string in ISO8601. + # https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html + return Function("DATE_FORMAT", col, "%Y-%m-%d", type_=self) + + +class TimeType(UserDefinedType): + # UserDefinedType to read Times as text. They are stored in MySQL as Times but we read them back as text. + def column_expression(self, col): + # Reading - SQL layer - convert timestamp to string in ISO8601. + # https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html + return Function("DATE_FORMAT", col, "%H:%i:%S", type_=self) + + +class TimestampType(UserDefinedType): + """ + UserDefinedType to read Timestamps as text. They are stored in MySQL as Timestamps but we read them back as text. + """ + + def bind_processor(self, dialect): + # 1. Writing - Python layer - remove timezone specifier - MySQL can't read timezone specifiers. + # MySQL requires instead that the timezone is set in the database session (see create_engine.py) + return lambda timestamp: timestamp.rstrip("Z") + + def column_expression(self, col): + # 2. Reading - SQL layer - convert timestamp to string in ISO8601 with Z as the timezone specifier. + # https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html + return Function("DATE_FORMAT", col, "%Y-%m-%dT%H:%i:%SZ", type_=self) diff --git a/kart/working_copy/mysql_adapter.py b/kart/working_copy/mysql_adapter.py new file mode 100644 index 000000000..a38c7ab76 --- /dev/null +++ b/kart/working_copy/mysql_adapter.py @@ -0,0 +1,222 @@ +from kart import crs_util +from kart.schema import Schema, ColumnSchema + +from sqlalchemy.dialects.mysql.base import MySQLIdentifierPreparer, MySQLDialect + + +_PREPARER = MySQLIdentifierPreparer(MySQLDialect()) + + +V2_TYPE_TO_MYSQL_TYPE = { + "boolean": "bit(1)", + "blob": "longblob", + "date": "date", + "float": {0: "float", 32: "float", 64: "double precision"}, + "geometry": "geometry", + "integer": { + 0: "int", + 8: "tinyint", + 16: "smallint", + 32: "int", + 64: "bigint", + }, + "interval": "text", + "numeric": "numeric", + "text": "longtext", + "time": "time", + "timestamp": "timestamp", +} + + +MYSQL_TYPE_TO_V2_TYPE = { + "bit": "boolean", + "tinyint": ("integer", 8), + "smallint": ("integer", 16), + "int": ("integer", 32), + "bigint": ("integer", 64), + "float": ("float", 32), + "double": ("float", 64), + "double precision": ("float", 64), + "binary": "blob", + "blob": "blob", + "char": "text", + "date": "date", + "datetime": "timestamp", + "decimal": "numeric", + "geometry": "geometry", + "numeric": "numeric", + "text": "text", + "time": "time", + "timestamp": "timestamp", + "varchar": "text", + "varbinary": "blob", +} + +for prefix in ["tiny", "medium", "long"]: + MYSQL_TYPE_TO_V2_TYPE[f"{prefix}blob"] = "blob" + MYSQL_TYPE_TO_V2_TYPE[f"{prefix}text"] = "text" + + +# Types that can't be roundtripped perfectly in MySQL, and what they end up as. +APPROXIMATED_TYPES = {"interval": "text"} + +# Extra type info that might be missing/extra due to an approximated type. +APPROXIMATED_TYPES_EXTRA_TYPE_INFO = ("length",) + +MYSQL_GEOMETRY_TYPES = { + "GEOMETRY", + "POINT", + "LINESTRING", + "POLYGON", + "MULTIPOINT", + "MULTILINESTRING", + "MULTIPOLYGON", + "GEOMETRYCOLLECTION", +} + + +def quote(ident): + return _PREPARER.quote(ident) + + +def v2_schema_to_mysql_spec(schema, v2_obj): + """ + Generate the SQL CREATE TABLE spec from a V2 object eg: + 'fid INTEGER, geom POINT WITH CRSID 2136, desc VARCHAR(128), PRIMARY KEY(fid)' + """ + result = [_v2_column_schema_to_mysql_spec(col, v2_obj) for col in schema] + + if schema.pk_columns: + pk_col_names = ", ".join((quote(col.name) for col in schema.pk_columns)) + result.append(f"PRIMARY KEY({pk_col_names})") + + return ", ".join(result) + + +def _v2_column_schema_to_mysql_spec(column_schema, v2_obj): + name = column_schema.name + mysql_type = _v2_type_to_mysql_type(column_schema, v2_obj) + + return " ".join([quote(name), mysql_type]) + + +_MAX_SPECIFIABLE_LENGTH = 0xFFFF + + +def _v2_type_to_mysql_type(column_schema, v2_obj): + """Convert a v2 schema type to a MySQL type.""" + v2_type = column_schema.data_type + if v2_type == "geometry": + return _v2_geometry_type_to_mysql_type(column_schema, v2_obj) + + extra_type_info = column_schema.extra_type_info + + mysql_type_info = V2_TYPE_TO_MYSQL_TYPE.get(v2_type) + if mysql_type_info is None: + raise ValueError(f"Unrecognised data type: {v2_type}") + + if isinstance(mysql_type_info, dict): + return mysql_type_info.get(extra_type_info.get("size", 0)) + + mysql_type = mysql_type_info + + length = extra_type_info.get("length", None) + if length and length > 0 and length <= _MAX_SPECIFIABLE_LENGTH: + if mysql_type == "longtext": + return f"varchar({length})" + elif mysql_type == "longblob": + return f"varbinary({length})" + + if mysql_type == "numeric": + precision = extra_type_info.get("precision", None) + scale = extra_type_info.get("scale", None) + if precision is not None and scale is not None: + return f"numeric({precision},{scale})" + elif precision is not None: + return f"numeric({precision})" + else: + return "numeric" + + return mysql_type + + +def _v2_geometry_type_to_mysql_type(column_schema, v2_obj): + extra_type_info = column_schema.extra_type_info + mysql_type = extra_type_info.get("geometryType", "geometry").split(" ")[0] + + crs_name = extra_type_info.get("geometryCRS") + crs_id = crs_util.get_identifier_int_from_dataset(v2_obj, crs_name) + if crs_id is not None: + mysql_type += f" SRID {crs_id}" + + return mysql_type + + +def sqlserver_to_v2_schema(mysql_table_info, mysql_crs_info, id_salt): + """Generate a V2 schema from the given My SQL metadata.""" + return Schema( + [ + _mysql_to_column_schema(col, mysql_crs_info, id_salt) + for col in mysql_table_info + ] + ) + + +def _mysql_to_column_schema(mysql_col_info, mysql_crs_info, id_salt): + """ + Given the MySQL column info for a particular column, converts it to a ColumnSchema. + + Parameters: + mysql_col_info - info about a single column from mysql_table_info. + mysql_crs_info - info about all the CRS, in case this column is a geometry column. + id_salt - the UUIDs of the generated ColumnSchema are deterministic and depend on + the name and type of the column, and on this salt. + """ + name = mysql_col_info["COLUMN_NAME"] + pk_index = mysql_col_info["pk_ordinal_position"] + if pk_index is not None: + pk_index -= 1 + if mysql_col_info["DATA_TYPE"].upper() in MYSQL_GEOMETRY_TYPES: + data_type, extra_type_info = _mysql_type_to_v2_geometry_type( + mysql_col_info, mysql_crs_info + ) + else: + data_type, extra_type_info = _mysql_type_to_v2_type(mysql_col_info) + + col_id = ColumnSchema.deterministic_id(name, data_type, id_salt) + return ColumnSchema(col_id, name, data_type, pk_index, **extra_type_info) + + +def _mysql_type_to_v2_type(mysql_col_info): + v2_type_info = MYSQL_TYPE_TO_V2_TYPE.get(mysql_col_info["DATA_TYPE"]) + + if isinstance(v2_type_info, tuple): + v2_type = v2_type_info[0] + extra_type_info = {"size": v2_type_info[1]} + else: + v2_type = v2_type_info + extra_type_info = {} + + if v2_type in ("text", "blob"): + length = mysql_col_info["CHARACTER_MAXIMUM_LENGTH"] or None + if length is not None and length > 0 and length <= _MAX_SPECIFIABLE_LENGTH: + extra_type_info["length"] = length + + if v2_type == "numeric": + extra_type_info["precision"] = mysql_col_info["NUMERIC_PRECISION"] or None + extra_type_info["scale"] = mysql_col_info["NUMERIC_SCALE"] or None + + return v2_type, extra_type_info + + +def _mysql_type_to_v2_geometry_type(mysql_col_info, mysql_crs_info): + geometry_type = mysql_col_info["DATA_TYPE"].upper() + geometry_crs = None + + crs_id = mysql_col_info["SRS_ID"] + if crs_id: + crs_info = next((r for r in mysql_crs_info if r["SRS_ID"] == crs_id), None) + if crs_info: + geometry_crs = crs_util.get_identifier_str(crs_info["DEFINITION"]) + + return "geometry", {"geometryType": geometry_type, "geometryCRS": geometry_crs} diff --git a/kart/working_copy/table_defs.py b/kart/working_copy/table_defs.py index c28e1edcc..1db68fbe7 100644 --- a/kart/working_copy/table_defs.py +++ b/kart/working_copy/table_defs.py @@ -9,7 +9,7 @@ UniqueConstraint, ) -from sqlalchemy.types import NVARCHAR +from sqlalchemy.types import NVARCHAR, VARCHAR class TinyInt(Integer): @@ -94,6 +94,37 @@ class PostgisKartTables(AbstractKartTables): """Tables for Kart-specific metadata - PostGIS variant. Nothing special required.""" +class MySqlKartTables(AbstractKartTables): + """ + Tables for Kart-specific metadata - MySQL variant. + Primary keys have to be VARCHAR of a fixed maximum length - + if the total maximum length is too long, MySQL cannot generate an index. + """ + + def __init__(self, db_schema=None, is_kart_branding=False): + # Don't call super since we are redefining self.kart_state and self.kart_track. + self.sqlalchemy_metadata = MetaData() + self.db_schema = db_schema + self.is_kart_branding = is_kart_branding + + self.kart_state = Table( + self.kart_table_name(STATE), + self.sqlalchemy_metadata, + Column("table_name", VARCHAR(256), nullable=False, primary_key=True), + Column("key", VARCHAR(256), nullable=False, primary_key=True), + Column("value", Text, nullable=False), + schema=self.db_schema, + ) + + self.kart_track = Table( + self.kart_table_name(TRACK), + self.sqlalchemy_metadata, + Column("table_name", VARCHAR(256), nullable=False, primary_key=True), + Column("pk", VARCHAR(256), nullable=True, primary_key=True), + schema=self.db_schema, + ) + + class SqlServerKartTables(AbstractKartTables): """ Tables for kart-specific metadata - SQL Server variant. diff --git a/tests/conftest.py b/tests/conftest.py index 9fed94256..1ad5f0fed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,12 @@ from kart.geometry import Geometry from kart.repo import KartRepo -from kart.sqlalchemy.create_engine import gpkg_engine, postgis_engine, sqlserver_engine +from kart.sqlalchemy.create_engine import ( + gpkg_engine, + postgis_engine, + sqlserver_engine, + mysql_engine, +) pytest_plugins = ["helpers_namespace"] @@ -995,3 +1000,49 @@ def _sqlserver_drop_schema_cascade(conn, db_schema): conn.execute(f"DROP TABLE IF EXISTS {table_identifiers};") conn.execute(f"DROP SCHEMA IF EXISTS {db_schema};") + + +@pytest.fixture() +def mysql_db(): + """ + Using docker, you can run a MySQL test - such as those in test_working_copy_mysql - as follows: + docker run -it --rm -d -p 13306:3306 -e MYSQL_ROOT_PASSWORD=PassWord1 mysql + KART_MYSQL_URL='mysql://root:PassWord1@localhost:13306' pytest -k mysql --pdb -vvs + """ + if "KART_MYSQL_URL" not in os.environ: + raise pytest.skip("Requires MySQL - read docstring at conftest.mysql_db") + engine = mysql_engine(os.environ["KART_MYSQL_URL"]) + with engine.connect() as conn: + # test connection: + try: + conn.execute("SELECT @@version;") + except sqlalchemy.exc.DBAPIError: + raise pytest.skip("Requires MySQL") + yield engine + + +@pytest.fixture() +def new_mysql_db_schema(request, mysql_db): + @contextlib.contextmanager + def ctx(create=False): + sha = hashlib.sha1(request.node.nodeid.encode("utf8")).hexdigest()[:20] + schema = f"kart_test_{sha}" + with mysql_db.connect() as conn: + # Start by deleting in case it is left over from last test-run... + conn.execute(f"""DROP SCHEMA IF EXISTS `{schema}`;""") + # Actually create only if create=True, otherwise the test will create it + if create: + conn.execute(f"""CREATE SCHEMA `{schema}`;""") + try: + url = urlsplit(os.environ["KART_MYSQL_URL"]) + url_path = url.path.rstrip("/") + "/" + schema + new_schema_url = urlunsplit( + [url.scheme, url.netloc, url_path, url.query, ""] + ) + yield new_schema_url, schema + finally: + # Clean up - delete it again if it exists. + with mysql_db.connect() as conn: + conn.execute(f"""DROP SCHEMA IF EXISTS `{schema}`;""") + + return ctx diff --git a/tests/test_working_copy_mysql.py b/tests/test_working_copy_mysql.py new file mode 100644 index 000000000..3b19faf2f --- /dev/null +++ b/tests/test_working_copy_mysql.py @@ -0,0 +1,316 @@ +import pytest + +import pygit2 + +from kart.repo import KartRepo +from kart.working_copy import sqlserver_adapter +from kart.working_copy.base import WorkingCopyStatus +from kart.working_copy.db_server import DatabaseServer_WorkingCopy +from test_working_copy import compute_approximated_types + + +H = pytest.helpers.helpers() + + +@pytest.mark.parametrize( + "existing_schema", + [ + pytest.param(True, id="existing-schema"), + pytest.param(False, id="brand-new-schema"), + ], +) +@pytest.mark.parametrize( + "archive,table,commit_sha", + [ + pytest.param("points", H.POINTS.LAYER, H.POINTS.HEAD_SHA, id="points"), + pytest.param("polygons", H.POLYGONS.LAYER, H.POLYGONS.HEAD_SHA, id="polygons"), + pytest.param("table", H.TABLE.LAYER, H.TABLE.HEAD_SHA, id="table"), + ], +) +def test_checkout_workingcopy( + archive, + table, + commit_sha, + existing_schema, + data_archive, + cli_runner, + new_mysql_db_schema, +): + """ Checkout a working copy """ + with data_archive(archive) as repo_path: + repo = KartRepo(repo_path) + H.clear_working_copy() + + with new_mysql_db_schema(create=existing_schema) as ( + mysql_url, + mysql_schema, + ): + r = cli_runner.invoke(["create-workingcopy", mysql_url]) + assert r.exit_code == 0, r.stderr + assert ( + r.stdout.splitlines()[-1] + == f"Creating working copy at {DatabaseServer_WorkingCopy.strip_password(mysql_url)} ..." + ) + + r = cli_runner.invoke(["status"]) + assert r.exit_code == 0, r.stderr + assert r.stdout.splitlines() == [ + "On branch main", + "", + "Nothing to commit, working copy clean", + ] + + wc = repo.working_copy + assert wc.status() & WorkingCopyStatus.INITIALISED + assert wc.status() & WorkingCopyStatus.HAS_DATA + + head_tree_id = repo.head_tree.hex + assert wc.assert_db_tree_match(head_tree_id) + + +@pytest.mark.parametrize( + "existing_schema", + [ + pytest.param(True, id="existing-schema"), + pytest.param(False, id="brand-new-schema"), + ], +) +def test_init_import( + existing_schema, + new_mysql_db_schema, + data_archive, + tmp_path, + cli_runner, +): + """ Import the GeoPackage (eg. `kx-foo-layer.gpkg`) into a Kart repository. """ + repo_path = tmp_path / "repo" + repo_path.mkdir() + + with data_archive("gpkg-points") as data: + with new_mysql_db_schema(create=existing_schema) as ( + mysql_url, + mysql_schema, + ): + r = cli_runner.invoke( + [ + "init", + "--import", + f"gpkg:{data / 'nz-pa-points-topo-150k.gpkg'}", + str(repo_path), + f"--workingcopy-path={mysql_url}", + ] + ) + assert r.exit_code == 0, r.stderr + assert (repo_path / ".kart" / "HEAD").exists() + + repo = KartRepo(repo_path) + wc = repo.working_copy + assert wc.status() & WorkingCopyStatus.INITIALISED + assert wc.status() & WorkingCopyStatus.HAS_DATA + + assert wc.location == mysql_url + + +@pytest.mark.parametrize( + "archive,table,commit_sha", + [ + pytest.param("points", H.POINTS.LAYER, H.POINTS.HEAD_SHA, id="points"), + pytest.param("polygons", H.POLYGONS.LAYER, H.POLYGONS.HEAD_SHA, id="polygons"), + pytest.param("table", H.TABLE.LAYER, H.TABLE.HEAD_SHA, id="table"), + ], +) +def test_commit_edits( + archive, + table, + commit_sha, + data_archive, + cli_runner, + new_mysql_db_schema, + edit_points, + edit_polygons, + edit_table, +): + """ Checkout a working copy and make some edits """ + with data_archive(archive) as repo_path: + repo = KartRepo(repo_path) + H.clear_working_copy() + + with new_mysql_db_schema() as (mysql_url, mysql_schema): + r = cli_runner.invoke(["create-workingcopy", mysql_url]) + assert r.exit_code == 0, r.stderr + + r = cli_runner.invoke(["status"]) + assert r.exit_code == 0, r.stderr + assert r.stdout.splitlines() == [ + "On branch main", + "", + "Nothing to commit, working copy clean", + ] + + wc = repo.working_copy + assert wc.status() & WorkingCopyStatus.INITIALISED + assert wc.status() & WorkingCopyStatus.HAS_DATA + + with wc.session() as sess: + if archive == "points": + edit_points(sess, repo.datasets()[H.POINTS.LAYER], wc) + elif archive == "polygons": + edit_polygons(sess, repo.datasets()[H.POLYGONS.LAYER], wc) + elif archive == "table": + edit_table(sess, repo.datasets()[H.TABLE.LAYER], wc) + + r = cli_runner.invoke(["status"]) + assert r.exit_code == 0, r.stderr + assert r.stdout.splitlines() == [ + "On branch main", + "", + "Changes in working copy:", + ' (use "kart commit" to commit)', + ' (use "kart reset" to discard changes)', + "", + f" {table}:", + " feature:", + " 1 inserts", + " 2 updates", + " 5 deletes", + ] + orig_head = repo.head.peel(pygit2.Commit).hex + + r = cli_runner.invoke(["commit", "-m", "test_commit"]) + assert r.exit_code == 0, r.stderr + + r = cli_runner.invoke(["status"]) + assert r.exit_code == 0, r.stderr + assert r.stdout.splitlines() == [ + "On branch main", + "", + "Nothing to commit, working copy clean", + ] + + new_head = repo.head.peel(pygit2.Commit).hex + assert new_head != orig_head + + r = cli_runner.invoke(["checkout", "HEAD^"]) + + assert repo.head.peel(pygit2.Commit).hex == orig_head + + +def test_edit_schema(data_archive, cli_runner, new_mysql_db_schema): + with data_archive("polygons") as repo_path: + repo = KartRepo(repo_path) + H.clear_working_copy() + + with new_mysql_db_schema() as (mysql_url, mysql_schema): + r = cli_runner.invoke(["create-workingcopy", mysql_url]) + assert r.exit_code == 0, r.stderr + + wc = repo.working_copy + assert wc.status() & WorkingCopyStatus.INITIALISED + assert wc.status() & WorkingCopyStatus.HAS_DATA + + r = cli_runner.invoke(["diff", "--output-format=quiet"]) + assert r.exit_code == 0, r.stderr + + with wc.session() as sess: + sess.execute( + f"""ALTER TABLE "{mysql_schema}"."{H.POLYGONS.LAYER}" ADD colour NVARCHAR(32);""" + ) + sess.execute( + f"""ALTER TABLE "{mysql_schema}"."{H.POLYGONS.LAYER}" DROP COLUMN survey_reference;""" + ) + + r = cli_runner.invoke(["diff"]) + assert r.exit_code == 0, r.stderr + diff = r.stdout.splitlines() + + # New column "colour" has an ID is deterministically generated from the commit hash, + # but we don't care exactly what it is. + try: + colour_id_line = diff[-6] + except KeyError: + colour_id_line = "" + + assert diff[-46:] == [ + "--- nz_waca_adjustments:meta:schema.json", + "+++ nz_waca_adjustments:meta:schema.json", + " [", + " {", + ' "id": "79d3c4ca-3abd-0a30-2045-45169357113c",', + ' "name": "id",', + ' "dataType": "integer",', + ' "primaryKeyIndex": 0,', + ' "size": 64', + " },", + " {", + ' "id": "c1d4dea1-c0ad-0255-7857-b5695e3ba2e9",', + ' "name": "geom",', + ' "dataType": "geometry",', + ' "geometryType": "MULTIPOLYGON",', + ' "geometryCRS": "EPSG:4167"', + " },", + " {", + ' "id": "d3d4b64b-d48e-4069-4bb5-dfa943d91e6b",', + ' "name": "date_adjusted",', + ' "dataType": "timestamp"', + " },", + "- {", + '- "id": "dff34196-229d-f0b5-7fd4-b14ecf835b2c",', + '- "name": "survey_reference",', + '- "dataType": "text",', + '- "length": 50', + "- },", + " {", + ' "id": "13dc4918-974e-978f-05ce-3b4321077c50",', + ' "name": "adjusted_nodes",', + ' "dataType": "integer",', + ' "size": 32', + " },", + "+ {", + colour_id_line, + '+ "name": "colour",', + '+ "dataType": "text",', + '+ "length": 32', + "+ },", + " ]", + ] + + orig_head = repo.head.peel(pygit2.Commit).hex + + r = cli_runner.invoke(["commit", "-m", "test_commit"]) + assert r.exit_code == 0, r.stderr + + r = cli_runner.invoke(["status"]) + assert r.exit_code == 0, r.stderr + assert r.stdout.splitlines() == [ + "On branch main", + "", + "Nothing to commit, working copy clean", + ] + + new_head = repo.head.peel(pygit2.Commit).hex + assert new_head != orig_head + + r = cli_runner.invoke(["checkout", "HEAD^"]) + + assert repo.head.peel(pygit2.Commit).hex == orig_head + + +def test_approximated_types(): + assert sqlserver_adapter.APPROXIMATED_TYPES == compute_approximated_types( + sqlserver_adapter.V2_TYPE_TO_MS_TYPE, sqlserver_adapter.MS_TYPE_TO_V2_TYPE + ) + + +def test_types_roundtrip(data_archive, cli_runner, new_mysql_db_schema): + with data_archive("types") as repo_path: + repo = KartRepo(repo_path) + H.clear_working_copy() + + with new_mysql_db_schema() as (mysql_url, mysql_schema): + repo.config["kart.workingcopy.location"] = mysql_url + r = cli_runner.invoke(["checkout"]) + + # If type-approximation roundtrip code isn't working, + # we would get spurious diffs on types that SQL server doesn't support. + r = cli_runner.invoke(["diff", "--exit-code"]) + assert r.exit_code == 0, r.stdout From abedf459ce94a4745f730337d4065047078166fd Mon Sep 17 00:00:00 2001 From: Andrew Olsen Date: Wed, 28 Apr 2021 11:26:34 +1000 Subject: [PATCH 4/6] MySQL post review 1 - address rcoup comments --- docs/DATASETS_v2.md | 2 ++ kart/sqlalchemy/create_engine.py | 7 +++---- kart/working_copy/mysql.py | 16 +++++++++++++++- kart/working_copy/mysql_adapter.py | 8 ++++---- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/DATASETS_v2.md b/docs/DATASETS_v2.md index d054cf50f..9332fa1d4 100644 --- a/docs/DATASETS_v2.md +++ b/docs/DATASETS_v2.md @@ -266,3 +266,5 @@ Geometries are encoded using the Standard GeoPackageBinary format specified in [ - Geometries with a Z component have an XYZ envelope. - Other geometries have an XY envelope. 5. The `srs_id` is always 0, since this information not stored in the geometry object but is stored on a per-column basis in `meta/schema.json` in the `geometryCRS` field. + +**Note on axis-ordering:** As required by the GeoPackageBinary format, which Kart uses internally for geometry storage, Kart's axis-ordering is always `(longitude, latitude)`. This same axis-ordering is also used in Kart's JSON and GeoJSON output. diff --git a/kart/sqlalchemy/create_engine.py b/kart/sqlalchemy/create_engine.py index db0d15a33..56e358d94 100644 --- a/kart/sqlalchemy/create_engine.py +++ b/kart/sqlalchemy/create_engine.py @@ -132,10 +132,9 @@ def _on_checkout(mysql_conn, connection_record, connection_proxy): url = urlsplit(msurl) if url.scheme != CANONICAL_MYSQL_SCHEME: raise ValueError("Expecting mysql://") - # url_query = _append_to_query( - # url.query, {"Application Name": "sno"} - # ) - msurl = urlunsplit([INTERNAL_MYSQL_SCHEME, url.netloc, url.path, url.query, ""]) + url_path = url.path or "/" # Empty path doesn't work with non-empty query. + url_query = _append_to_query(url.query, {"program_name": "kart"}) + msurl = urlunsplit([INTERNAL_MYSQL_SCHEME, url.netloc, url_path, url_query, ""]) engine = sqlalchemy.create_engine(msurl) sqlalchemy.event.listen(engine, "checkout", _on_checkout) diff --git a/kart/working_copy/mysql.py b/kart/working_copy/mysql.py index eb317d042..a1dad6de0 100644 --- a/kart/working_copy/mysql.py +++ b/kart/working_copy/mysql.py @@ -20,6 +20,16 @@ class WorkingCopy_MySql(DatabaseServer_WorkingCopy): """ MySQL working copy implementation. + Unlike other database-servers (eg Postgresql, Microsoft SQL Server) - MySQL has no concept of a schema (where + "schema" means a type of namespace, that exists within a database, that exists within a database server / cluster). + So typically, a Kart manages a working copy by managing every table inside an entire db schema, ie: + >>> postgresql://HOST[:PORT]/DBNAME/DBSCHEMA + But in the case of a MySQL working copy, where schemas don't exist, Kart manages a working copy by managing + every table inside entire database: + >>> mysql://HOST[:PORT]/DBNAME + + Note that, for compatibility with other working copy implementations, self.db_schema (and escaped variant + self.DB_SCHEMA) actually contain the database name in this implementation. Requirements: 1. The MySQL server needs to exist @@ -228,6 +238,7 @@ def try_align_schema_col(cls, old_col_dict, new_col_dict): new_col_dict[key] = old_col_dict.get(key) # Geometry types don't have to be approximated, except for the Z/M specifiers. + # MySQL can't restrict a geometry column so that it is only 2D (or 3D, or 4D). if old_type == "geometry" and new_type == "geometry": old_gtype = old_col_dict.get("geometryType") new_gtype = new_col_dict.get("geometryType") @@ -270,7 +281,10 @@ def _is_meta_update_supported(self, dataset_version, meta_diff): class GeometryType(UserDefinedType): """UserDefinedType so that V2 geometry is adapted to MySQL binary format.""" - # TODO: is "axis-order=long-lat" always the correct behaviour? It makes all the tests pass. + # In Kart, all geometries are stored as WKB with axis-order=long-lat - since this is the GPKG + # standard, and a Kart geometry is a normalised GPKG geometry. MySQL has to be explicitly told + # that this is the ordering we use in WKB, since MySQL would otherwise expect lat-long ordering + # as specified by ISO 19128:2005. AXIS_ORDER = "axis-order=long-lat" def __init__(self, crs_id): diff --git a/kart/working_copy/mysql_adapter.py b/kart/working_copy/mysql_adapter.py index a38c7ab76..f20a63070 100644 --- a/kart/working_copy/mysql_adapter.py +++ b/kart/working_copy/mysql_adapter.py @@ -28,6 +28,8 @@ } +_TEXT_AND_BLOB_PREFIXES = ("tiny", "medium", "long") + MYSQL_TYPE_TO_V2_TYPE = { "bit": "boolean", "tinyint": ("integer", 8), @@ -50,12 +52,10 @@ "timestamp": "timestamp", "varchar": "text", "varbinary": "blob", + **{f"{prefix}text": "text" for prefix in _TEXT_AND_BLOB_PREFIXES}, + **{f"{prefix}blob": "blob" for prefix in _TEXT_AND_BLOB_PREFIXES}, } -for prefix in ["tiny", "medium", "long"]: - MYSQL_TYPE_TO_V2_TYPE[f"{prefix}blob"] = "blob" - MYSQL_TYPE_TO_V2_TYPE[f"{prefix}text"] = "text" - # Types that can't be roundtripped perfectly in MySQL, and what they end up as. APPROXIMATED_TYPES = {"interval": "text"} From a502d62e972bbf4a14298c667f7f836deda35185 Mon Sep 17 00:00:00 2001 From: Andrew Olsen Date: Thu, 29 Apr 2021 14:42:58 +1000 Subject: [PATCH 5/6] Remove Z/M approximation code for MySQL, add an error message instead. 3D and 4D geometries are not supported at all by MySQL. --- kart/working_copy/mysql.py | 9 --------- kart/working_copy/mysql_adapter.py | 11 ++++++++++- tests/data/types.tgz | Bin 16507 -> 19214 bytes tests/test_working_copy_mysql.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/kart/working_copy/mysql.py b/kart/working_copy/mysql.py index a1dad6de0..e22694799 100644 --- a/kart/working_copy/mysql.py +++ b/kart/working_copy/mysql.py @@ -237,15 +237,6 @@ def try_align_schema_col(cls, old_col_dict, new_col_dict): for key in mysql_adapter.APPROXIMATED_TYPES_EXTRA_TYPE_INFO: new_col_dict[key] = old_col_dict.get(key) - # Geometry types don't have to be approximated, except for the Z/M specifiers. - # MySQL can't restrict a geometry column so that it is only 2D (or 3D, or 4D). - if old_type == "geometry" and new_type == "geometry": - old_gtype = old_col_dict.get("geometryType") - new_gtype = new_col_dict.get("geometryType") - if old_gtype and new_gtype and old_gtype != new_gtype: - if old_gtype.split(" ")[0] == new_gtype: - new_col_dict["geometryType"] = new_gtype = old_gtype - return new_type == old_type _UNSUPPORTED_META_ITEMS = ( diff --git a/kart/working_copy/mysql_adapter.py b/kart/working_copy/mysql_adapter.py index f20a63070..be0e29dac 100644 --- a/kart/working_copy/mysql_adapter.py +++ b/kart/working_copy/mysql_adapter.py @@ -1,4 +1,5 @@ from kart import crs_util +from kart.exceptions import NotYetImplemented from kart.schema import Schema, ColumnSchema from sqlalchemy.dialects.mysql.base import MySQLIdentifierPreparer, MySQLDialect @@ -142,7 +143,15 @@ def _v2_type_to_mysql_type(column_schema, v2_obj): def _v2_geometry_type_to_mysql_type(column_schema, v2_obj): extra_type_info = column_schema.extra_type_info - mysql_type = extra_type_info.get("geometryType", "geometry").split(" ")[0] + geometry_type = extra_type_info.get("geometryType", "geometry") + geometry_type_parts = geometry_type.strip().split(" ") + if len(geometry_type_parts) > 1: + raise NotYetImplemented( + "Three or four dimensional geometries are not supported by MySQL working copy: " + f'("{column_schema.name}" {geometry_type.upper()})' + ) + + mysql_type = geometry_type_parts[0] crs_name = extra_type_info.get("geometryCRS") crs_id = crs_util.get_identifier_int_from_dataset(v2_obj, crs_name) diff --git a/tests/data/types.tgz b/tests/data/types.tgz index 7914649c91d20a33663bf265595ccea06eaac1df..46b894f56a490bb33755c6175594030bee03b68e 100644 GIT binary patch literal 19214 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 diff --git a/tests/test_working_copy_mysql.py b/tests/test_working_copy_mysql.py index 3b19faf2f..9a4466633 100644 --- a/tests/test_working_copy_mysql.py +++ b/tests/test_working_copy_mysql.py @@ -308,7 +308,7 @@ def test_types_roundtrip(data_archive, cli_runner, new_mysql_db_schema): with new_mysql_db_schema() as (mysql_url, mysql_schema): repo.config["kart.workingcopy.location"] = mysql_url - r = cli_runner.invoke(["checkout"]) + r = cli_runner.invoke(["checkout", "2d-geometry-only"]) # If type-approximation roundtrip code isn't working, # we would get spurious diffs on types that SQL server doesn't support. From 99dd089e1684cbc8df1b479f258715a833021a6b Mon Sep 17 00:00:00 2001 From: Andrew Olsen Date: Fri, 30 Apr 2021 10:03:27 +1000 Subject: [PATCH 6/6] Enable MYSQL CI-tests on linux --- .github/workflows/build.yml | 6 ++++++ Makefile | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e394ea09d..5fe486ccf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,12 @@ jobs: -e SA_PASSWORD=PassWord1 ports: - 1433:1433 + mysql: + image: mysql + options: >- + -e MYSQL_ROOT_PASSWORD=PassWord1 + ports: + - 3306:3306 - name: macOS id: Darwin diff --git a/Makefile b/Makefile index 5bc2a9ebe..9631b7cac 100644 --- a/Makefile +++ b/Makefile @@ -188,6 +188,7 @@ ifeq ($(PLATFORM),Linux) # (github actions only supports docker containers on linux) ci-test: export KART_POSTGRES_URL ?= postgresql://postgres:@localhost:5432/postgres ci-test: export KART_SQLSERVER_URL ?= mssql://sa:PassWord1@localhost:1433/master +ci-test: export KART_MYSQL_URL ?= mysql://root:PassWord1@localhost:3306 endif ci-test: