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,101 @@
1
+ """
2
+ pysqlsync: Synchronize schema and large volumes of data.
3
+
4
+ This module helps create a secure sockets layer (SSL) context.
5
+
6
+ :see: https://github.com/instructure-internal/pysqlsync
7
+ """
8
+
9
+ import enum
10
+ import ssl
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from typing import Optional
14
+ from urllib.parse import quote
15
+
16
+ if sys.version_info >= (3, 10):
17
+ import truststore
18
+ else:
19
+ import certifi
20
+
21
+
22
+ @enum.unique
23
+ class ConnectionSSLMode(enum.Enum):
24
+ # SSL is disabled.
25
+ disable = "disable"
26
+
27
+ # Try SSL first, fallback to non-SSL connection if SSL connection fails.
28
+ prefer = "prefer"
29
+
30
+ # Try without SSL first, then retry with SSL if the first attempt fails.
31
+ allow = "allow"
32
+
33
+ # Force an SSL connection. Certificate verification errors are ignored.
34
+ require = "require"
35
+
36
+ # Force an SSL connection, and verify that the server certificate is issued by a trusted
37
+ # certificate authority (CA).
38
+ verify_ca = "verify-ca"
39
+
40
+ # Force an SSL connection, verify that the server certificate is issued by a trusted CA,
41
+ # and that the requested server host name matches that in the certificate.
42
+ verify_full = "verify-full"
43
+
44
+
45
+ def create_context(ssl_mode: ConnectionSSLMode) -> Optional[ssl.SSLContext]:
46
+ "Creates an SSL context to pass to a database connection object."
47
+
48
+ if ssl_mode is ConnectionSSLMode.disable:
49
+ return None
50
+ elif ssl_mode is ConnectionSSLMode.prefer or ssl_mode is ConnectionSSLMode.allow or ssl_mode is ConnectionSSLMode.require:
51
+ ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
52
+ ctx.check_hostname = False
53
+ ctx.verify_mode = ssl.CERT_NONE
54
+ return ctx
55
+ elif ssl_mode is ConnectionSSLMode.verify_ca:
56
+ if sys.version_info >= (3, 10):
57
+ ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
58
+ else:
59
+ ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
60
+ ctx.check_hostname = False
61
+ ctx.verify_mode = ssl.CERT_REQUIRED
62
+ return ctx
63
+ elif ssl_mode is ConnectionSSLMode.verify_full:
64
+ if sys.version_info >= (3, 10):
65
+ ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
66
+ else:
67
+ ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
68
+ ctx.check_hostname = True
69
+ ctx.verify_mode = ssl.CERT_REQUIRED
70
+ return ctx
71
+ else:
72
+ raise ValueError(f"unsupported SSL mode: {ssl_mode}")
73
+
74
+
75
+ @dataclass
76
+ class ConnectionParameters:
77
+ """
78
+ Database connection parameters that would typically be encapsulated in a connection string.
79
+
80
+ :param host: Database server to connect to.
81
+ :param port: Port to use for the connection, `None` for default.
82
+ :param username: User identifier to log in with.
83
+ :param password: Password to log in with.
84
+ :param database: Database name to select as default (a.k.a. `USE`).
85
+ :param ssl: Connection mode for trusted connections.
86
+ """
87
+
88
+ host: Optional[str] = None
89
+ port: Optional[int] = None
90
+ username: Optional[str] = None
91
+ password: Optional[str] = None
92
+ database: Optional[str] = None
93
+ ssl: Optional[ConnectionSSLMode] = None
94
+
95
+ def __str__(self) -> str:
96
+ host = self.host or "localhost"
97
+ port = f":{self.port}" if self.port else ""
98
+ username = f"{quote(self.username, safe='')}@" if self.username else ""
99
+ database = f"/{quote(self.database, safe='')}" if self.database else ""
100
+ ssl = f"?ssl={self.ssl.value}" if self.ssl else ""
101
+ return f"{username}{host}{port}{database}{ssl}"
File without changes
@@ -0,0 +1,184 @@
1
+ from typing import Any, AsyncIterator, BinaryIO, Callable, Generic, Iterable, Optional, Protocol, TypeVar
2
+
3
+ from strong_typing.inspection import DataclassInstance, dataclass_fields
4
+ from tsv.helper import AutoDetectParser, Generator
5
+
6
+ from ..formation.inspection import reference_to_key
7
+ from ..model.properties import get_field_properties
8
+
9
+ D = TypeVar("D", bound=DataclassInstance)
10
+
11
+
12
+ def _get_extractor_fn(field_name: str) -> Callable[[Any], Any]:
13
+ """
14
+ Gets the value of a data-class instance attribute.
15
+
16
+ Must be a separate function to seal context and avoid re-assignment of `field_name`.
17
+ """
18
+
19
+ return lambda entity: getattr(entity, field_name)
20
+
21
+
22
+ class TextWriter(Generic[D]):
23
+ "Writes data to tab-separated values file."
24
+
25
+ generator: Generator
26
+ stream: BinaryIO
27
+
28
+ _extractors: list[Callable[[Any], Any]]
29
+
30
+ def __init__(
31
+ self,
32
+ stream: BinaryIO,
33
+ entity_type: type[D],
34
+ field_mapping: Optional[dict[str, str]] = None,
35
+ ) -> None:
36
+ """
37
+ Initializes a TSV writer.
38
+
39
+ :param stream: The file-like object to write to.
40
+ :param entity_type: The data-class type whose fields to persist.
41
+ :param field_mapping: A mapping from data-class field names to TSV field names. Defines field order.
42
+ """
43
+
44
+ self.stream = stream
45
+ self.generator = Generator()
46
+ self._extractors = []
47
+
48
+ if field_mapping is None:
49
+ field_mapping = {field.name: field.name for field in dataclass_fields(entity_type)}
50
+
51
+ for name in field_mapping.keys():
52
+ self._extractors.append(_get_extractor_fn(name))
53
+
54
+ stream.write(b"\t".join(name.encode("utf-8") for name in field_mapping.values()))
55
+ stream.write(b"\n")
56
+
57
+ def write_objects(self, entities: list[D]) -> None:
58
+ for entity in entities:
59
+ self.stream.write(self.generator.generate_line(tuple(extractor(entity) for extractor in self._extractors)))
60
+ self.stream.write(b"\n")
61
+
62
+
63
+ def fields_to_types(
64
+ entity_type: type[DataclassInstance],
65
+ field_mapping: Optional[dict[str, str]] = None,
66
+ ) -> dict[str, type]:
67
+ """
68
+ Creates a mapping for a TSV reader.
69
+
70
+ :param entity_type: The data-class type whose fields to populate.
71
+ :param field_mapping: A mapping from data-class field names to TSV field names.
72
+ """
73
+
74
+ if field_mapping is None:
75
+ field_mapping = {field.name: field.name for field in dataclass_fields(entity_type)}
76
+
77
+ return {
78
+ field_mapping[field.name]: get_field_properties(reference_to_key(field.type, entity_type)).tsv_type
79
+ for field in dataclass_fields(entity_type)
80
+ }
81
+
82
+
83
+ class TextReader:
84
+ "Reads data from tab-separated values file."
85
+
86
+ stream: BinaryIO
87
+ parser: AutoDetectParser
88
+ field_types: tuple[type, ...]
89
+
90
+ def __init__(self, stream: BinaryIO, names_to_types: dict[str, type]) -> None:
91
+ """
92
+ Initializes a TSV reader.
93
+
94
+ :param stream: The file-like object to read from.
95
+ :param names_to_types: A mapping from field names to TSV field types.
96
+ """
97
+
98
+ self.stream = stream
99
+ self.names_to_types = names_to_types
100
+
101
+ header = stream.readline().rstrip(b"\n")
102
+
103
+ self.parser = AutoDetectParser(names_to_types, header)
104
+ self.field_types = tuple(names_to_types[name] for name in self.parser.columns)
105
+
106
+ @property
107
+ def columns(self) -> tuple[str, ...]:
108
+ return self.parser.columns
109
+
110
+ def read_records(self) -> list[tuple[Any, ...]]:
111
+ "Reads all records from the file-like object."
112
+
113
+ rows: list[tuple[Any, ...]] = []
114
+ while True:
115
+ line = self.stream.readline().rstrip(b"\n")
116
+ if not line:
117
+ break
118
+ rows.append(self.parser.parse_line(line))
119
+ return rows
120
+
121
+ def records(self) -> Iterable[tuple[Any, ...]]:
122
+ "An iterator to records in a file-like object."
123
+
124
+ while True:
125
+ line = self.stream.readline().rstrip(b"\n")
126
+ if not line:
127
+ break
128
+ yield self.parser.parse_line(line)
129
+
130
+
131
+ class AsyncBinaryIO(Protocol):
132
+ async def readline(self, limit: int = -1) -> bytes: ...
133
+
134
+
135
+ class AsyncTextReader:
136
+ "Reads data from tab-separated values file."
137
+
138
+ stream: AsyncBinaryIO
139
+ parser: AutoDetectParser
140
+ names_to_types: dict[str, type]
141
+ field_types: tuple[type, ...]
142
+
143
+ def __init__(self, stream: AsyncBinaryIO, names_to_types: dict[str, type]) -> None:
144
+ """
145
+ Initializes a TSV reader.
146
+
147
+ :param stream: The asynchronous file-like object to read from.
148
+ :param names_to_types: A mapping from field names to TSV field types.
149
+ """
150
+
151
+ self.stream = stream
152
+ self.names_to_types = names_to_types
153
+
154
+ async def read_header(self) -> None:
155
+ line = await self.stream.readline()
156
+ header = line.rstrip(b"\n")
157
+ self.parser = AutoDetectParser(self.names_to_types, header)
158
+ self.field_types = tuple(self.names_to_types[name] for name in self.parser.columns)
159
+
160
+ @property
161
+ def columns(self) -> tuple[str, ...]:
162
+ return self.parser.columns
163
+
164
+ async def read_records(self) -> list[tuple[Any, ...]]:
165
+ "Reads all records from the file-like object."
166
+
167
+ rows: list[tuple[Any, ...]] = []
168
+ while True:
169
+ line = await self.stream.readline()
170
+ record = line.rstrip(b"\n")
171
+ if not record:
172
+ break
173
+ rows.append(self.parser.parse_line(record))
174
+ return rows
175
+
176
+ async def records(self) -> AsyncIterator[tuple[Any, ...]]:
177
+ "An asynchronous iterator to records in a file-like object."
178
+
179
+ while True:
180
+ line = await self.stream.readline()
181
+ record = line.rstrip(b"\n")
182
+ if not record:
183
+ break
184
+ yield self.parser.parse_line(record)
@@ -0,0 +1,305 @@
1
+ import datetime
2
+ import decimal
3
+ import enum
4
+ import random
5
+ import string
6
+ import typing
7
+ import uuid
8
+ from ipaddress import IPv4Address, IPv6Address, ip_address
9
+ from socket import AF_INET, AF_INET6, inet_ntop
10
+ from struct import pack
11
+ from typing import Any, Callable, Optional, TypeVar
12
+
13
+ from strong_typing.auxiliary import IntegerRange, MaxLength, MinLength, Precision
14
+ from strong_typing.inspection import (
15
+ DataclassInstance,
16
+ dataclass_fields,
17
+ get_annotation,
18
+ is_type_enum,
19
+ is_type_literal,
20
+ is_type_union,
21
+ unwrap_literal_values,
22
+ unwrap_union_types,
23
+ )
24
+ from strong_typing.serialization import object_to_json
25
+
26
+ from pysqlsync.formation.inspection import is_struct_type, reference_to_key
27
+ from pysqlsync.model.properties import get_field_properties
28
+
29
+ T = TypeVar("T")
30
+ E = TypeVar("E", bound=enum.Enum)
31
+
32
+
33
+ class DataGeneratorError(RuntimeError):
34
+ pass
35
+
36
+
37
+ def random_bool() -> bool:
38
+ return random.uniform(0.0, 1.0) > 0.5
39
+
40
+
41
+ def random_decimal(significant_digits: int, decimal_digits: int) -> decimal.Decimal:
42
+ integer_digits = significant_digits - decimal_digits
43
+ digits: list[str] = []
44
+ digits.extend(random.choices(string.digits, k=integer_digits))
45
+ digits.append(".")
46
+ digits.extend(random.choices(string.digits, k=decimal_digits))
47
+ return decimal.Decimal("".join(digits))
48
+
49
+
50
+ def time_today(time_of_day: datetime.time) -> datetime.datetime:
51
+ return datetime.datetime.combine(datetime.datetime.today(), time_of_day, time_of_day.tzinfo)
52
+
53
+
54
+ def random_datetime(start: datetime.datetime, end: datetime.datetime) -> datetime.datetime:
55
+ """
56
+ Returns a random datetime between two datetime objects.
57
+ """
58
+
59
+ delta = end - start
60
+ seconds = delta.days * 86400 + delta.seconds
61
+ return start + datetime.timedelta(seconds=random.randrange(seconds))
62
+
63
+
64
+ def random_date(start: datetime.date, end: datetime.date) -> datetime.date:
65
+ """
66
+ Returns a random date between two date objects.
67
+ """
68
+
69
+ delta = end - start
70
+ days = delta.days
71
+ return start + datetime.timedelta(days=random.randrange(days))
72
+
73
+
74
+ def random_time(start: Optional[datetime.time] = None, end: Optional[datetime.time] = None) -> datetime.time:
75
+ """
76
+ Returns a random time between two time objects.
77
+ """
78
+
79
+ if start is None:
80
+ start = datetime.time(0, 0, 0, tzinfo=datetime.timezone.utc)
81
+ if end is None:
82
+ end = datetime.time(23, 59, 59, tzinfo=datetime.timezone.utc)
83
+
84
+ ts_start = time_today(start)
85
+ ts_end = time_today(end)
86
+ delta = ts_end - ts_start
87
+ ts = ts_start + datetime.timedelta(seconds=random.randrange(delta.seconds))
88
+ return ts.timetz()
89
+
90
+
91
+ def random_enum(enum_type: type[E]) -> E:
92
+ """
93
+ Chooses an enumeration value randomly out of the possible set of values.
94
+ """
95
+
96
+ values = [e for e in enum_type]
97
+ return random.choice(values)
98
+
99
+
100
+ def shuffled(items: list[T]) -> list[T]:
101
+ items = items.copy()
102
+ random.shuffle(items)
103
+ return items
104
+
105
+
106
+ def random_enum_generator(
107
+ enum_type: type[E],
108
+ *,
109
+ count: Optional[int] = None,
110
+ min_count: Optional[int] = None,
111
+ max_count: Optional[int] = None,
112
+ ) -> Callable[[], list[E]]:
113
+ values = [e for e in enum_type]
114
+ if count is not None:
115
+ if count >= len(values):
116
+ return lambda: shuffled(values)
117
+ else:
118
+ return lambda: shuffled(values)[:count]
119
+ elif min_count is not None or max_count is not None:
120
+ min_c = min_count or 0
121
+ max_c = max_count or 4
122
+ c = max_c - min_c
123
+ return lambda: shuffled(values)[: min(min_c + random.randint(0, c), len(values))]
124
+ else:
125
+ raise TypeError("invalid parameter combination")
126
+
127
+
128
+ def random_ipv4addr() -> IPv4Address:
129
+ "Creates a random IPv4 address."
130
+
131
+ return typing.cast(
132
+ IPv4Address,
133
+ ip_address(inet_ntop(AF_INET, pack(">L", random.getrandbits(32)))),
134
+ )
135
+
136
+
137
+ def random_ipv6addr() -> IPv6Address:
138
+ "Creates a random IPv6 address."
139
+
140
+ return typing.cast(
141
+ IPv6Address,
142
+ ip_address(inet_ntop(AF_INET6, pack(">QQ", random.getrandbits(64), random.getrandbits(64)))),
143
+ )
144
+
145
+
146
+ def random_alphanumeric_str(min_len: int, max_len: int) -> str:
147
+ """
148
+ Creates a random string of alphanumeric characters.
149
+
150
+ :param min_len: The minimum number of characters the string should comprise of.
151
+ :param max_len: The maximum number of characters the string should comprise of.
152
+ """
153
+
154
+ return "".join(random.choices(string.ascii_letters + string.digits, k=random.randint(min_len, max_len)))
155
+
156
+
157
+ P = TypeVar("P")
158
+ R = TypeVar("R")
159
+
160
+
161
+ def call_repeat(generator: Callable[[P], R], arg: P, max_count: int) -> list[R]:
162
+ count = random.randint(0, max_count)
163
+ return [generator(arg) for _ in range(count)]
164
+
165
+
166
+ class RandomGenerator:
167
+ """
168
+ Generates a data-class instances with all their fields populated with random values recursively.
169
+ """
170
+
171
+ keys: list[int]
172
+
173
+ def __init__(self, count: int) -> None:
174
+ """
175
+ Initializes the generator to a sample size.
176
+
177
+ :param count: The total number of objects to generate.
178
+ """
179
+
180
+ self.keys = list(range(count))
181
+ random.shuffle(self.keys)
182
+
183
+ def create_json(self, cls: type) -> Callable[[int], dict[str, Any]]:
184
+ generators = {field.name: self.create(field.type, cls) for field in dataclass_fields(cls)}
185
+ return lambda k: {field_name: generator(k) for field_name, generator in generators.items()}
186
+
187
+ def create(self, typ: Any, cls: type[DataclassInstance]) -> Callable[[int], Any]:
188
+ """
189
+ Creates a generator for a random value.
190
+
191
+ :param typ: The type of the value to generate.
192
+ :param cls: The context in which the type occurs, used for evaluating forward reference types.
193
+ :returns: A callable object that takes a sequence index and returns a random value.
194
+ """
195
+
196
+ field_properties = get_field_properties(typ)
197
+ referenced_type = reference_to_key(field_properties.field_type, cls)
198
+
199
+ value_properties = get_field_properties(referenced_type)
200
+ plain_type = value_properties.plain_type
201
+ field_type = value_properties.field_type
202
+
203
+ if field_properties.is_primary:
204
+ if plain_type is int:
205
+ return lambda k: self.keys[k]
206
+ elif plain_type is uuid.UUID:
207
+ return lambda _: uuid.uuid4()
208
+
209
+ raise DataGeneratorError(f"unknown key type: {plain_type}")
210
+
211
+ if value_properties.nullable:
212
+ optional_generator = self.create(field_type, cls)
213
+ return lambda k: optional_generator(k) if random.uniform(0.0, 1.0) > 0.1 else None
214
+ elif plain_type is bool:
215
+ return lambda _: random_bool()
216
+ elif plain_type is int:
217
+ integer_range = get_annotation(field_type, IntegerRange)
218
+ if integer_range is not None:
219
+ minimum_value = integer_range.minimum
220
+ maximum_value = integer_range.maximum
221
+ return lambda _: random.randint(minimum_value, maximum_value)
222
+
223
+ return lambda _: random.randint(0, 1000000)
224
+ elif plain_type is float:
225
+ return lambda _: random.uniform(0.0, 1.0)
226
+ elif plain_type is datetime.datetime:
227
+ return lambda _: random_datetime(
228
+ datetime.datetime(1982, 10, 23, 2, 30, 0, tzinfo=datetime.timezone.utc),
229
+ datetime.datetime.now().replace(tzinfo=datetime.timezone.utc),
230
+ )
231
+ elif plain_type is datetime.date:
232
+ return lambda _: random_date(
233
+ datetime.date(1982, 10, 23),
234
+ datetime.date.today(),
235
+ )
236
+ elif plain_type is datetime.time:
237
+ return lambda _: random_time()
238
+ elif plain_type is str:
239
+ min_length_tag = get_annotation(field_type, MinLength)
240
+ # use a minimum length of at least 1; Oracle treats empty string as equivalent to NULL
241
+ min_length = min_length_tag.value if min_length_tag is not None else 1
242
+ max_length_tag = get_annotation(field_type, MaxLength)
243
+ max_length = max_length_tag.value if max_length_tag is not None else 255
244
+ return lambda _: random_alphanumeric_str(min_length, max_length)
245
+ elif plain_type is decimal.Decimal:
246
+ precision = get_annotation(field_type, Precision)
247
+ significant_digits = precision.significant_digits if precision else 10
248
+ decimal_digits = precision.decimal_digits if precision else 0
249
+ return lambda _: random_decimal(significant_digits, decimal_digits)
250
+ elif plain_type is uuid.UUID:
251
+ return lambda _: uuid.uuid4()
252
+ elif plain_type is IPv4Address:
253
+ return lambda _: random_ipv4addr()
254
+ elif plain_type is IPv6Address:
255
+ return lambda _: random_ipv6addr()
256
+ elif is_type_enum(plain_type):
257
+ return lambda _: random_enum(plain_type)
258
+ elif is_type_literal(plain_type):
259
+ literal_values = unwrap_literal_values(plain_type)
260
+ if len(literal_values) > 1:
261
+ return lambda _: random.choice(literal_values)
262
+ elif len(literal_values) > 0:
263
+ literal_value = literal_values[0]
264
+ return lambda _: literal_value
265
+ else:
266
+ raise TypeError("empty `typing.Literal`")
267
+ elif is_type_union(plain_type):
268
+ union_types = unwrap_union_types(plain_type)
269
+ union_generators = [self.create(union_type, cls) for union_type in union_types]
270
+ return lambda k: random.choice(union_generators)(k)
271
+ elif is_struct_type(plain_type):
272
+ json_generator = self.create_json(plain_type)
273
+ return lambda k: object_to_json(json_generator(k))
274
+
275
+ origin_type = typing.get_origin(plain_type)
276
+ if origin_type is list or origin_type is set:
277
+ (element_type,) = typing.get_args(plain_type)
278
+ if is_type_enum(element_type):
279
+ enum_generator = random_enum_generator(element_type, max_count=5)
280
+ return lambda _: object_to_json(enum_generator())
281
+ else:
282
+ element_generator = self.create(element_type, cls)
283
+ return lambda k: object_to_json(call_repeat(element_generator, k, 5))
284
+
285
+ raise DataGeneratorError(f"unknown value type: {plain_type}")
286
+
287
+
288
+ D = TypeVar("D", bound=DataclassInstance)
289
+
290
+
291
+ def random_objects(cls: type[D], count: int) -> list[D]:
292
+ """
293
+ Creates a list of data-class instances with all their fields populated with random values recursively.
294
+ """
295
+
296
+ random_generator = RandomGenerator(count)
297
+
298
+ generators = {field.name: random_generator.create(field.type, cls) for field in dataclass_fields(cls)}
299
+
300
+ items: list[D] = []
301
+ for k in range(0, count):
302
+ args = {field_name: generator(k) for field_name, generator in generators.items()}
303
+ items.append(cls(**args))
304
+
305
+ return items
@@ -0,0 +1,35 @@
1
+ # Dialects
2
+
3
+ pysqlsync comes with several database dialects shipped with the library. However, it is possible to create and register new dialects that behave the same way as built-in dialects. In terms of capabilities, there are no differences between built-in and user-defined dialects.
4
+
5
+ If you are about to write integration for a new database dialect, it is recommended that you take one of the existing dialects (e.g. PostgreSQL, Microsoft SQL Server or MySQL), and use it as a template.
6
+
7
+ ## Register a new dialect
8
+
9
+ New dialects are added by calling the function `pysqlsync.factory.register_dialect`. The dialect has to derive from `BaseEngine`, which is a factory class for generator, connection and explorer objects. Each dialect has to provide its own implementation of `BaseEngine`, which returns the appropriate class types.
10
+
11
+ ## Generator
12
+
13
+ The generator class is the most fundamental of the three base classes, and is responsible for generating SQL statements for creating or dropping database objects (e.g. tables, structs or enums), and inserting, updating or deleting data. The base class for a generator is `BaseGenerator`.
14
+
15
+ A generator maintains a state, either created explicitly in code or discovered from a database. A state is a collection of Python objects that represent the set of namespaces (schemas), tables, structs, enums, constraints, etc. that exist in a database.
16
+
17
+ A generator encapsulates a data-class converter (an instance of `DataclassConverter`), which takes a Python data-class type and returns a collection of Python objects that may in turn be used to generate SQL. The data-class converter is identical across dialects but each dialect passes different configuration to the converter to account for differences between dialects, e.g. PostgreSQL has a dedicated `enum` type, MySQL and Oracle have in-line enumeration types, and Microsoft SQL Server has no enumeration type at all. By passing the appropriate configuration options to the data-class converter, the dialect implementation can ensure that the appropriate Python objects are instantiated either for database objects or database data types. For example, the PostgreSQL dialect would use `PostgreSQLJsonType` instead of the basic `SqlJsonType` to represent the PostgreSQL-specific compact JSON type [jsonb](https://www.postgresql.org/docs/current/datatype-json.html).
18
+
19
+ A generator also holds a reference to an object factory. The object factory ensures that the data-class converter or the explorer instantiate the dialect-specific Python object types. For example, the PostgreSQL dialect has `PostgreSQLTable`, which differs from the ANSI-compliant `Table` in that it can associate comments with table objects.
20
+
21
+ Finally, the generator has a mutator, whose job is to emit SQL statements to transform a database source state into a target state. In general, the mutator is the same across dialects, inheritance helps specialize the implementation to account for dialect-specific differences. For example, PostgreSQL or MySQL have comments for database objects, and the mutator can help add/remove comments when a database object is modified.
22
+
23
+ In writing a specialization for a generator, one would pass the appropriate configuration options to the data-class converter in the `__init__` method, override methods such as `get_table_insert_stmt` to tell what SQL statement is generated when data is about to be inserted/updated/deleted, and override methods such as `get_value_transformer` to define mappings between native Python types and the binding types the database driver (e.g. `asyncpg`) is expecting.
24
+
25
+ ## Connection and context
26
+
27
+ The connection class represents an active connection to the database server. It holds a native connection object (e.g. `asyncpg.Connection`). When the connection is opened, it yields a context object. The dialect-specific context class may possibly override when happens on operations such as executing a SQL statement, running a query, or inserting data.
28
+
29
+ pysqlsync runs in an asynchronous context. Many database drivers (e.g. `asyncpg` or `aiomysql`) support asynchronous operations natively. For database drivers that require sycnhronous calls, it is the responsibility of the connection implementation to dispatch the task to a worker thread. pysqlsync provides the decorator `thread_dispatch` for this purpose.
30
+
31
+ ## Explorer
32
+
33
+ The role of the explorer class is to use `information_schema` (or its dialect-specific equivalent such as `pg_catalog` in PostgreSQL or `sys` in Microsoft SQL server) to create a hierarchy of Python objects that represent the database state. By comparing the source state with a target state, the generator can emit SQL statements that mutate the database, e.g. create/drop database objects, or modify existing objects.
34
+
35
+ If the database dialect supports `information_schema`, the explorer implementation can simply inherit from `AnsiExplorer`, and override only those methods where the database dialect has additional features (e.g. identity columns in Microsoft SQL Server, or `auto_increment` or column comments in MySQL).
File without changes
File without changes
@@ -0,0 +1,51 @@
1
+ from dataclasses import dataclass
2
+
3
+ from pysqlsync.model.data_types import (
4
+ SqlDoubleType,
5
+ SqlFixedBinaryType,
6
+ SqlRealType,
7
+ SqlTimestampType,
8
+ SqlVariableBinaryType,
9
+ SqlVariableCharacterType,
10
+ )
11
+
12
+
13
+ @dataclass
14
+ class DeltaRealType(SqlRealType):
15
+ def __str__(self) -> str:
16
+ return "float"
17
+
18
+
19
+ @dataclass
20
+ class DeltaDoubleType(SqlDoubleType):
21
+ def __str__(self) -> str:
22
+ return "double"
23
+
24
+
25
+ @dataclass
26
+ class DeltaFixedBinaryType(SqlFixedBinaryType):
27
+ def __str__(self) -> str:
28
+ return "binary"
29
+
30
+
31
+ @dataclass
32
+ class DeltaVariableBinaryType(SqlVariableBinaryType):
33
+ def __str__(self) -> str:
34
+ return "binary"
35
+
36
+
37
+ @dataclass
38
+ class DeltaVariableCharacterType(SqlVariableCharacterType):
39
+ def __str__(self) -> str:
40
+ return "string"
41
+
42
+
43
+ @dataclass
44
+ class DeltaTimestampType(SqlTimestampType):
45
+ "Timestamp without time zone."
46
+
47
+ def __init__(self) -> None:
48
+ self.precision = 9
49
+
50
+ def __str__(self) -> str:
51
+ return "timestamp_ntz"
@@ -0,0 +1,7 @@
1
+ """
2
+ pysqlsync: Synchronize schema and large volumes of data.
3
+
4
+ This module defines dependencies required for Delta Lake on Databricks.
5
+
6
+ :see: https://github.com/instructure-internal/pysqlsync
7
+ """