instructure-pysqlsync 0.8.5.dev0__py3-none-any.whl
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.
- instructure_pysqlsync-0.8.5.dev0.dist-info/METADATA +244 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/RECORD +102 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/WHEEL +5 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/licenses/LICENSE +21 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/top_level.txt +1 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/zip-safe +1 -0
- pysqlsync/__init__.py +15 -0
- pysqlsync/base.py +1386 -0
- pysqlsync/connection.py +101 -0
- pysqlsync/data/__init__.py +0 -0
- pysqlsync/data/exchange.py +184 -0
- pysqlsync/data/generator.py +305 -0
- pysqlsync/dialect/README.md +35 -0
- pysqlsync/dialect/__init__.py +0 -0
- pysqlsync/dialect/delta/__init__.py +0 -0
- pysqlsync/dialect/delta/data_types.py +51 -0
- pysqlsync/dialect/delta/dependency.py +7 -0
- pysqlsync/dialect/delta/engine.py +18 -0
- pysqlsync/dialect/delta/generator.py +74 -0
- pysqlsync/dialect/delta/object_types.py +87 -0
- pysqlsync/dialect/mssql/README.md +23 -0
- pysqlsync/dialect/mssql/__init__.py +0 -0
- pysqlsync/dialect/mssql/connection.py +146 -0
- pysqlsync/dialect/mssql/data_types.py +113 -0
- pysqlsync/dialect/mssql/dependency.py +9 -0
- pysqlsync/dialect/mssql/discovery.py +99 -0
- pysqlsync/dialect/mssql/engine.py +20 -0
- pysqlsync/dialect/mssql/generator.py +88 -0
- pysqlsync/dialect/mssql/mutation.py +46 -0
- pysqlsync/dialect/mssql/object_types.py +135 -0
- pysqlsync/dialect/mysql/__init__.py +0 -0
- pysqlsync/dialect/mysql/connection.py +126 -0
- pysqlsync/dialect/mysql/data_types.py +40 -0
- pysqlsync/dialect/mysql/dependency.py +10 -0
- pysqlsync/dialect/mysql/discovery.py +145 -0
- pysqlsync/dialect/mysql/engine.py +20 -0
- pysqlsync/dialect/mysql/generator.py +140 -0
- pysqlsync/dialect/mysql/mutation.py +65 -0
- pysqlsync/dialect/mysql/object_types.py +75 -0
- pysqlsync/dialect/oracle/README.md +22 -0
- pysqlsync/dialect/oracle/__init__.py +0 -0
- pysqlsync/dialect/oracle/connection.py +141 -0
- pysqlsync/dialect/oracle/data_types.py +104 -0
- pysqlsync/dialect/oracle/dependency.py +9 -0
- pysqlsync/dialect/oracle/discovery.py +231 -0
- pysqlsync/dialect/oracle/engine.py +20 -0
- pysqlsync/dialect/oracle/generator.py +93 -0
- pysqlsync/dialect/oracle/mutation.py +37 -0
- pysqlsync/dialect/oracle/object_types.py +59 -0
- pysqlsync/dialect/postgresql/__init__.py +0 -0
- pysqlsync/dialect/postgresql/connection.py +133 -0
- pysqlsync/dialect/postgresql/data_types.py +6 -0
- pysqlsync/dialect/postgresql/dependency.py +9 -0
- pysqlsync/dialect/postgresql/discovery.py +392 -0
- pysqlsync/dialect/postgresql/engine.py +20 -0
- pysqlsync/dialect/postgresql/generator.py +99 -0
- pysqlsync/dialect/postgresql/mutation.py +82 -0
- pysqlsync/dialect/postgresql/object_types.py +99 -0
- pysqlsync/dialect/redshift/__init__.py +0 -0
- pysqlsync/dialect/redshift/connection.py +156 -0
- pysqlsync/dialect/redshift/data_types.py +10 -0
- pysqlsync/dialect/redshift/dependency.py +9 -0
- pysqlsync/dialect/redshift/engine.py +20 -0
- pysqlsync/dialect/redshift/generator.py +82 -0
- pysqlsync/dialect/snowflake/__init__.py +0 -0
- pysqlsync/dialect/snowflake/data_types.py +18 -0
- pysqlsync/dialect/snowflake/dependency.py +9 -0
- pysqlsync/dialect/snowflake/engine.py +18 -0
- pysqlsync/dialect/snowflake/generator.py +64 -0
- pysqlsync/dialect/snowflake/object_types.py +84 -0
- pysqlsync/dialect/test/__init__.py +0 -0
- pysqlsync/dialect/test/dependency.py +10 -0
- pysqlsync/dialect/trino/__init__.py +0 -0
- pysqlsync/dialect/trino/connection.py +86 -0
- pysqlsync/dialect/trino/dependency.py +9 -0
- pysqlsync/dialect/trino/discovery.py +21 -0
- pysqlsync/dialect/trino/engine.py +20 -0
- pysqlsync/dialect/trino/generator.py +31 -0
- pysqlsync/factory.py +169 -0
- pysqlsync/formation/__init__.py +0 -0
- pysqlsync/formation/constraints.py +78 -0
- pysqlsync/formation/data_types.py +192 -0
- pysqlsync/formation/discovery.py +390 -0
- pysqlsync/formation/inspection.py +179 -0
- pysqlsync/formation/mutation.py +320 -0
- pysqlsync/formation/object_dict.py +79 -0
- pysqlsync/formation/object_types.py +872 -0
- pysqlsync/formation/py_to_sql.py +1134 -0
- pysqlsync/formation/sql_to_py.py +194 -0
- pysqlsync/model/__init__.py +0 -0
- pysqlsync/model/data_types.py +397 -0
- pysqlsync/model/entity_types.py +68 -0
- pysqlsync/model/id_types.py +193 -0
- pysqlsync/model/key_types.py +38 -0
- pysqlsync/model/properties.py +234 -0
- pysqlsync/py.typed +0 -0
- pysqlsync/python_types.py +192 -0
- pysqlsync/resultset.py +106 -0
- pysqlsync/util/__init__.py +0 -0
- pysqlsync/util/dataclasses.py +80 -0
- pysqlsync/util/dispatch.py +28 -0
- pysqlsync/util/typing.py +13 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pysqlsync.formation.mutation import Mutator
|
|
5
|
+
from pysqlsync.formation.object_types import Column, ColumnFormationError, StatementList, Table, join_or_none
|
|
6
|
+
|
|
7
|
+
from .object_types import MSSQLColumn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MSSQLMutator(Mutator):
|
|
11
|
+
def mutate_column_stmt(self, source: Column, target: Column) -> Optional[str]:
|
|
12
|
+
if source.identity != target.identity:
|
|
13
|
+
raise ColumnFormationError(
|
|
14
|
+
"operation not permitted; cannot add or drop identity property",
|
|
15
|
+
source.name,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if source.data_type != target.data_type or source.nullable != target.nullable:
|
|
19
|
+
nullable = " NOT NULL" if not target.nullable else ""
|
|
20
|
+
return f"ALTER COLUMN {source.name} {target.data_type}{nullable}"
|
|
21
|
+
else:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def mutate_table_stmt(self, source: Table, target: Table) -> Optional[str]:
|
|
25
|
+
statements = StatementList()
|
|
26
|
+
|
|
27
|
+
constraints: list[str] = []
|
|
28
|
+
common_columns = source.columns.intersection(target.columns)
|
|
29
|
+
for source_column, target_column in common_columns:
|
|
30
|
+
source_def = source_column.default
|
|
31
|
+
target_def = target_column.default
|
|
32
|
+
|
|
33
|
+
if source_def == target_def:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
name = typing.cast(MSSQLColumn, source_column).default_constraint_name
|
|
37
|
+
if source_def is not None:
|
|
38
|
+
constraints.append(f"DROP CONSTRAINT {name}")
|
|
39
|
+
if target_def is not None:
|
|
40
|
+
constraints.append(f"ADD CONSTRAINT {name} DEFAULT {target_def} FOR {source_column.name}")
|
|
41
|
+
|
|
42
|
+
if constraints:
|
|
43
|
+
statements.append(source.alter_table_stmt(constraints))
|
|
44
|
+
|
|
45
|
+
statements.append(super().mutate_table_stmt(source, target))
|
|
46
|
+
return join_or_none(statements)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import string
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
|
|
6
|
+
from pysqlsync.formation.object_types import (
|
|
7
|
+
Column,
|
|
8
|
+
EnumTable,
|
|
9
|
+
Namespace,
|
|
10
|
+
ObjectFactory,
|
|
11
|
+
Table,
|
|
12
|
+
join_or_none,
|
|
13
|
+
)
|
|
14
|
+
from pysqlsync.model.data_types import SqlDataType, quote
|
|
15
|
+
from pysqlsync.model.id_types import LocalId
|
|
16
|
+
from pysqlsync.util.typing import override
|
|
17
|
+
|
|
18
|
+
ID_GENERATOR = random.Random()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class MSSQLDefault:
|
|
23
|
+
"""
|
|
24
|
+
Default value constraint.
|
|
25
|
+
|
|
26
|
+
:param name: Globally unique name for the default constraint.
|
|
27
|
+
:param expr: SQL expression for the default value.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
expr: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MSSQLColumn(Column):
|
|
35
|
+
"""
|
|
36
|
+
A column in a Microsoft SQL Server database table.
|
|
37
|
+
|
|
38
|
+
:param default_constraint_name: The name of the constraint for DEFAULT.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
default_constraint_name: LocalId
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
name: LocalId,
|
|
46
|
+
data_type: SqlDataType,
|
|
47
|
+
nullable: bool,
|
|
48
|
+
default: Union[MSSQLDefault, str, None] = None,
|
|
49
|
+
identity: bool = False,
|
|
50
|
+
description: Optional[str] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(
|
|
53
|
+
name,
|
|
54
|
+
data_type,
|
|
55
|
+
nullable=nullable,
|
|
56
|
+
default=default.expr if isinstance(default, MSSQLDefault) else default,
|
|
57
|
+
identity=identity,
|
|
58
|
+
description=description,
|
|
59
|
+
)
|
|
60
|
+
if isinstance(default, MSSQLDefault):
|
|
61
|
+
self.default_constraint_name = LocalId(default.name)
|
|
62
|
+
else:
|
|
63
|
+
r = "".join(ID_GENERATOR.choices(string.ascii_lowercase, k=6))
|
|
64
|
+
self.default_constraint_name = LocalId(f"df_{self.name.local_id}_{r}")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def data_spec(self) -> str:
|
|
68
|
+
nullable = " NOT NULL" if not self.nullable else ""
|
|
69
|
+
name = self.default_constraint_name
|
|
70
|
+
default = f" CONSTRAINT {name} DEFAULT {self.default}" if self.default is not None else ""
|
|
71
|
+
identity = " IDENTITY" if self.identity else ""
|
|
72
|
+
return f"{self.data_type}{nullable}{default}{identity}"
|
|
73
|
+
|
|
74
|
+
@override
|
|
75
|
+
def create_stmt(self) -> str:
|
|
76
|
+
return f"ADD {self.column_spec}"
|
|
77
|
+
|
|
78
|
+
@override
|
|
79
|
+
def drop_stmt(self) -> str:
|
|
80
|
+
if self.default is not None:
|
|
81
|
+
name = self.default_constraint_name
|
|
82
|
+
return f"DROP CONSTRAINT {name}, COLUMN {self.name}"
|
|
83
|
+
else:
|
|
84
|
+
return f"DROP COLUMN {self.name}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MSSQLTable(Table):
|
|
88
|
+
def alter_table_stmt(self, statements: list[str]) -> str:
|
|
89
|
+
return "\n".join(f"ALTER TABLE {self.name} {statement};" for statement in statements)
|
|
90
|
+
|
|
91
|
+
def add_constraints_stmt(self) -> Optional[str]:
|
|
92
|
+
statements: list[str] = []
|
|
93
|
+
if self.table_constraints:
|
|
94
|
+
statements.append(f"ALTER TABLE {self.name} ADD\n" + ",\n".join(f"CONSTRAINT {c.spec}" for c in self.table_constraints) + ";")
|
|
95
|
+
return join_or_none(statements)
|
|
96
|
+
|
|
97
|
+
def drop_constraints_stmt(self) -> Optional[str]:
|
|
98
|
+
statements: list[str] = []
|
|
99
|
+
if self.table_constraints:
|
|
100
|
+
statements.append(
|
|
101
|
+
f"ALTER TABLE {self.name} DROP\n" + ",\n".join(f"CONSTRAINT {c.name}" for c in self.table_constraints) + "\n;"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return join_or_none(statements)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MSSQLEnumTable(EnumTable, MSSQLTable):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MSSQLNamespace(Namespace):
|
|
112
|
+
def create_schema_stmt(self) -> Optional[str]:
|
|
113
|
+
if self.name.local_id:
|
|
114
|
+
# Microsoft SQL Server requires a separate batch for creating a schema
|
|
115
|
+
return f"IF NOT EXISTS ( SELECT * FROM sys.schemas WHERE name = N{quote(self.name.id)} ) EXEC('CREATE SCHEMA {self.name}');"
|
|
116
|
+
else:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MSSQLObjectFactory(ObjectFactory):
|
|
121
|
+
@property
|
|
122
|
+
def column_class(self) -> type[Column]:
|
|
123
|
+
return MSSQLColumn
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def table_class(self) -> type[Table]:
|
|
127
|
+
return MSSQLTable
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def enum_table_class(self) -> type[EnumTable]:
|
|
131
|
+
return MSSQLEnumTable
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def namespace_class(self) -> type[Namespace]:
|
|
135
|
+
return MSSQLNamespace
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import ssl
|
|
3
|
+
import typing
|
|
4
|
+
from typing import Optional, TypeVar
|
|
5
|
+
|
|
6
|
+
import aiomysql
|
|
7
|
+
import pymysql
|
|
8
|
+
from strong_typing.inspection import DataclassInstance, is_dataclass_type
|
|
9
|
+
|
|
10
|
+
from pysqlsync.base import BaseConnection, BaseContext, DataSource
|
|
11
|
+
from pysqlsync.connection import ConnectionSSLMode, create_context
|
|
12
|
+
from pysqlsync.model.data_types import escape_like
|
|
13
|
+
from pysqlsync.model.id_types import LocalId
|
|
14
|
+
from pysqlsync.resultset import resultset_unwrap_dict, resultset_unwrap_tuple
|
|
15
|
+
from pysqlsync.util.typing import override
|
|
16
|
+
|
|
17
|
+
D = TypeVar("D", bound=DataclassInstance)
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
LOGGER = logging.getLogger("pysqlsync.mysql")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def quoted_id(identifier: str) -> str:
|
|
24
|
+
return "`" + identifier.replace("`", "``") + "`"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MySQLConnection(BaseConnection):
|
|
28
|
+
native: aiomysql.Connection
|
|
29
|
+
|
|
30
|
+
@override
|
|
31
|
+
async def open(self) -> BaseContext:
|
|
32
|
+
LOGGER.info("connecting to %s (with aiomysql)", self.params)
|
|
33
|
+
|
|
34
|
+
ssl_mode = self.params.ssl
|
|
35
|
+
if ssl_mode is None or ssl_mode is ConnectionSSLMode.disable:
|
|
36
|
+
return await self._open()
|
|
37
|
+
elif ssl_mode is ConnectionSSLMode.prefer:
|
|
38
|
+
try:
|
|
39
|
+
return await self._open(create_context(ssl_mode))
|
|
40
|
+
except pymysql.err.OperationalError:
|
|
41
|
+
return await self._open()
|
|
42
|
+
elif ssl_mode is ConnectionSSLMode.allow:
|
|
43
|
+
try:
|
|
44
|
+
return await self._open()
|
|
45
|
+
except pymysql.err.OperationalError:
|
|
46
|
+
return await self._open(create_context(ssl_mode))
|
|
47
|
+
elif ssl_mode is ConnectionSSLMode.require or ssl_mode is ConnectionSSLMode.verify_ca or ssl_mode is ConnectionSSLMode.verify_full:
|
|
48
|
+
return await self._open(create_context(ssl_mode))
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"unsupported SSL mode: {ssl_mode}")
|
|
51
|
+
|
|
52
|
+
async def _open(self, ctx: Optional[ssl.SSLContext] = None) -> BaseContext:
|
|
53
|
+
sql_mode = ",".join(
|
|
54
|
+
[
|
|
55
|
+
"ANSI_QUOTES",
|
|
56
|
+
"NO_AUTO_VALUE_ON_ZERO",
|
|
57
|
+
"STRICT_ALL_TABLES",
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
self.native = await aiomysql.connect(
|
|
61
|
+
host=self.params.host or "localhost",
|
|
62
|
+
port=self.params.port or 3306,
|
|
63
|
+
user=self.params.username,
|
|
64
|
+
password=self.params.password or "",
|
|
65
|
+
db=self.params.database,
|
|
66
|
+
sql_mode=f"'{sql_mode}'",
|
|
67
|
+
init_command='SET @@session.time_zone = "+00:00";',
|
|
68
|
+
autocommit=True,
|
|
69
|
+
ssl=ctx,
|
|
70
|
+
)
|
|
71
|
+
return MySQLContext(self)
|
|
72
|
+
|
|
73
|
+
@override
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
self.native.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MySQLContext(BaseContext):
|
|
79
|
+
def __init__(self, connection: MySQLConnection) -> None:
|
|
80
|
+
super().__init__(connection)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def native_connection(self) -> aiomysql.Connection:
|
|
84
|
+
return typing.cast(MySQLConnection, self.connection).native
|
|
85
|
+
|
|
86
|
+
@override
|
|
87
|
+
async def _execute(self, statement: str) -> None:
|
|
88
|
+
async with self.native_connection.cursor() as cur:
|
|
89
|
+
await cur.execute(statement)
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
async def _execute_all(self, statement: str, source: DataSource) -> None:
|
|
93
|
+
async for batch in source.batches():
|
|
94
|
+
async with self.native_connection.cursor() as cur:
|
|
95
|
+
await cur.executemany(statement, batch)
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
async def _query_all(self, signature: type[T], statement: str) -> list[T]:
|
|
99
|
+
if is_dataclass_type(signature):
|
|
100
|
+
cur = await self.native_connection.cursor(aiomysql.cursors.DictCursor)
|
|
101
|
+
await cur.execute(statement)
|
|
102
|
+
records = await cur.fetchall()
|
|
103
|
+
return resultset_unwrap_dict(signature, records) # type: ignore
|
|
104
|
+
else:
|
|
105
|
+
cur = await self.native_connection.cursor()
|
|
106
|
+
await cur.execute(statement)
|
|
107
|
+
records = await cur.fetchall()
|
|
108
|
+
return resultset_unwrap_tuple(signature, records)
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
async def current_schema(self) -> Optional[str]:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
@override
|
|
115
|
+
async def drop_schema(self, namespace: LocalId) -> None:
|
|
116
|
+
LOGGER.debug("drop schema: %s", namespace)
|
|
117
|
+
|
|
118
|
+
tables = await self.query_all(
|
|
119
|
+
str,
|
|
120
|
+
"SELECT table_name\n"
|
|
121
|
+
"FROM information_schema.tables\n"
|
|
122
|
+
f"WHERE table_schema = DATABASE() AND table_name LIKE '{escape_like(namespace.id, '~')}~_~_%' ESCAPE '~';",
|
|
123
|
+
)
|
|
124
|
+
if tables:
|
|
125
|
+
table_list = ", ".join(quoted_id(table) for table in tables)
|
|
126
|
+
await self.execute(f"DROP TABLE IF EXISTS {table_list};")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pysqlsync.model.data_types import SqlTimestampType, SqlVariableBinaryType, SqlVariableCharacterType
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MySQLDateTimeType(SqlTimestampType):
|
|
5
|
+
def __str__(self) -> str:
|
|
6
|
+
if self.precision is not None:
|
|
7
|
+
precision = f"({self.precision})"
|
|
8
|
+
else:
|
|
9
|
+
precision = ""
|
|
10
|
+
return f"datetime{precision}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MySQLVariableCharacterType(SqlVariableCharacterType):
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
if self.limit is None:
|
|
16
|
+
return "mediumtext"
|
|
17
|
+
|
|
18
|
+
if self.limit < 65536:
|
|
19
|
+
return f"varchar({self.limit})"
|
|
20
|
+
elif self.limit < 16777216:
|
|
21
|
+
return "mediumtext"
|
|
22
|
+
elif self.limit < 4294967296:
|
|
23
|
+
return "longtext"
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError(f"character count exceeds maximum: {self.limit}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MySQLVariableBinaryType(SqlVariableBinaryType):
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
if self.storage is None:
|
|
31
|
+
return "mediumblob"
|
|
32
|
+
|
|
33
|
+
if self.storage < 65536:
|
|
34
|
+
return f"varbinary({self.storage})"
|
|
35
|
+
elif self.storage < 16777216:
|
|
36
|
+
return "mediumblob"
|
|
37
|
+
elif self.storage < 4294967296:
|
|
38
|
+
return "longblob"
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError(f"storage size exceeds maximum: {self.storage}")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pysqlsync: Synchronize schema and large volumes of data.
|
|
3
|
+
|
|
4
|
+
This module defines dependencies required for MySQL.
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/instructure-internal/pysqlsync
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import aiomysql # pyright: ignore[reportUnusedImport] # noqa: F401
|
|
10
|
+
import cryptography # pyright: ignore[reportUnusedImport] # noqa: F401
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pysqlsync.base import BaseContext
|
|
5
|
+
from pysqlsync.formation.constraints import ForeignFactory
|
|
6
|
+
from pysqlsync.formation.data_types import SqlDiscovery, SqlDiscoveryOptions
|
|
7
|
+
from pysqlsync.formation.discovery import AnsiColumnMeta, AnsiConstraintMeta, AnsiExplorer
|
|
8
|
+
from pysqlsync.formation.object_types import Column, ForeignConstraint, Namespace
|
|
9
|
+
from pysqlsync.model.data_types import quote
|
|
10
|
+
from pysqlsync.model.id_types import LocalId, PrefixedId, SupportsQualifiedId
|
|
11
|
+
from pysqlsync.util.typing import override
|
|
12
|
+
|
|
13
|
+
from .data_types import MySQLDateTimeType, MySQLVariableBinaryType, MySQLVariableCharacterType
|
|
14
|
+
from .object_types import MySQLObjectFactory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MySQLColumnMeta(AnsiColumnMeta):
|
|
19
|
+
column_type: str
|
|
20
|
+
extra: str
|
|
21
|
+
column_comment: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MySQLExplorer(AnsiExplorer):
|
|
25
|
+
def __init__(self, conn: BaseContext) -> None:
|
|
26
|
+
super().__init__(
|
|
27
|
+
conn,
|
|
28
|
+
SqlDiscovery(
|
|
29
|
+
SqlDiscoveryOptions(
|
|
30
|
+
substitutions={
|
|
31
|
+
"datetime": MySQLDateTimeType(),
|
|
32
|
+
"varchar": MySQLVariableCharacterType(),
|
|
33
|
+
"tinytext": MySQLVariableCharacterType(limit=255),
|
|
34
|
+
"text": MySQLVariableCharacterType(limit=65535),
|
|
35
|
+
"mediumtext": MySQLVariableCharacterType(limit=16777215),
|
|
36
|
+
"longtext": MySQLVariableCharacterType(limit=4294967295),
|
|
37
|
+
"varbinary": MySQLVariableBinaryType(),
|
|
38
|
+
"tinyblob": MySQLVariableBinaryType(storage=255),
|
|
39
|
+
"blob": MySQLVariableBinaryType(storage=65535),
|
|
40
|
+
"mediumblob": MySQLVariableBinaryType(storage=16777215),
|
|
41
|
+
"longblob": MySQLVariableBinaryType(storage=4294967295),
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
),
|
|
45
|
+
MySQLObjectFactory(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
def get_qualified_id(self, namespace: Optional[str], id: str) -> SupportsQualifiedId:
|
|
50
|
+
return PrefixedId(namespace, id)
|
|
51
|
+
|
|
52
|
+
def split_composite_id(self, name: str) -> SupportsQualifiedId:
|
|
53
|
+
if "__" in name:
|
|
54
|
+
parts = name.split("__", 1)
|
|
55
|
+
return PrefixedId(parts[0], parts[1])
|
|
56
|
+
else:
|
|
57
|
+
return PrefixedId(None, name)
|
|
58
|
+
|
|
59
|
+
async def get_columns(self, table_id: SupportsQualifiedId) -> list[Column]:
|
|
60
|
+
column_meta = await self.conn.query_all(
|
|
61
|
+
MySQLColumnMeta,
|
|
62
|
+
"SELECT\n"
|
|
63
|
+
" column_name AS column_name,\n"
|
|
64
|
+
" data_type AS data_type,\n"
|
|
65
|
+
" CASE WHEN is_nullable = 'YES' THEN 1 ELSE 0 END AS nullable,\n"
|
|
66
|
+
" column_default AS column_default,\n"
|
|
67
|
+
" character_maximum_length AS character_maximum_length,\n"
|
|
68
|
+
" numeric_precision AS numeric_precision,\n"
|
|
69
|
+
" numeric_scale AS numeric_scale,\n"
|
|
70
|
+
" datetime_precision AS datetime_precision,\n"
|
|
71
|
+
" column_type AS column_type,\n"
|
|
72
|
+
" extra AS extra,\n"
|
|
73
|
+
" column_comment AS column_comment\n"
|
|
74
|
+
"FROM information_schema.columns AS col\n"
|
|
75
|
+
f"WHERE {self._where_table(table_id, 'col')}\n"
|
|
76
|
+
"ORDER BY ordinal_position",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
columns: list[Column] = []
|
|
80
|
+
for col in column_meta:
|
|
81
|
+
columns.append(
|
|
82
|
+
self.factory.column_class(
|
|
83
|
+
LocalId(col.column_name),
|
|
84
|
+
self.discovery.sql_data_type_from_spec(
|
|
85
|
+
type_name=col.data_type,
|
|
86
|
+
type_def=col.column_type,
|
|
87
|
+
character_maximum_length=col.character_maximum_length,
|
|
88
|
+
numeric_precision=col.numeric_precision,
|
|
89
|
+
numeric_scale=col.numeric_scale,
|
|
90
|
+
datetime_precision=col.datetime_precision,
|
|
91
|
+
),
|
|
92
|
+
bool(col.nullable),
|
|
93
|
+
default=col.column_default,
|
|
94
|
+
identity="auto_increment" in col.extra,
|
|
95
|
+
description=col.column_comment or None,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return columns
|
|
99
|
+
|
|
100
|
+
async def get_referential_constraints(self, table_id: SupportsQualifiedId) -> list[ForeignConstraint]:
|
|
101
|
+
constraint_meta = await self.conn.query_all(
|
|
102
|
+
AnsiConstraintMeta,
|
|
103
|
+
"SELECT\n"
|
|
104
|
+
" kcu.constraint_name AS fk_constraint_name,\n"
|
|
105
|
+
" kcu.table_schema AS fk_table_schema,\n"
|
|
106
|
+
" kcu.table_name AS fk_table_name,\n"
|
|
107
|
+
" kcu.column_name AS fk_column_name,\n"
|
|
108
|
+
" kcu.ordinal_position AS fk_ordinal_position,\n"
|
|
109
|
+
" 'PRIMARY' AS uq_constraint_name,\n"
|
|
110
|
+
" kcu.referenced_table_schema AS uq_table_schema,\n"
|
|
111
|
+
" kcu.referenced_table_name AS uq_table_name,\n"
|
|
112
|
+
" kcu.referenced_column_name AS uq_column_name,\n"
|
|
113
|
+
" kcu.ordinal_position AS uq_ordinal_position\n"
|
|
114
|
+
"FROM information_schema.referential_constraints AS ref\n"
|
|
115
|
+
" INNER JOIN information_schema.key_column_usage AS kcu\n"
|
|
116
|
+
" ON kcu.constraint_catalog = ref.constraint_catalog\n"
|
|
117
|
+
" AND kcu.constraint_schema = ref.constraint_schema\n"
|
|
118
|
+
" AND kcu.constraint_name = ref.constraint_name\n"
|
|
119
|
+
f"WHERE ref.table_name = {quote(table_id.local_id)} AND {self._where_table(table_id, 'kcu')}\n",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
constraints = ForeignFactory()
|
|
123
|
+
for con in constraint_meta:
|
|
124
|
+
constraints.add(
|
|
125
|
+
con.fk_constraint_name,
|
|
126
|
+
LocalId(con.fk_column_name),
|
|
127
|
+
self.split_composite_id(con.uq_table_name),
|
|
128
|
+
LocalId(con.uq_column_name),
|
|
129
|
+
)
|
|
130
|
+
return constraints.fetch()
|
|
131
|
+
|
|
132
|
+
async def get_table_description(self, table_id: SupportsQualifiedId) -> Optional[str]:
|
|
133
|
+
return (
|
|
134
|
+
await self.conn.query_one(
|
|
135
|
+
str,
|
|
136
|
+
f"SELECT table_comment\nFROM information_schema.tables AS tab\nWHERE {self._where_table(table_id, 'tab')}",
|
|
137
|
+
)
|
|
138
|
+
or None
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def get_namespace_current(self) -> Namespace:
|
|
142
|
+
return await self.get_namespace_flat()
|
|
143
|
+
|
|
144
|
+
async def get_namespace(self, namespace_id: LocalId) -> Namespace:
|
|
145
|
+
return await self.get_namespace_flat(namespace_id)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pysqlsync.base import BaseConnection, BaseEngine, BaseGenerator, Explorer
|
|
2
|
+
|
|
3
|
+
from .connection import MySQLConnection
|
|
4
|
+
from .discovery import MySQLExplorer
|
|
5
|
+
from .generator import MySQLGenerator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MySQLEngine(BaseEngine):
|
|
9
|
+
@property
|
|
10
|
+
def name(self) -> str:
|
|
11
|
+
return "mysql"
|
|
12
|
+
|
|
13
|
+
def get_generator_type(self) -> type[BaseGenerator]:
|
|
14
|
+
return MySQLGenerator
|
|
15
|
+
|
|
16
|
+
def get_connection_type(self) -> type[BaseConnection]:
|
|
17
|
+
return MySQLConnection
|
|
18
|
+
|
|
19
|
+
def get_explorer_type(self) -> type[Explorer]:
|
|
20
|
+
return MySQLExplorer
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import ipaddress
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
from pysqlsync.base import BaseGenerator, GeneratorOptions
|
|
7
|
+
from pysqlsync.formation.inspection import is_ip_address_type
|
|
8
|
+
from pysqlsync.formation.object_types import Column, Table
|
|
9
|
+
from pysqlsync.formation.py_to_sql import ArrayMode, DataclassConverter, DataclassConverterOptions, EnumMode, NamespaceMapping, StructMode
|
|
10
|
+
from pysqlsync.model.data_types import SqlFixedBinaryType, SqlIntegerType
|
|
11
|
+
from pysqlsync.model.id_types import LocalId
|
|
12
|
+
from pysqlsync.util.typing import override
|
|
13
|
+
|
|
14
|
+
from .data_types import MySQLDateTimeType, MySQLVariableCharacterType
|
|
15
|
+
from .mutation import MySQLMutator
|
|
16
|
+
from .object_types import MySQLObjectFactory
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MySQLGenerator(BaseGenerator):
|
|
20
|
+
"""
|
|
21
|
+
Generator for MySQL.
|
|
22
|
+
|
|
23
|
+
Assumes configuration `ANSI_QUOTES` and `SET @@session.time_zone = "+00:00"`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, options: GeneratorOptions) -> None:
|
|
27
|
+
super().__init__(options, MySQLObjectFactory(), MySQLMutator(options.synchronization))
|
|
28
|
+
|
|
29
|
+
self.check_enum_mode(exclude=[EnumMode.TYPE])
|
|
30
|
+
self.check_struct_mode(matches=StructMode.JSON)
|
|
31
|
+
self.check_array_mode(include=[ArrayMode.JSON, ArrayMode.RELATION])
|
|
32
|
+
|
|
33
|
+
self.converter = DataclassConverter(
|
|
34
|
+
options=DataclassConverterOptions(
|
|
35
|
+
enum_mode=options.enum_mode or EnumMode.INLINE,
|
|
36
|
+
struct_mode=options.struct_mode or StructMode.JSON,
|
|
37
|
+
array_mode=options.array_mode or ArrayMode.JSON,
|
|
38
|
+
qualified_names=False,
|
|
39
|
+
namespaces=NamespaceMapping(options.namespaces),
|
|
40
|
+
foreign_constraints=options.foreign_constraints,
|
|
41
|
+
initialize_tables=options.initialize_tables,
|
|
42
|
+
substitutions={
|
|
43
|
+
bool: SqlIntegerType(1),
|
|
44
|
+
datetime.datetime: MySQLDateTimeType(),
|
|
45
|
+
uuid.UUID: SqlFixedBinaryType(16),
|
|
46
|
+
str: MySQLVariableCharacterType(16777215),
|
|
47
|
+
ipaddress.IPv4Address: SqlFixedBinaryType(4),
|
|
48
|
+
ipaddress.IPv6Address: SqlFixedBinaryType(16),
|
|
49
|
+
},
|
|
50
|
+
factory=self.factory,
|
|
51
|
+
skip_annotations=options.skip_annotations,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@override
|
|
56
|
+
def get_current_schema_stmt(self) -> str:
|
|
57
|
+
return "DATABASE()"
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
def placeholder(self, index: int) -> str:
|
|
61
|
+
return r"%s"
|
|
62
|
+
|
|
63
|
+
@override
|
|
64
|
+
def get_table_insert_stmt(self, table: Table, order: Optional[tuple[str, ...]] = None) -> str:
|
|
65
|
+
statements: list[str] = []
|
|
66
|
+
statements.append(f"INSERT INTO {table.name}")
|
|
67
|
+
columns = [column for column in table.get_columns(order) if not column.identity]
|
|
68
|
+
column_list = ", ".join(str(column.name) for column in columns)
|
|
69
|
+
value_list = ", ".join("%s" for _column in columns)
|
|
70
|
+
statements.append(f"({column_list}) VALUES ({value_list})")
|
|
71
|
+
statements.append(";")
|
|
72
|
+
return "\n".join(statements)
|
|
73
|
+
|
|
74
|
+
@override
|
|
75
|
+
def get_table_merge_stmt(self, table: Table, order: Optional[tuple[str, ...]] = None) -> str:
|
|
76
|
+
statements: list[str] = []
|
|
77
|
+
statements.append(f"INSERT INTO {table.name}")
|
|
78
|
+
columns = [column for column in table.get_columns(order) if not column.identity]
|
|
79
|
+
column_list = ", ".join(str(column.name) for column in columns)
|
|
80
|
+
value_list = ", ".join("%s" for _column in columns)
|
|
81
|
+
statements.append(f"({column_list}) VALUES ({value_list})")
|
|
82
|
+
statements.append("ON DUPLICATE KEY UPDATE")
|
|
83
|
+
defs = [f"{key} = {key}" for key in table.primary_key]
|
|
84
|
+
statements.append(",\n".join(defs))
|
|
85
|
+
statements.append(";")
|
|
86
|
+
return "\n".join(statements)
|
|
87
|
+
|
|
88
|
+
@override
|
|
89
|
+
def get_table_upsert_stmt(self, table: Table, order: Optional[tuple[str, ...]] = None) -> str:
|
|
90
|
+
statements: list[str] = []
|
|
91
|
+
statements.append(f"INSERT INTO {table.name}")
|
|
92
|
+
columns = [column for column in table.get_columns(order)]
|
|
93
|
+
statements.append(_field_list([column.name for column in columns]))
|
|
94
|
+
statements.append("ON DUPLICATE KEY UPDATE")
|
|
95
|
+
value_columns = table.get_value_columns()
|
|
96
|
+
if value_columns:
|
|
97
|
+
defs = [_field_update(column.name) for column in value_columns]
|
|
98
|
+
else:
|
|
99
|
+
defs = [_field_update(key) for key in table.primary_key]
|
|
100
|
+
statements.append(",\n".join(defs))
|
|
101
|
+
statements.append(";")
|
|
102
|
+
return "\n".join(statements)
|
|
103
|
+
|
|
104
|
+
@override
|
|
105
|
+
def get_field_extractor(self, column: Column, field_name: str, field_type: type) -> Callable[[Any], Any]:
|
|
106
|
+
if field_type is uuid.UUID:
|
|
107
|
+
return lambda obj: getattr(obj, field_name).bytes
|
|
108
|
+
elif is_ip_address_type(field_type):
|
|
109
|
+
return lambda obj: getattr(obj, field_name).packed
|
|
110
|
+
|
|
111
|
+
return super().get_field_extractor(column, field_name, field_type)
|
|
112
|
+
|
|
113
|
+
@override
|
|
114
|
+
def get_value_transformer(self, column: Column, field_type: type) -> Optional[Callable[[Any], Any]]:
|
|
115
|
+
if field_type is uuid.UUID:
|
|
116
|
+
return lambda field: field.bytes
|
|
117
|
+
elif is_ip_address_type(field_type):
|
|
118
|
+
return lambda field: field.packed
|
|
119
|
+
|
|
120
|
+
return super().get_value_transformer(column, field_type)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _field_list(field_ids: list[LocalId]) -> str:
|
|
124
|
+
field_list = ", ".join(str(field_id) for field_id in field_ids)
|
|
125
|
+
value_list = ", ".join("%s" for _ in field_ids)
|
|
126
|
+
if False:
|
|
127
|
+
# compatible with MySQL 8.0.19 and later, slow with aiomysql 0.2.0 and earlier
|
|
128
|
+
return f"({field_list}) VALUES ({value_list}) AS EXCLUDED" # type: ignore[unreachable]
|
|
129
|
+
else:
|
|
130
|
+
# emits a warning with MySQL 8.0.20 and later
|
|
131
|
+
return f"({field_list}) VALUES ({value_list})"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _field_update(field_id: LocalId) -> str:
|
|
135
|
+
if False:
|
|
136
|
+
# compatible with MySQL 8.0.19 and later, slow with aiomysql 0.2.0 and earlier
|
|
137
|
+
return f"{field_id} = EXCLUDED.{field_id}" # type: ignore[unreachable]
|
|
138
|
+
else:
|
|
139
|
+
# emits a warning with MySQL 8.0.20 and later
|
|
140
|
+
return f"{field_id} = VALUES({field_id})"
|