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.
Files changed (102) hide show
  1. instructure_pysqlsync-0.8.5.dev0.dist-info/METADATA +244 -0
  2. instructure_pysqlsync-0.8.5.dev0.dist-info/RECORD +102 -0
  3. instructure_pysqlsync-0.8.5.dev0.dist-info/WHEEL +5 -0
  4. instructure_pysqlsync-0.8.5.dev0.dist-info/licenses/LICENSE +21 -0
  5. instructure_pysqlsync-0.8.5.dev0.dist-info/top_level.txt +1 -0
  6. instructure_pysqlsync-0.8.5.dev0.dist-info/zip-safe +1 -0
  7. pysqlsync/__init__.py +15 -0
  8. pysqlsync/base.py +1386 -0
  9. pysqlsync/connection.py +101 -0
  10. pysqlsync/data/__init__.py +0 -0
  11. pysqlsync/data/exchange.py +184 -0
  12. pysqlsync/data/generator.py +305 -0
  13. pysqlsync/dialect/README.md +35 -0
  14. pysqlsync/dialect/__init__.py +0 -0
  15. pysqlsync/dialect/delta/__init__.py +0 -0
  16. pysqlsync/dialect/delta/data_types.py +51 -0
  17. pysqlsync/dialect/delta/dependency.py +7 -0
  18. pysqlsync/dialect/delta/engine.py +18 -0
  19. pysqlsync/dialect/delta/generator.py +74 -0
  20. pysqlsync/dialect/delta/object_types.py +87 -0
  21. pysqlsync/dialect/mssql/README.md +23 -0
  22. pysqlsync/dialect/mssql/__init__.py +0 -0
  23. pysqlsync/dialect/mssql/connection.py +146 -0
  24. pysqlsync/dialect/mssql/data_types.py +113 -0
  25. pysqlsync/dialect/mssql/dependency.py +9 -0
  26. pysqlsync/dialect/mssql/discovery.py +99 -0
  27. pysqlsync/dialect/mssql/engine.py +20 -0
  28. pysqlsync/dialect/mssql/generator.py +88 -0
  29. pysqlsync/dialect/mssql/mutation.py +46 -0
  30. pysqlsync/dialect/mssql/object_types.py +135 -0
  31. pysqlsync/dialect/mysql/__init__.py +0 -0
  32. pysqlsync/dialect/mysql/connection.py +126 -0
  33. pysqlsync/dialect/mysql/data_types.py +40 -0
  34. pysqlsync/dialect/mysql/dependency.py +10 -0
  35. pysqlsync/dialect/mysql/discovery.py +145 -0
  36. pysqlsync/dialect/mysql/engine.py +20 -0
  37. pysqlsync/dialect/mysql/generator.py +140 -0
  38. pysqlsync/dialect/mysql/mutation.py +65 -0
  39. pysqlsync/dialect/mysql/object_types.py +75 -0
  40. pysqlsync/dialect/oracle/README.md +22 -0
  41. pysqlsync/dialect/oracle/__init__.py +0 -0
  42. pysqlsync/dialect/oracle/connection.py +141 -0
  43. pysqlsync/dialect/oracle/data_types.py +104 -0
  44. pysqlsync/dialect/oracle/dependency.py +9 -0
  45. pysqlsync/dialect/oracle/discovery.py +231 -0
  46. pysqlsync/dialect/oracle/engine.py +20 -0
  47. pysqlsync/dialect/oracle/generator.py +93 -0
  48. pysqlsync/dialect/oracle/mutation.py +37 -0
  49. pysqlsync/dialect/oracle/object_types.py +59 -0
  50. pysqlsync/dialect/postgresql/__init__.py +0 -0
  51. pysqlsync/dialect/postgresql/connection.py +133 -0
  52. pysqlsync/dialect/postgresql/data_types.py +6 -0
  53. pysqlsync/dialect/postgresql/dependency.py +9 -0
  54. pysqlsync/dialect/postgresql/discovery.py +392 -0
  55. pysqlsync/dialect/postgresql/engine.py +20 -0
  56. pysqlsync/dialect/postgresql/generator.py +99 -0
  57. pysqlsync/dialect/postgresql/mutation.py +82 -0
  58. pysqlsync/dialect/postgresql/object_types.py +99 -0
  59. pysqlsync/dialect/redshift/__init__.py +0 -0
  60. pysqlsync/dialect/redshift/connection.py +156 -0
  61. pysqlsync/dialect/redshift/data_types.py +10 -0
  62. pysqlsync/dialect/redshift/dependency.py +9 -0
  63. pysqlsync/dialect/redshift/engine.py +20 -0
  64. pysqlsync/dialect/redshift/generator.py +82 -0
  65. pysqlsync/dialect/snowflake/__init__.py +0 -0
  66. pysqlsync/dialect/snowflake/data_types.py +18 -0
  67. pysqlsync/dialect/snowflake/dependency.py +9 -0
  68. pysqlsync/dialect/snowflake/engine.py +18 -0
  69. pysqlsync/dialect/snowflake/generator.py +64 -0
  70. pysqlsync/dialect/snowflake/object_types.py +84 -0
  71. pysqlsync/dialect/test/__init__.py +0 -0
  72. pysqlsync/dialect/test/dependency.py +10 -0
  73. pysqlsync/dialect/trino/__init__.py +0 -0
  74. pysqlsync/dialect/trino/connection.py +86 -0
  75. pysqlsync/dialect/trino/dependency.py +9 -0
  76. pysqlsync/dialect/trino/discovery.py +21 -0
  77. pysqlsync/dialect/trino/engine.py +20 -0
  78. pysqlsync/dialect/trino/generator.py +31 -0
  79. pysqlsync/factory.py +169 -0
  80. pysqlsync/formation/__init__.py +0 -0
  81. pysqlsync/formation/constraints.py +78 -0
  82. pysqlsync/formation/data_types.py +192 -0
  83. pysqlsync/formation/discovery.py +390 -0
  84. pysqlsync/formation/inspection.py +179 -0
  85. pysqlsync/formation/mutation.py +320 -0
  86. pysqlsync/formation/object_dict.py +79 -0
  87. pysqlsync/formation/object_types.py +872 -0
  88. pysqlsync/formation/py_to_sql.py +1134 -0
  89. pysqlsync/formation/sql_to_py.py +194 -0
  90. pysqlsync/model/__init__.py +0 -0
  91. pysqlsync/model/data_types.py +397 -0
  92. pysqlsync/model/entity_types.py +68 -0
  93. pysqlsync/model/id_types.py +193 -0
  94. pysqlsync/model/key_types.py +38 -0
  95. pysqlsync/model/properties.py +234 -0
  96. pysqlsync/py.typed +0 -0
  97. pysqlsync/python_types.py +192 -0
  98. pysqlsync/resultset.py +106 -0
  99. pysqlsync/util/__init__.py +0 -0
  100. pysqlsync/util/dataclasses.py +80 -0
  101. pysqlsync/util/dispatch.py +28 -0
  102. 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})"