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,65 @@
1
+ import typing
2
+ from typing import Optional
3
+
4
+ from pysqlsync.formation.mutation import Mutator
5
+ from pysqlsync.formation.object_types import Column, StatementList, Table, deleted, join_or_none
6
+ from pysqlsync.model.data_types import SqlEnumType, SqlVariableCharacterType, quote
7
+ from pysqlsync.model.id_types import LocalId
8
+
9
+ from .object_types import MySQLColumn, MySQLTable
10
+
11
+
12
+ class MySQLMutator(Mutator):
13
+ def migrate_column_stmt(self, source_table: Table, source: Column, target_table: Table, target: Column) -> Optional[str]:
14
+ statements: list[str] = []
15
+ ref = target_table.get_constraint(target.name)
16
+ if isinstance(source.data_type, SqlEnumType):
17
+ enum_values = ", ".join(f"({quote(v)})" for v in source.data_type.values)
18
+ statements.append(f'INSERT INTO {ref.table} ("value") VALUES {enum_values}\nON DUPLICATE KEY UPDATE "value" = "value";')
19
+ elif isinstance(source.data_type, SqlVariableCharacterType):
20
+ statements.append(
21
+ f'INSERT INTO {ref.table} ("value") SELECT DISTINCT {LocalId(deleted(source.name.id))} FROM {source_table.name}\n'
22
+ 'ON DUPLICATE KEY UPDATE "value" = "value";'
23
+ )
24
+ statements.append(
25
+ f"UPDATE {source_table.name} data_table\n"
26
+ f'JOIN {ref.table} enum_table ON data_table.{LocalId(deleted(source.name.id))} = enum_table."value"\n'
27
+ f'SET data_table.{target.name} = enum_table."id";'
28
+ )
29
+ return "\n".join(statements)
30
+
31
+ def mutate_table_stmt(self, source: Table, target: Table) -> Optional[str]:
32
+ source = typing.cast(MySQLTable, source)
33
+ target = typing.cast(MySQLTable, target)
34
+
35
+ statements: StatementList = StatementList()
36
+ statements.append(super().mutate_table_stmt(source, target))
37
+
38
+ source_desc = source.short_description
39
+ target_desc = target.short_description
40
+
41
+ if source_desc is None:
42
+ if target_desc is not None:
43
+ statements.append(f"ALTER TABLE {target.name} COMMENT = {quote(target_desc)};")
44
+ else:
45
+ if target_desc is None:
46
+ statements.append(f"ALTER TABLE {target.name} COMMENT = {quote('')};")
47
+ elif source_desc != target_desc:
48
+ statements.append(f"ALTER TABLE {target.name} COMMENT = {quote(target_desc)};")
49
+
50
+ return join_or_none(statements)
51
+
52
+ def mutate_column_stmt(self, source: Column, target: Column) -> Optional[str]:
53
+ source = typing.cast(MySQLColumn, source)
54
+ target = typing.cast(MySQLColumn, target)
55
+
56
+ if (
57
+ source.data_type != target.data_type
58
+ or source.nullable != target.nullable
59
+ or source.default != target.default
60
+ or source.identity != target.identity
61
+ or source.comment != target.comment
62
+ ):
63
+ return f"MODIFY COLUMN {source.name} {target.data_spec}"
64
+ else:
65
+ return None
@@ -0,0 +1,75 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ from pysqlsync.formation.object_types import (
5
+ Column,
6
+ EnumTable,
7
+ FormationError,
8
+ ObjectFactory,
9
+ Table,
10
+ )
11
+ from pysqlsync.model.data_types import SqlEnumType, quote
12
+ from pysqlsync.model.id_types import LocalId
13
+
14
+
15
+ class MySQLTable(Table):
16
+ @property
17
+ def short_description(self) -> Optional[str]:
18
+ if self.description is None:
19
+ return None
20
+
21
+ return self.description if "\n" not in self.description else self.description[: self.description.index("\n")]
22
+
23
+ def create_stmt(self) -> str:
24
+ defs: list[str] = []
25
+ defs.extend(str(c) for c in self.columns.values())
26
+ primary_columns = ", ".join(str(key) for key in self.primary_key)
27
+ defs.append(f"CONSTRAINT {self.primary_key_constraint_id} PRIMARY KEY ({primary_columns})")
28
+ definition = ",\n".join(defs)
29
+ comment = f"\nCOMMENT = {quote(self.short_description)}" if self.short_description else ""
30
+ return f"CREATE TABLE {self.name} (\n{definition}\n){comment};"
31
+
32
+
33
+ class MySQLEnumTable(EnumTable, MySQLTable):
34
+ pass
35
+
36
+
37
+ @dataclass(eq=True)
38
+ class MySQLColumn(Column):
39
+ @property
40
+ def data_spec(self) -> str:
41
+ charset = " CHARACTER SET ascii COLLATE ascii_bin" if isinstance(self.data_type, SqlEnumType) else ""
42
+ nullable = " NOT NULL" if not self.nullable else ""
43
+ default = f" DEFAULT {self.default}" if self.default is not None else ""
44
+ identity = " AUTO_INCREMENT" if self.identity else ""
45
+ description = f" COMMENT {self.comment}" if self.description is not None else ""
46
+ return f"{self.data_type}{charset}{nullable}{default}{identity}{description}"
47
+
48
+ @property
49
+ def comment(self) -> Optional[str]:
50
+ if self.description is not None:
51
+ description = self.description if "\n" not in self.description else self.description[: self.description.index("\n")]
52
+
53
+ if len(description) > 1024:
54
+ raise FormationError(f"comment for column {self.name} too long, expected: maximum 1024; got: {len(description)}")
55
+
56
+ return quote(description)
57
+ else:
58
+ return None
59
+
60
+ def rename_stmt(self, name: str) -> str:
61
+ return f"CHANGE {self.name} {LocalId(name)} {self.data_spec}"
62
+
63
+
64
+ class MySQLObjectFactory(ObjectFactory):
65
+ @property
66
+ def column_class(self) -> type[Column]:
67
+ return MySQLColumn
68
+
69
+ @property
70
+ def table_class(self) -> type[Table]:
71
+ return MySQLTable
72
+
73
+ @property
74
+ def enum_table_class(self) -> type[EnumTable]:
75
+ return MySQLEnumTable
@@ -0,0 +1,22 @@
1
+ # Oracle dialect for pysqlsync
2
+
3
+ ## Testing
4
+
5
+ Refer to [Oracle Database Free Release Quick Start](https://www.oracle.com/database/free/get-started/) for setting up Oracle in Docker.
6
+
7
+ Specifically, pull the Docker image with the command:
8
+ ```sh
9
+ docker pull container-registry.oracle.com/database/free:latest
10
+ ```
11
+
12
+ Next, start the Docker container with the command:
13
+ ```sh
14
+ docker run -d --name oracle-db -e "ORACLE_PWD=<?YourStrong@Passw0rd>" -p 1521:1521 container-registry.oracle.com/database/free:latest
15
+ ```
16
+
17
+ In order to inspect database startup logs, run:
18
+ ```sh
19
+ docker logs oracle-db
20
+ ```
21
+
22
+ Finally, run unit and integration tests.
File without changes
@@ -0,0 +1,141 @@
1
+ import logging
2
+ import re
3
+ import typing
4
+ from typing import Iterable, Optional, TypeVar
5
+
6
+ import oracledb
7
+ from strong_typing.inspection import DataclassInstance, is_dataclass_type
8
+
9
+ from pysqlsync.base import BaseConnection, BaseContext, DataSource, QueryException, RecordType
10
+ from pysqlsync.formation.object_types import Table
11
+ from pysqlsync.model.data_types import escape_like
12
+ from pysqlsync.model.id_types import LocalId
13
+ from pysqlsync.resultset import resultset_unwrap_tuple
14
+ from pysqlsync.util.dispatch import thread_dispatch
15
+ from pysqlsync.util.typing import override
16
+
17
+ from .data_types import sql_to_oracle_type
18
+
19
+ D = TypeVar("D", bound=DataclassInstance)
20
+ T = TypeVar("T")
21
+
22
+ LOGGER = logging.getLogger("pysqlsync.oracle")
23
+
24
+
25
+ class OracleConnection(BaseConnection):
26
+ native: oracledb.Connection
27
+
28
+ @override
29
+ @thread_dispatch
30
+ def open(self) -> BaseContext:
31
+ LOGGER.info("connecting to %s", self.params)
32
+
33
+ host = self.params.host or "localhost"
34
+ port = self.params.port or 1521
35
+ database = self.params.database or "FREEPDB1"
36
+
37
+ conn = oracledb.connect(
38
+ user=self.params.username,
39
+ password=self.params.password,
40
+ dsn=f"{host}:{port}/{database}",
41
+ )
42
+ conn.autocommit = True
43
+ with conn.cursor() as cur:
44
+ cur.execute("ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'")
45
+
46
+ self.native = conn
47
+ return OracleContext(self)
48
+
49
+ @override
50
+ @thread_dispatch
51
+ def close(self) -> None:
52
+ self.native.close()
53
+
54
+
55
+ class OracleContext(BaseContext):
56
+ def __init__(self, connection: OracleConnection) -> None:
57
+ super().__init__(connection)
58
+
59
+ @property
60
+ def native_connection(self) -> oracledb.Connection:
61
+ return typing.cast(OracleConnection, self.connection).native
62
+
63
+ @override
64
+ @thread_dispatch
65
+ def _execute(self, statement: str) -> None:
66
+ with self.native_connection.cursor() as cur:
67
+ if statement.startswith("BEGIN"):
68
+ cur.execute(statement)
69
+ else:
70
+ statement = statement.rstrip("\r\n\t\v ;")
71
+ for s in re.split(r";$", statement, flags=re.MULTILINE):
72
+ try:
73
+ cur.execute(s)
74
+ except Exception as e:
75
+ raise QueryException(s) from e
76
+
77
+ @override
78
+ async def _execute_all(self, statement: str, source: DataSource) -> None:
79
+ async for batch in source.batches():
80
+ await self._internal_execute_all(statement, batch)
81
+
82
+ @override
83
+ async def _execute_typed(
84
+ self,
85
+ statement: str,
86
+ source: DataSource,
87
+ table: Table,
88
+ order: Optional[tuple[str, ...]] = None,
89
+ ) -> None:
90
+ async for batch in source.batches():
91
+ await self._internal_execute_typed(statement, batch, table, order)
92
+
93
+ @thread_dispatch
94
+ def _internal_execute_all(self, statement: str, records: Iterable[RecordType]) -> None:
95
+ statement = statement.rstrip("\r\n\t\v ;")
96
+ with self.native_connection.cursor() as cur:
97
+ cur.executemany(statement, list(records))
98
+
99
+ @thread_dispatch
100
+ def _internal_execute_typed(
101
+ self,
102
+ statement: str,
103
+ records: Iterable[RecordType],
104
+ table: Table,
105
+ order: Optional[tuple[str, ...]] = None,
106
+ ) -> None:
107
+ statement = statement.rstrip("\r\n\t\v ;")
108
+ with self.native_connection.cursor() as cur:
109
+ cur.setinputsizes(*tuple(sql_to_oracle_type(cur, column.data_type) for column in table.get_columns(order)))
110
+ cur.executemany(statement, list(records))
111
+
112
+ @override
113
+ @thread_dispatch
114
+ def _query_all(self, signature: type[T], statement: str) -> list[T]:
115
+ with self.native_connection.cursor() as cur:
116
+ cur.execute(statement)
117
+ if is_dataclass_type(signature):
118
+ cur.rowfactory = lambda *args: signature(*args)
119
+
120
+ records = cur.fetchall()
121
+
122
+ if is_dataclass_type(signature):
123
+ return typing.cast(list[T], records)
124
+ else:
125
+ return resultset_unwrap_tuple(signature, records)
126
+
127
+ @override
128
+ async def current_schema(self) -> Optional[str]:
129
+ return None
130
+
131
+ @override
132
+ async def drop_schema(self, namespace: LocalId) -> None:
133
+ LOGGER.debug("drop schema: %s", namespace)
134
+ condition = f"table_name LIKE '{escape_like(namespace.id, '~')}~_~_%' ESCAPE '~'"
135
+ tables = await self.query_all(
136
+ str,
137
+ f"SELECT table_name FROM dba_tables WHERE {condition}",
138
+ )
139
+ statement = "\n".join(f"DROP TABLE {LocalId(table)} CASCADE CONSTRAINTS PURGE;" for table in tables)
140
+ if statement:
141
+ await self.execute(statement)
@@ -0,0 +1,104 @@
1
+ import oracledb
2
+
3
+ from pysqlsync.model.data_types import (
4
+ SqlBooleanType,
5
+ SqlDataType,
6
+ SqlDateType,
7
+ SqlDecimalType,
8
+ SqlDoubleType,
9
+ SqlFixedCharacterType,
10
+ SqlIntegerType,
11
+ SqlRealType,
12
+ SqlTimestampType,
13
+ SqlTimeType,
14
+ SqlVariableBinaryType,
15
+ SqlVariableCharacterType,
16
+ )
17
+
18
+
19
+ class OracleIntegerType(SqlIntegerType):
20
+ def __init__(self) -> None:
21
+ super().__init__(8)
22
+
23
+ def __str__(self) -> str:
24
+ return "number"
25
+
26
+
27
+ class OracleTimestampType(SqlTimestampType):
28
+ def __str__(self) -> str:
29
+ if self.precision is not None and self.precision != 6:
30
+ precision = f"({self.precision})"
31
+ else:
32
+ precision = ""
33
+ if self.with_time_zone:
34
+ time_zone = " with time zone"
35
+ else:
36
+ time_zone = ""
37
+ return f"timestamp{precision}{time_zone}"
38
+
39
+
40
+ class OracleTimeType(SqlTimeType):
41
+ def __str__(self) -> str:
42
+ return "interval day to second"
43
+
44
+
45
+ class OracleVariableCharacterType(SqlVariableCharacterType):
46
+ def __str__(self) -> str:
47
+ if self.limit is not None and self.limit <= 4000:
48
+ return f"varchar2({self.limit})"
49
+ else:
50
+ return "clob"
51
+
52
+
53
+ class OracleVariableBinaryType(SqlVariableBinaryType):
54
+ def __str__(self) -> str:
55
+ if self.storage is not None and self.storage <= 2000:
56
+ return f"raw({self.storage})"
57
+ else:
58
+ return "blob"
59
+
60
+
61
+ def sql_to_oracle_type(cur: oracledb.Cursor, data_type: SqlDataType) -> oracledb.Var:
62
+ """
63
+ Returns the Oracle data type associated with the SQL data type.
64
+
65
+ Passing the right data types to `setinputsizes` eliminates data-based guessing, and can speed up `executemany`
66
+ by a significant factor.
67
+ """
68
+
69
+ if isinstance(data_type, SqlBooleanType):
70
+ return cur.var(oracledb.DB_TYPE_BOOLEAN)
71
+ elif isinstance(data_type, SqlRealType):
72
+ return cur.var(oracledb.DB_TYPE_BINARY_FLOAT)
73
+ elif isinstance(data_type, SqlDoubleType):
74
+ return cur.var(oracledb.DB_TYPE_BINARY_DOUBLE)
75
+ elif isinstance(data_type, SqlIntegerType):
76
+ if data_type.width > 4:
77
+ return cur.var(oracledb.DB_TYPE_NUMBER)
78
+ else:
79
+ return cur.var(oracledb.DB_TYPE_BINARY_INTEGER, data_type.width)
80
+ elif isinstance(data_type, SqlVariableBinaryType):
81
+ if data_type.storage is not None:
82
+ return cur.var(oracledb.DB_TYPE_RAW, data_type.storage)
83
+ else:
84
+ return cur.var(oracledb.DB_TYPE_BLOB)
85
+ elif isinstance(data_type, SqlFixedCharacterType):
86
+ if data_type.limit is not None:
87
+ return cur.var(oracledb.DB_TYPE_CHAR, data_type.limit)
88
+ else:
89
+ return cur.var(oracledb.DB_TYPE_CHAR, 4000)
90
+ elif isinstance(data_type, SqlVariableCharacterType):
91
+ if data_type.limit is not None:
92
+ return cur.var(oracledb.DB_TYPE_VARCHAR, data_type.limit)
93
+ else:
94
+ return cur.var(oracledb.DB_TYPE_CLOB)
95
+ elif isinstance(data_type, SqlDateType):
96
+ return cur.var(oracledb.DB_TYPE_DATE)
97
+ elif isinstance(data_type, SqlTimeType):
98
+ return cur.var(oracledb.DB_TYPE_INTERVAL_DS)
99
+ elif isinstance(data_type, SqlDecimalType):
100
+ return cur.var(oracledb.DB_TYPE_NUMBER)
101
+ elif isinstance(data_type, SqlTimestampType):
102
+ return cur.var(oracledb.DB_TYPE_TIMESTAMP)
103
+
104
+ return cur.var(oracledb.DB_TYPE_UNKNOWN)
@@ -0,0 +1,9 @@
1
+ """
2
+ pysqlsync: Synchronize schema and large volumes of data.
3
+
4
+ This module defines dependencies required for Oracle.
5
+
6
+ :see: https://github.com/instructure-internal/pysqlsync
7
+ """
8
+
9
+ import oracledb # pyright: ignore[reportUnusedImport] # noqa: F401
@@ -0,0 +1,231 @@
1
+ import logging
2
+ import re
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ from pysqlsync.base import BaseContext, Explorer
7
+ from pysqlsync.formation.constraints import ForeignFactory, UniqueFactory
8
+ from pysqlsync.formation.data_types import SqlDiscovery, SqlDiscoveryOptions
9
+ from pysqlsync.formation.object_types import Column, Constraint, Namespace, Table
10
+ from pysqlsync.model.data_types import escape_like, quote
11
+ from pysqlsync.model.id_types import (
12
+ LocalId,
13
+ PrefixedId,
14
+ QualifiedId,
15
+ SupportsQualifiedId,
16
+ )
17
+
18
+ from .data_types import (
19
+ OracleIntegerType,
20
+ OracleTimestampType,
21
+ OracleTimeType,
22
+ OracleVariableBinaryType,
23
+ OracleVariableCharacterType,
24
+ )
25
+ from .object_types import OracleObjectFactory
26
+
27
+ LOGGER = logging.getLogger("pysqlsync.oracle")
28
+
29
+
30
+ @dataclass
31
+ class OracleColumnMeta:
32
+ column_name: str
33
+ data_type: str
34
+ data_type_owner: str
35
+ data_length: int
36
+ data_precision: int
37
+ data_scale: int
38
+ is_nullable: bool
39
+ data_default: str
40
+ char_length: int
41
+ is_identity: bool
42
+ comments: str
43
+
44
+
45
+ @dataclass
46
+ class OracleConstraintMeta:
47
+ constraint_name: str
48
+ constraint_type: str
49
+ source_column: str
50
+ target_table: str
51
+ target_column: str
52
+
53
+
54
+ class OracleExplorer(Explorer):
55
+ discovery: SqlDiscovery
56
+ factory: OracleObjectFactory
57
+
58
+ def __init__(self, conn: BaseContext) -> None:
59
+ super().__init__(conn)
60
+ self.discovery = SqlDiscovery(
61
+ SqlDiscoveryOptions(
62
+ substitutions={
63
+ "BLOB": OracleVariableBinaryType(),
64
+ "CLOB": OracleVariableCharacterType(),
65
+ "INTERVAL DAY": OracleTimeType(),
66
+ "LONG": OracleVariableCharacterType(),
67
+ "NUMBER": OracleIntegerType(),
68
+ "RAW": OracleVariableBinaryType(),
69
+ "TIMESTAMP": OracleTimestampType(),
70
+ "VARCHAR2": OracleVariableCharacterType(),
71
+ }
72
+ )
73
+ )
74
+ self.factory = OracleObjectFactory()
75
+
76
+ def get_qualified_id(self, namespace: Optional[str], id: str) -> SupportsQualifiedId:
77
+ return PrefixedId(namespace, id)
78
+
79
+ def split_composite_id(self, name: str) -> SupportsQualifiedId:
80
+ if "__" in name:
81
+ parts = name.split("__", 1)
82
+ return PrefixedId(parts[0], parts[1])
83
+ else:
84
+ return PrefixedId(None, name)
85
+
86
+ async def get_table_names(self) -> list[QualifiedId]:
87
+ raise NotImplementedError()
88
+
89
+ async def has_table(self, table_id: SupportsQualifiedId) -> bool:
90
+ raise NotImplementedError()
91
+
92
+ async def has_column(self, table_id: SupportsQualifiedId, column_id: LocalId) -> bool:
93
+ raise NotImplementedError()
94
+
95
+ async def get_table(self, table_id: SupportsQualifiedId) -> Table:
96
+ condition = f"t.table_name = {quote(table_id.local_id)}"
97
+
98
+ table_comments = await self.conn.query_all(
99
+ str,
100
+ "SELECT tc.comments\n"
101
+ "FROM dba_tables t JOIN dba_tab_comments tc ON t.owner = tc.owner AND t.table_name = tc.table_name\n"
102
+ f"WHERE t.owner != 'SYS' AND {condition}\n AND t.tablespace_name != 'SYSAUX'\n"
103
+ " AND t.status = 'VALID' AND t.temporary = 'N' AND t.secondary = 'N' AND t.dropped = 'NO' AND t.read_only = 'NO'",
104
+ )
105
+
106
+ column_records = await self.conn.query_all(
107
+ OracleColumnMeta,
108
+ "SELECT\n"
109
+ " t.column_name,\n"
110
+ " t.data_type,\n"
111
+ " t.data_type_owner,\n"
112
+ " t.data_length,\n"
113
+ " t.data_precision,\n"
114
+ " t.data_scale,\n"
115
+ " t.nullable != 'N' AS is_nullable,\n"
116
+ " t.data_default,\n"
117
+ " t.char_length,\n"
118
+ " t.identity_column = 'YES' AS is_identity,\n"
119
+ " tc.comments\n"
120
+ "FROM dba_tab_columns t\n"
121
+ " JOIN dba_col_comments tc ON t.owner = tc.owner AND t.table_name = tc.table_name AND t.column_name = tc.column_name\n"
122
+ f"WHERE {condition}\n"
123
+ "ORDER BY column_id",
124
+ )
125
+
126
+ columns: list[Column] = []
127
+ for col in column_records:
128
+ char_length: Optional[int]
129
+ if col.data_type in ["BLOB", "CLOB"]:
130
+ char_length = None
131
+ else:
132
+ char_length = col.char_length or col.data_length
133
+ m = re.match("^([^()]+)", col.data_type)
134
+ if m is not None:
135
+ data_type = m.group(1)
136
+ else:
137
+ data_type = col.data_type
138
+
139
+ data_type = self.discovery.sql_data_type_from_spec(
140
+ type_name=data_type,
141
+ type_schema=col.data_type_owner,
142
+ type_def=col.data_type,
143
+ character_maximum_length=char_length,
144
+ numeric_precision=col.data_precision,
145
+ numeric_scale=col.data_scale,
146
+ )
147
+ nullable = bool(col.is_nullable)
148
+ identity = bool(col.is_identity)
149
+ columns.append(
150
+ self.factory.column_class(
151
+ LocalId(col.column_name),
152
+ data_type,
153
+ nullable,
154
+ identity=identity,
155
+ default=col.data_default if not identity else None,
156
+ description=col.comments,
157
+ )
158
+ )
159
+
160
+ constraint_records = await self.conn.query_all(
161
+ OracleConstraintMeta,
162
+ f"SELECT\n"
163
+ " t.constraint_name AS constraint_name,\n"
164
+ " t.constraint_type AS constraint_type,\n"
165
+ " tc.column_name AS source_column,\n"
166
+ " r.table_name AS target_table,\n"
167
+ " rc.column_name AS target_column\n"
168
+ "FROM dba_constraints t\n"
169
+ " JOIN dba_cons_columns tc ON t.owner = tc.owner AND t.constraint_name = tc.constraint_name\n"
170
+ " LEFT JOIN dba_constraints r ON t.r_owner = r.owner AND t.r_constraint_name = r.constraint_name\n"
171
+ " LEFT JOIN dba_cons_columns rc ON r.owner = rc.owner AND r.constraint_name = rc.constraint_name\n"
172
+ f"WHERE {condition}\n"
173
+ "ORDER BY tc.position",
174
+ )
175
+
176
+ primary: list[LocalId] = []
177
+ unique = UniqueFactory()
178
+ foreign = ForeignFactory()
179
+ for con in constraint_records:
180
+ if con.constraint_type == "P":
181
+ primary.append(LocalId(con.source_column))
182
+ elif con.constraint_type == "U":
183
+ unique.add(con.constraint_name, LocalId(con.source_column))
184
+ elif con.constraint_type == "R":
185
+ foreign.add(
186
+ con.constraint_name,
187
+ LocalId(con.source_column),
188
+ self.split_composite_id(con.target_table),
189
+ LocalId(con.target_column),
190
+ )
191
+
192
+ constraints: list[Constraint] = []
193
+ constraints.extend(unique.fetch())
194
+ constraints.extend(foreign.fetch())
195
+
196
+ return self.factory.table_class(
197
+ name=table_id,
198
+ columns=columns,
199
+ primary_key=tuple(primary),
200
+ constraints=constraints or None,
201
+ description=table_comments[0] if table_comments else None,
202
+ )
203
+
204
+ async def get_namespace_current(self) -> Namespace:
205
+ return await self._get_namespace()
206
+
207
+ async def get_namespace(self, namespace_id: LocalId) -> Namespace:
208
+ return await self._get_namespace(namespace_id)
209
+
210
+ async def _get_namespace(self, namespace_id: Optional[LocalId] = None) -> Namespace:
211
+ if namespace_id is not None:
212
+ condition = f"table_name LIKE '{escape_like(namespace_id.id, '~')}~_~_%' ESCAPE '~'"
213
+ else:
214
+ condition = "table_name NOT LIKE '%~_~_%' ESCAPE '~'"
215
+
216
+ table_names = await self.conn.query_all(
217
+ str,
218
+ "SELECT table_name FROM dba_tables\n"
219
+ f"WHERE owner != 'SYS' AND {condition}\n AND table_name NOT LIKE '%$%' AND tablespace_name != 'SYSAUX'\n"
220
+ " AND status = 'VALID' AND temporary = 'N' AND secondary = 'N' AND dropped = 'NO' AND read_only = 'NO'",
221
+ )
222
+
223
+ tables: list[Table] = []
224
+ for table_name in table_names:
225
+ table = await self.get_table(self.split_composite_id(table_name))
226
+ tables.append(table)
227
+
228
+ if tables:
229
+ return self.factory.namespace_class(enums=[], structs=[], tables=tables)
230
+ else:
231
+ return self.factory.namespace_class()
@@ -0,0 +1,20 @@
1
+ from pysqlsync.base import BaseConnection, BaseEngine, BaseGenerator, Explorer
2
+
3
+ from .connection import OracleConnection
4
+ from .discovery import OracleExplorer
5
+ from .generator import OracleGenerator
6
+
7
+
8
+ class OracleEngine(BaseEngine):
9
+ @property
10
+ def name(self) -> str:
11
+ return "oracle"
12
+
13
+ def get_generator_type(self) -> type[BaseGenerator]:
14
+ return OracleGenerator
15
+
16
+ def get_connection_type(self) -> type[BaseConnection]:
17
+ return OracleConnection
18
+
19
+ def get_explorer_type(self) -> type[Explorer]:
20
+ return OracleExplorer