alchemy-kit 0.2.0__tar.gz → 0.2.1__tar.gz

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 (110) hide show
  1. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.gitignore +1 -0
  2. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/PKG-INFO +1 -1
  3. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/connect/__init__.py +2 -0
  4. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/connect/_conn_builders/mssql.py +21 -0
  5. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/connect/info_builder.py +38 -4
  6. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model_def/_extractor.py +77 -7
  7. alchemy_kit-0.2.1/alchemy_kit/model/_model_def/_sql/mssql_check_constraints.sql +9 -0
  8. alchemy_kit-0.2.1/alchemy_kit/model/_model_def/_sql/mssql_unique_constraints.sql +14 -0
  9. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/_settings.py +2 -0
  10. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dialect_map/_base.py +10 -4
  11. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dialect_map/mariadb.py +8 -0
  12. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dialect_map/mssql.py +8 -0
  13. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dialect_map/mysql.py +8 -0
  14. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dialect_map/oracle.py +9 -0
  15. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dialect_map/postgresql.py +9 -0
  16. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dialect_map/sqlite.py +8 -0
  17. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/_driver_types.py +6 -3
  18. alchemy_kit-0.2.1/docs/builder/base-builder.md +0 -0
  19. alchemy_kit-0.2.1/docs/builder/insert-builder.md +0 -0
  20. alchemy_kit-0.2.1/docs/builder/select-builder.md +0 -0
  21. alchemy_kit-0.2.1/docs/builder/temp-builder.md +0 -0
  22. alchemy_kit-0.2.1/docs/connect/connection-info.md +0 -0
  23. alchemy_kit-0.2.1/docs/connect/engine-handler.md +0 -0
  24. alchemy_kit-0.2.1/docs/connect/engine-manager.md +0 -0
  25. alchemy_kit-0.2.1/docs/connect/info-builder.md +0 -0
  26. alchemy_kit-0.2.1/docs/model/base-model.md +0 -0
  27. alchemy_kit-0.2.1/docs/model/build.md +0 -0
  28. alchemy_kit-0.2.1/docs/model/schema-config.md +0 -0
  29. alchemy_kit-0.2.1/docs/model/units.md +0 -0
  30. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/pyproject.toml +1 -1
  31. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/model/test_builder.py +10 -1
  32. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/model/test_inspector_def.py +90 -0
  33. alchemy_kit-0.2.1/tests/resources/test_reflected_type_facts.py +82 -0
  34. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/uv.lock +289 -3
  35. alchemy_kit-0.2.0/builder.md +0 -222
  36. alchemy_kit-0.2.0/tests/resources/test_reflected_type_facts.py +0 -41
  37. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.claude/settings.local.json +0 -0
  38. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.grimp_cache/.gitignore +0 -0
  39. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.grimp_cache/CACHEDIR.TAG +0 -0
  40. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.grimp_cache/alchemy_kit.meta.json +0 -0
  41. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.grimp_cache/b436f6e8205b1418cbee6b7347a3c7b0d1be876b.data.json +0 -0
  42. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.import_linter_cache/.gitignore +0 -0
  43. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.import_linter_cache/CACHEDIR.TAG +0 -0
  44. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.import_linter_cache/alchemy_kit.meta.json +0 -0
  45. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.import_linter_cache/b436f6e8205b1418cbee6b7347a3c7b0d1be876b.data.json +0 -0
  46. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/.python-version +0 -0
  47. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/LICENSE +0 -0
  48. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/README.md +0 -0
  49. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/__init__.py +0 -0
  50. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/builder/__init__.py +0 -0
  51. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/builder/_base.py +0 -0
  52. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/builder/_insert.py +0 -0
  53. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/builder/_select.py +0 -0
  54. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/builder/_temp.py +0 -0
  55. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/connect/_conn_builders/__init__.py +0 -0
  56. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/connect/_drivers.py +0 -0
  57. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/connect/_engine_handler.py +0 -0
  58. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/connect/_engine_manager.py +0 -0
  59. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/connect/_info.py +0 -0
  60. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/__init__.py +0 -0
  61. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builder.py +0 -0
  62. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/__init__.py +0 -0
  63. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/_build_py_file.py +0 -0
  64. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/_build_stub_file.py +0 -0
  65. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/_check_parser.py +0 -0
  66. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/_column_metadata.py +0 -0
  67. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/_model.py +0 -0
  68. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/_svg.py +0 -0
  69. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/py_template_file.txt +0 -0
  70. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_builders/stub_template_file.txt +0 -0
  71. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model/__init__.py +0 -0
  72. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model/column_model.py +0 -0
  73. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model/constraint_model.py +0 -0
  74. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model/db_model.py +0 -0
  75. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model/object_model.py +0 -0
  76. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model/schema_model.py +0 -0
  77. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model_def/__init__.py +0 -0
  78. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_model_def/_frames.py +0 -0
  79. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_schema_config.py +0 -0
  80. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/_utils.py +0 -0
  81. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/base_model.py +0 -0
  82. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/units/__init__.py +0 -0
  83. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/units/_column_unit.py +0 -0
  84. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/model/units/_object_unit.py +0 -0
  85. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/__init__.py +0 -0
  86. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/_better_logger.py +0 -0
  87. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/_identifiers.py +0 -0
  88. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/_sql.py +0 -0
  89. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dialect_map/__init__.py +0 -0
  90. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/resources/dir_helpers.py +0 -0
  91. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/__init__.py +0 -0
  92. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/_sql_parameters.py +0 -0
  93. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/_sql_utilities.py +0 -0
  94. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/api_types.py +0 -0
  95. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/auth_types.py +0 -0
  96. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/dialect_types.py +0 -0
  97. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/errors/__init__.py +0 -0
  98. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/errors/_model_validation_error.py +0 -0
  99. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/generic_path.py +0 -0
  100. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/py_type_parameters.py +0 -0
  101. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/sql_type_parameters.py +0 -0
  102. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/alchemy_kit/types/sql_types.py +0 -0
  103. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/builder/test_builders.py +0 -0
  104. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/connect/test_connect.py +0 -0
  105. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/connect/test_engine_handler.py +0 -0
  106. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/dotenv_test +0 -0
  107. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/model/test_check_codegen.py +0 -0
  108. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/model/test_check_parser.py +0 -0
  109. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/model/test_fk_validation.py +0 -0
  110. {alchemy_kit-0.2.0 → alchemy_kit-0.2.1}/tests/test_json.json +0 -0
@@ -1,4 +1,5 @@
1
1
  secret*
2
+ .env*
2
3
 
3
4
  # Byte-compiled / optimized / DLL files
4
5
  __pycache__/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alchemy-kit
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Add your description here
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,8 +1,10 @@
1
+ from . import info_builder
1
2
  from ._engine_handler import EngineHandler
2
3
  from ._engine_manager import EngineManager
3
4
  from ._info import ConnectionInfo
4
5
 
5
6
  __all__ = [
7
+ "info_builder",
6
8
  "EngineHandler",
7
9
  "EngineManager",
8
10
  "ConnectionInfo",
@@ -1,3 +1,5 @@
1
+ from typing import Optional
2
+
1
3
  from pydantic import SecretStr
2
4
  import sqlalchemy
3
5
 
@@ -5,6 +7,18 @@ from ...types import _driver_types
5
7
  from ...types.api_types import SqlServerApi
6
8
 
7
9
 
10
+ def _tls_query(
11
+ encrypt: Optional[bool],
12
+ trust_server_certificate: Optional[bool],
13
+ ) -> dict[str, str]:
14
+ query: dict[str, str] = {}
15
+ if encrypt is not None:
16
+ query["Encrypt"] = "yes" if encrypt else "no"
17
+ if trust_server_certificate is not None:
18
+ query["TrustServerCertificate"] = "yes" if trust_server_certificate else "no"
19
+ return query
20
+
21
+
8
22
  def sql_auth(
9
23
  driver: _driver_types.SqlServerDrivers,
10
24
  server: str,
@@ -12,6 +26,8 @@ def sql_auth(
12
26
  user_name: str,
13
27
  user_pwd: SecretStr,
14
28
  api: SqlServerApi,
29
+ encrypt: Optional[bool] = None,
30
+ trust_server_certificate: Optional[bool] = None,
15
31
  ) -> sqlalchemy.URL:
16
32
 
17
33
  if isinstance(driver, _driver_types.SqlServerODBC):
@@ -25,6 +41,7 @@ def sql_auth(
25
41
  "driver": driver,
26
42
  "ApplicationIntent": "ReadWrite",
27
43
  "MultiSubnetFailover": "yes",
44
+ **_tls_query(encrypt, trust_server_certificate),
28
45
  }
29
46
  )
30
47
 
@@ -37,6 +54,7 @@ def sql_auth(
37
54
  database=database,
38
55
  query={
39
56
  "driver": driver,
57
+ **_tls_query(encrypt, trust_server_certificate),
40
58
  }
41
59
  )
42
60
 
@@ -46,6 +64,8 @@ def microsoft_auth(
46
64
  server: str,
47
65
  database: str,
48
66
  api: SqlServerApi,
67
+ encrypt: Optional[bool] = None,
68
+ trust_server_certificate: Optional[bool] = None,
49
69
  ) -> sqlalchemy.URL:
50
70
  return sqlalchemy.URL.create(
51
71
  drivername=f"mssql+{api}",
@@ -56,5 +76,6 @@ def microsoft_auth(
56
76
  "Trusted_Connection": "yes",
57
77
  "ApplicationIntent": "ReadWrite",
58
78
  "MultiSubnetFailover": "yes",
79
+ **_tls_query(encrypt, trust_server_certificate),
59
80
  }
60
81
  )
@@ -84,6 +84,20 @@ def from_env(env_path: GenericPath) -> ConnectionInfo:
84
84
 
85
85
  return _match_dialect(dialect, env_data.get(Settings.FileExtraction.unique_id_marker), env_data)
86
86
 
87
+ _TRUE_VALUES = {"yes", "true", "1", "on"}
88
+ _FALSE_VALUES = {"no", "false", "0", "off"}
89
+
90
+ def _parse_optional_bool(value: Optional[str]) -> Optional[bool]:
91
+ """Parse a connection-file flag into a tri-state bool (``None`` when unset)."""
92
+ if value is None or value == "":
93
+ return None
94
+ normalized = value.strip().lower()
95
+ if normalized in _TRUE_VALUES:
96
+ return True
97
+ if normalized in _FALSE_VALUES:
98
+ return False
99
+ raise ValueError(f"Expected a boolean-like value, got {value!r}")
100
+
87
101
  def _match_dialect(dialect: DialectTypes, unique_id: Optional[str], conn_data: dict[str, str]) -> ConnectionInfo: # CONTINUE
88
102
  """Dispatch a parsed connection definition to the right dialect builder.
89
103
 
@@ -109,6 +123,10 @@ def _match_dialect(dialect: DialectTypes, unique_id: Optional[str], conn_data: d
109
123
  case DialectTypes.MSSQL:
110
124
  driver = _driver_types.parse_sql_server_driver(conn_data[Settings.FileExtraction.driver_marker])
111
125
  api = SqlServerApi(conn_data.get(Settings.FileExtraction.api_marker, SqlServerApi.PYODBC))
126
+ encrypt = _parse_optional_bool(conn_data.get(Settings.FileExtraction.encrypt_marker))
127
+ trust_server_certificate = _parse_optional_bool(
128
+ conn_data.get(Settings.FileExtraction.trust_server_certificate_marker)
129
+ )
112
130
 
113
131
  if auth == AuthType.SQL_AUTH:
114
132
  return from_values_mssql(
@@ -119,7 +137,9 @@ def _match_dialect(dialect: DialectTypes, unique_id: Optional[str], conn_data: d
119
137
  user_name=conn_data[Settings.FileExtraction.user_name_marker],
120
138
  user_pwd=SecretStr(conn_data[Settings.FileExtraction.user_pwd_marker]),
121
139
  unique_id=unique_id,
122
- api=cast(SqlServerApi, api)
140
+ api=cast(SqlServerApi, api),
141
+ encrypt=encrypt,
142
+ trust_server_certificate=trust_server_certificate,
123
143
  )
124
144
  elif auth == AuthType.MICROSOFT_AUTH:
125
145
  return from_values_mssql(
@@ -128,7 +148,9 @@ def _match_dialect(dialect: DialectTypes, unique_id: Optional[str], conn_data: d
128
148
  conn_data[Settings.FileExtraction.server_marker],
129
149
  conn_data[Settings.FileExtraction.database_marker],
130
150
  unique_id=unique_id,
131
- api=api
151
+ api=api,
152
+ encrypt=encrypt,
153
+ trust_server_certificate=trust_server_certificate,
132
154
  )
133
155
  else:
134
156
  raise ValueError(f"'{auth}' is not a supported authentication method")
@@ -157,6 +179,8 @@ def from_values_mssql(
157
179
  *,
158
180
  unique_id: Optional[str] = None,
159
181
  api: SqlServerApi = SqlServerApi.PYODBC,
182
+ encrypt: Optional[bool] = None,
183
+ trust_server_certificate: Optional[bool] = None,
160
184
  ) -> ConnectionInfo: ...
161
185
  @overload
162
186
  def from_values_mssql(
@@ -169,6 +193,8 @@ def from_values_mssql(
169
193
  user_pwd: SecretStr,
170
194
  unique_id: Optional[str] = None,
171
195
  api: SqlServerApi = SqlServerApi.PYODBC,
196
+ encrypt: Optional[bool] = None,
197
+ trust_server_certificate: Optional[bool] = None,
172
198
  ) -> ConnectionInfo: ...
173
199
 
174
200
  def from_values_mssql(
@@ -181,6 +207,8 @@ def from_values_mssql(
181
207
  user_pwd: Optional[SecretStr] = None,
182
208
  unique_id: Optional[str] = None,
183
209
  api: SqlServerApi = SqlServerApi.PYODBC,
210
+ encrypt: Optional[bool] = None,
211
+ trust_server_certificate: Optional[bool] = None,
184
212
  ) -> ConnectionInfo:
185
213
  """Build a SQL Server connection from individual values.
186
214
 
@@ -214,10 +242,16 @@ def from_values_mssql(
214
242
  case AuthType.SQL_AUTH:
215
243
  if user_name is None or user_pwd is None:
216
244
  raise ValueError("SQL Authentication needs user_name and 'user_pwd' to be 'passed'")
217
- conn_url = mssql.sql_auth(driver, server, database, user_name, user_pwd, api)
245
+ conn_url = mssql.sql_auth(
246
+ driver, server, database, user_name, user_pwd, api,
247
+ encrypt, trust_server_certificate,
248
+ )
218
249
 
219
250
  case AuthType.MICROSOFT_AUTH:
220
- conn_url = mssql.microsoft_auth(driver, server, database, api)
251
+ conn_url = mssql.microsoft_auth(
252
+ driver, server, database, api,
253
+ encrypt, trust_server_certificate,
254
+ )
221
255
 
222
256
  return ConnectionInfo(conn_url, unique_id)
223
257
 
@@ -1,11 +1,14 @@
1
1
  import re
2
+ from pathlib import Path
2
3
  from typing import Any, Mapping, Sequence
3
4
 
4
5
  import pandas as pd
5
6
  from pandera.typing import DataFrame
7
+ from sqlalchemy.engine.interfaces import ReflectedCheckConstraint, ReflectedUniqueConstraint
6
8
  from sqlalchemy.exc import NoSuchTableError
7
9
 
8
10
  from ...connect._engine_handler import EngineHandler
11
+ from ...resources._sql import SQL
9
12
  from ...resources.dialect_map import DialectMap, ReflectedTypeFacts, get_map
10
13
  from ...types.dialect_types import DialectTypes
11
14
  from .._schema_config import SchemaConfig
@@ -35,6 +38,16 @@ _SYSTEM_SCHEMA_PREFIXES: dict[DialectTypes, tuple[str, ...]] = {
35
38
  DialectTypes.POSTGRESQL: ("pg_",),
36
39
  }
37
40
 
41
+ _FALLBACK_SQL_DIR = Path(__file__).parent / "_sql"
42
+
43
+ _UNIQUE_CONSTRAINTS_FALLBACK_SQL: dict[DialectTypes, str] = {
44
+ DialectTypes.MSSQL: "mssql_unique_constraints",
45
+ }
46
+
47
+ _CHECK_CONSTRAINTS_FALLBACK_SQL: dict[DialectTypes, str] = {
48
+ DialectTypes.MSSQL: "mssql_check_constraints",
49
+ }
50
+
38
51
 
39
52
  class MetadataExtractor:
40
53
  """Database metadata extraction backed by ``sqlalchemy.Inspector``.
@@ -166,7 +179,7 @@ class MetadataExtractor:
166
179
  index_type="PRIMARY KEY",
167
180
  )
168
181
 
169
- for constraint in self._inspector.get_unique_constraints(object_name, schema=schema_name):
182
+ for constraint in self._get_unique_constraints(schema_name, object_name):
170
183
  add_cluster(
171
184
  constraint.get("name") or f"uq_{object_name}",
172
185
  constraint["column_names"],
@@ -217,11 +230,7 @@ class MetadataExtractor:
217
230
  return ListForeignKeys.validate(df)
218
231
 
219
232
  def list_check_constraints(self, schema_name: str, object_name: str) -> DataFrame[ListCheckConstraints]:
220
- try:
221
- checks = self._inspector.get_check_constraints(object_name, schema=schema_name)
222
- except NotImplementedError:
223
- checks = []
224
-
233
+ checks = self._get_check_constraints(schema_name, object_name)
225
234
  column_names = [column["name"] for column in self._get_columns(schema_name, object_name)]
226
235
 
227
236
  rows = []
@@ -261,10 +270,71 @@ class MetadataExtractor:
261
270
  pk = self._inspector.get_pk_constraint(object_name, schema=schema_name)
262
271
  return list(pk.get("constrained_columns") or []) if pk else []
263
272
 
273
+ def _get_unique_constraints(self, schema_name: str, object_name: str) -> list[ReflectedUniqueConstraint]:
274
+ try:
275
+ return self._inspector.get_unique_constraints(object_name, schema=schema_name)
276
+ except NotImplementedError:
277
+ return self._unique_constraints_fallback(schema_name, object_name)
278
+
279
+ def _unique_constraints_fallback(self, schema_name: str, object_name: str) -> list[ReflectedUniqueConstraint]:
280
+ data = self._run_fallback_sql(_UNIQUE_CONSTRAINTS_FALLBACK_SQL, schema_name, object_name)
281
+ if data is None:
282
+ return []
283
+
284
+ grouped: dict[str, list[str]] = {}
285
+ for row in data.itertuples(index=False):
286
+ grouped.setdefault(str(row.constraint_name), []).append(str(row.column_name))
287
+
288
+ return [
289
+ ReflectedUniqueConstraint(
290
+ name=name,
291
+ column_names=columns,
292
+ comment=None,
293
+ duplicates_index=None,
294
+ dialect_options={},
295
+ )
296
+ for name, columns in grouped.items()
297
+ ]
298
+
299
+ def _get_check_constraints(self, schema_name: str, object_name: str) -> list[ReflectedCheckConstraint]:
300
+ try:
301
+ return self._inspector.get_check_constraints(object_name, schema=schema_name)
302
+ except NotImplementedError:
303
+ return self._check_constraints_fallback(schema_name, object_name)
304
+
305
+ def _check_constraints_fallback(self, schema_name: str, object_name: str) -> list[ReflectedCheckConstraint]:
306
+ data = self._run_fallback_sql(_CHECK_CONSTRAINTS_FALLBACK_SQL, schema_name, object_name)
307
+ if data is None:
308
+ return []
309
+
310
+ return [
311
+ ReflectedCheckConstraint(
312
+ name=str(row.constraint_name),
313
+ sqltext=str(row.sqltext),
314
+ comment=None,
315
+ dialect_options={},
316
+ )
317
+ for row in data.itertuples(index=False)
318
+ ]
319
+
320
+ def _run_fallback_sql(self, sql_names: Mapping[DialectTypes, str], schema_name: str, object_name: str) -> pd.DataFrame | None:
321
+ sql_name = sql_names.get(self._dialect)
322
+ if sql_name is None:
323
+ return None
324
+
325
+ sql = SQL(
326
+ sql_path=sql_name,
327
+ query_parameters={"schema_name": schema_name, "object_name": object_name},
328
+ )
329
+ sql.set_script_dir(_FALLBACK_SQL_DIR)
330
+
331
+ _, data = self._handler.run_sql(sql)
332
+ return data
333
+
264
334
  def _get_single_column_uniques(self, schema_name: str, object_name: str) -> set[str]:
265
335
  unique_columns: set[str] = set()
266
336
 
267
- for constraint in self._inspector.get_unique_constraints(object_name, schema=schema_name):
337
+ for constraint in self._get_unique_constraints(schema_name, object_name):
268
338
  if len(constraint["column_names"]) == 1:
269
339
  unique_columns.add(constraint["column_names"][0])
270
340
 
@@ -0,0 +1,9 @@
1
+ SELECT
2
+ cc.name AS constraint_name,
3
+ cc.definition AS sqltext
4
+ FROM sys.check_constraints cc
5
+ JOIN sys.tables t ON t.object_id = cc.parent_object_id
6
+ JOIN sys.schemas s ON s.schema_id = t.schema_id
7
+ WHERE s.name = @schema_name
8
+ AND t.name = @object_name
9
+ ORDER BY cc.name
@@ -0,0 +1,14 @@
1
+ SELECT
2
+ kc.name AS constraint_name,
3
+ c.name AS column_name
4
+ FROM sys.key_constraints kc
5
+ JOIN sys.tables t ON t.object_id = kc.parent_object_id
6
+ JOIN sys.schemas s ON s.schema_id = t.schema_id
7
+ JOIN sys.index_columns ic ON ic.object_id = kc.parent_object_id
8
+ AND ic.index_id = kc.unique_index_id
9
+ JOIN sys.columns c ON c.object_id = ic.object_id
10
+ AND c.column_id = ic.column_id
11
+ WHERE kc.type = 'UQ'
12
+ AND s.name = @schema_name
13
+ AND t.name = @object_name
14
+ ORDER BY kc.name, ic.key_ordinal
@@ -42,4 +42,6 @@ class Settings(NotInstantiable):
42
42
  database_marker = "database"
43
43
  user_name_marker = "user_name"
44
44
  user_pwd_marker = "user_pwd"
45
+ encrypt_marker = "encrypt"
46
+ trust_server_certificate_marker = "trust_server_certificate"
45
47
 
@@ -54,6 +54,7 @@ class DialectMap[_TypeParameters: str](ABC):
54
54
  dialect_paramaters: ClassVar[frozenset[str]]
55
55
  py_types: ClassVar[dict[str, PyTypeParameters]]
56
56
  sa_types: ClassVar[dict[str, SaTypeFactory]]
57
+ _reflected_synonyms: ClassVar[dict[str, str]] = {}
57
58
 
58
59
  _quote_open: ClassVar[str] = '"'
59
60
  _quote_close: ClassVar[str] = '"'
@@ -108,9 +109,13 @@ class DialectMap[_TypeParameters: str](ABC):
108
109
 
109
110
  The type name is the lowercased class name, which for dialect-specific
110
111
  reflected types matches the dialect's SQL type name (``NVARCHAR`` ->
111
- ``nvarchar``). A string type whose ``length`` is ``None`` is unbounded
112
- and reported as ``-1``; parameters a type does not carry are ``0``.
113
- Dialects with different conventions override this.
112
+ ``nvarchar``). Dialects sometimes reflect a SQL type as a class whose
113
+ name differs from the type's own name (MSSQL ``int`` ->
114
+ ``sqltypes.INTEGER``); ``_reflected_synonyms`` maps those class names
115
+ back to the dialect's canonical type name. A string type whose
116
+ ``length`` is ``None`` is unbounded and reported as ``-1``; parameters
117
+ a type does not carry are ``0``. Dialects with different conventions
118
+ override this.
114
119
  """
115
120
  length = getattr(sa_type, "length", None)
116
121
  if hasattr(sa_type, "length") and length is None:
@@ -118,8 +123,9 @@ class DialectMap[_TypeParameters: str](ABC):
118
123
  else:
119
124
  max_length = length or 0
120
125
 
126
+ sql_type = type(sa_type).__name__.lower()
121
127
  return ReflectedTypeFacts(
122
- sql_type=type(sa_type).__name__.lower(),
128
+ sql_type=cls._reflected_synonyms.get(sql_type, sql_type),
123
129
  max_length=max_length,
124
130
  precision=getattr(sa_type, "precision", None) or 0,
125
131
  scale=getattr(sa_type, "scale", None) or 0,
@@ -140,6 +140,13 @@ class MariadbMap(DialectMap[MariadbTypeParameters]):
140
140
  "geometrycollection": NullType,
141
141
  }
142
142
 
143
+ # Reflected class name -> dialect type name (NCHAR/NVARCHAR are national
144
+ # charset variants MariaDB stores as char/varchar)
145
+ _REFLECTED_SYNONYMS: dict[str, MariadbTypeParameters] = {
146
+ "nchar": "char",
147
+ "nvarchar": "varchar",
148
+ }
149
+
143
150
  # SQL type name buckets (MariaDB lengths are in characters)
144
151
  _length_types_char: set[MariadbTypeParameters] = {"char", "varchar"}
145
152
  _length_types_bin: set[MariadbTypeParameters] = {"binary", "varbinary"}
@@ -150,6 +157,7 @@ class MariadbMap(DialectMap[MariadbTypeParameters]):
150
157
  # Parent parameters
151
158
  py_types = cast(dict[str, PyTypeParameters], _PY_TYPES)
152
159
  sa_types = cast(dict[str, SaTypeFactory], _SA_TYPES)
160
+ _reflected_synonyms = cast(dict[str, str], _REFLECTED_SYNONYMS)
153
161
  dialect = DialectTypes.MARIADB
154
162
  dialect_paramaters = frozenset(get_args(MariadbTypeParameters))
155
163
  _quote_open = "`"
@@ -95,6 +95,13 @@ class MssqlMap(DialectMap[MssqlTypeParameters]):
95
95
  "sql_variant": sa_mssql.SQL_VARIANT,
96
96
  }
97
97
 
98
+ # Reflected class name -> dialect type name ("int" reflects as
99
+ # sqltypes.INTEGER; "double precision" is an alias of float(53))
100
+ _REFLECTED_SYNONYMS: dict[str, MssqlTypeParameters] = {
101
+ "integer": "int",
102
+ "double_precision": "float",
103
+ }
104
+
98
105
  # SQL type name buckets
99
106
  _length_types_char: set[MssqlTypeParameters] = {"char", "varchar"} # 1 byte/char
100
107
  _length_types_nchar: set[MssqlTypeParameters] = {"nchar", "nvarchar"} # 2 bytes/char (Unicode)
@@ -106,6 +113,7 @@ class MssqlMap(DialectMap[MssqlTypeParameters]):
106
113
  # Parent parameters
107
114
  py_types = cast(dict[str, PyTypeParameters], _PY_TYPES)
108
115
  sa_types = cast(dict[str, SaTypeFactory], _SA_TYPES)
116
+ _reflected_synonyms = cast(dict[str, str], _REFLECTED_SYNONYMS)
109
117
  dialect = DialectTypes.MSSQL
110
118
  dialect_paramaters = frozenset(get_args(MssqlTypeParameters))
111
119
  _quote_open = "["
@@ -130,6 +130,13 @@ class MysqlMap(DialectMap[MysqlTypeParameters]):
130
130
  "geometrycollection": NullType,
131
131
  }
132
132
 
133
+ # Reflected class name -> dialect type name (NCHAR/NVARCHAR are national
134
+ # charset variants MySQL stores as char/varchar)
135
+ _REFLECTED_SYNONYMS: dict[str, MysqlTypeParameters] = {
136
+ "nchar": "char",
137
+ "nvarchar": "varchar",
138
+ }
139
+
133
140
  # SQL type name buckets (MySQL lengths are in characters)
134
141
  _length_types_char: set[MysqlTypeParameters] = {"char", "varchar"}
135
142
  _length_types_bin: set[MysqlTypeParameters] = {"binary", "varbinary"}
@@ -140,6 +147,7 @@ class MysqlMap(DialectMap[MysqlTypeParameters]):
140
147
  # Parent parameters
141
148
  py_types = cast(dict[str, PyTypeParameters], _PY_TYPES)
142
149
  sa_types = cast(dict[str, SaTypeFactory], _SA_TYPES)
150
+ _reflected_synonyms = cast(dict[str, str], _REFLECTED_SYNONYMS)
143
151
  dialect = DialectTypes.MYSQL
144
152
  dialect_paramaters = frozenset(get_args(MysqlTypeParameters))
145
153
  _quote_open = "`"
@@ -96,6 +96,14 @@ class OracleMap(DialectMap[OracleTypeParameters]):
96
96
  "xmltype": NullType,
97
97
  }
98
98
 
99
+ # Reflected class name -> dialect type name (NVARCHAR2 reflects as the
100
+ # generic NVARCHAR class; REAL and DOUBLE PRECISION are FLOAT aliases)
101
+ _REFLECTED_SYNONYMS: dict[str, OracleTypeParameters] = {
102
+ "nvarchar": "nvarchar2",
103
+ "real": "float",
104
+ "double_precision": "float",
105
+ }
106
+
99
107
  # SQL type name buckets (Oracle lengths reported in characters/bytes as-is)
100
108
  _length_types_char: set[OracleTypeParameters] = {
101
109
  "char", "nchar", "varchar", "varchar2", "nvarchar2",
@@ -112,6 +120,7 @@ class OracleMap(DialectMap[OracleTypeParameters]):
112
120
  # Parent parameters
113
121
  py_types = cast(dict[str, PyTypeParameters], _PY_TYPES)
114
122
  sa_types = cast(dict[str, SaTypeFactory], _SA_TYPES)
123
+ _reflected_synonyms = cast(dict[str, str], _REFLECTED_SYNONYMS)
115
124
  dialect = DialectTypes.ORACLE
116
125
  dialect_paramaters = frozenset(get_args(OracleTypeParameters))
117
126
 
@@ -190,6 +190,14 @@ class PostgresqlMap(DialectMap[PostgresqlTypeParameters]):
190
190
  "array": NullType,
191
191
  }
192
192
 
193
+ # Reflected class name -> dialect type name (catalog types '"char"' and
194
+ # 'name' reflect as the generic sqltypes.String; FLOAT is stored as
195
+ # double precision)
196
+ _REFLECTED_SYNONYMS: dict[str, PostgresqlTypeParameters] = {
197
+ "string": "text",
198
+ "float": "double precision",
199
+ }
200
+
193
201
  # SQL type name buckets (PostgreSQL lengths are in characters)
194
202
  _length_types_char: set[PostgresqlTypeParameters] = {
195
203
  "character", "char", "character varying", "varchar", "bpchar",
@@ -204,6 +212,7 @@ class PostgresqlMap(DialectMap[PostgresqlTypeParameters]):
204
212
  # Parent parameters
205
213
  py_types = cast(dict[str, PyTypeParameters], _PY_TYPES)
206
214
  sa_types = cast(dict[str, SaTypeFactory], _SA_TYPES)
215
+ _reflected_synonyms = cast(dict[str, str], _REFLECTED_SYNONYMS)
207
216
  dialect = DialectTypes.POSTGRESQL
208
217
  dialect_paramaters = frozenset(get_args(PostgresqlTypeParameters))
209
218
 
@@ -82,6 +82,13 @@ class SqliteMap(DialectMap[SqliteTypeParameters]):
82
82
  "blob": sa_sqlite.BLOB,
83
83
  }
84
84
 
85
+ # Reflected class name -> dialect type name (a declared CHAR reflects as
86
+ # the generic CHAR class; TIMESTAMP behaves as datetime)
87
+ _REFLECTED_SYNONYMS: dict[str, SqliteTypeParameters] = {
88
+ "char": "character",
89
+ "timestamp": "datetime",
90
+ }
91
+
85
92
  # SQL type name buckets (SQLite ignores length constraints, but declared
86
93
  # lengths are preserved for rendering and validation)
87
94
  _length_types_char: set[SqliteTypeParameters] = {
@@ -93,6 +100,7 @@ class SqliteMap(DialectMap[SqliteTypeParameters]):
93
100
  # Parent parameters
94
101
  py_types = cast(dict[str, PyTypeParameters], _PY_TYPES)
95
102
  sa_types = cast(dict[str, SaTypeFactory], _SA_TYPES)
103
+ _reflected_synonyms = cast(dict[str, str], _REFLECTED_SYNONYMS)
96
104
  dialect = DialectTypes.SQLITE
97
105
  dialect_paramaters = frozenset(get_args(SqliteTypeParameters))
98
106
 
@@ -2,9 +2,12 @@ from typing import TypeAlias, get_args
2
2
  from enum import StrEnum
3
3
 
4
4
  class SqlServerODBC(StrEnum):
5
- DRIVER_17 = "ODBC Driver 17 for SQL Server"
6
- DRIVER_11 = "ODBC Driver 11 for SQL Server"
7
- SQL_SERVER = "SQL Server"
5
+ DRIVER_18 = "ODBC Driver 18 for SQL Server"
6
+ DRIVER_17 = "ODBC Driver 17 for SQL Server"
7
+ DRIVER_13_1 = "ODBC Driver 13.1 for SQL Server"
8
+ DRIVER_13 = "ODBC Driver 13 for SQL Server"
9
+ DRIVER_11 = "ODBC Driver 11 for SQL Server"
10
+ SQL_SERVER = "SQL Server"
8
11
 
9
12
 
10
13
  class SqlServerNative(StrEnum):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alchemy-kit"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -5,17 +5,20 @@ from typing import cast
5
5
  import pytest
6
6
 
7
7
  from alchemy_kit.connect._info import ConnectionInfo
8
+ from alchemy_kit.connect import info_builder
8
9
  from alchemy_kit.model import build
9
10
  from alchemy_kit.model import _builder
10
11
  from alchemy_kit.model._model.column_model import ColumnModel, ForeignKeyModel
11
12
  from alchemy_kit.model._model.constraint_model import CheckConstraintModel, FilteredUniqueIndexModel
13
+ from alchemy_kit.model import SchemaConfig
12
14
  from alchemy_kit.model._model.db_model import DBModel
13
15
  from alchemy_kit.model._model.object_model import ObjectModel
14
16
  from alchemy_kit.model._model.schema_model import SchemaModel
15
17
  from alchemy_kit.types.dialect_types import DialectTypes
16
18
 
17
19
 
18
- PREVIEW_DIR = Path(__file__).resolve().parent.parent / "secret_model_preview"
20
+ ROOT = Path(__file__).resolve().parent.parent
21
+ PREVIEW_DIR = ROOT / "secret_model_preview"
19
22
 
20
23
  def _column(name: str, column_type: str, *, nullable: bool = False, fk_ref: ForeignKeyModel | None = None) -> ColumnModel:
21
24
  return ColumnModel(
@@ -117,3 +120,9 @@ def test_build_generates_package(monkeypatch: pytest.MonkeyPatch):
117
120
  for path in result_dir.rglob("*.py*"):
118
121
  compile(path.read_text(), str(path), "exec")
119
122
 
123
+ def test_mssql_local_build():
124
+ conn = info_builder.from_env(ROOT/".env-mssql")
125
+ config = SchemaConfig()
126
+ config.include_schema("uploader")
127
+ build(conn, ROOT/"secret_local_mssql", schema_config=config, clear_result_dir=True)
128
+