Skip to content

Commit 9b708d3

Browse files
authored
feat: auto-detect utf8mb4 charset when creating MySQL database via svc-mysql (#2904)
1 parent e6cb7a8 commit 9b708d3

2 files changed

Lines changed: 172 additions & 3 deletions

File tree

svc-mysql/svc_mysql/vendor/provider.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import json
1919
import logging
20+
import string
2021
from dataclasses import dataclass, field
2122
from typing import Dict, List
2223

@@ -62,7 +63,7 @@ def __post_init__(self):
6263
self.db_operator_template.setdefault(
6364
"CREATE_DATABASE",
6465
# default create database sql;
65-
"CREATE DATABASE IF NOT EXISTS `{engine.name}` DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;",
66+
"CREATE DATABASE IF NOT EXISTS `{engine.name}` DEFAULT CHARACTER SET {charset} COLLATE {collation};",
6667
)
6768
self.db_operator_template.setdefault(
6869
"DROP_DATABASE",
@@ -134,10 +135,25 @@ def create(self, params: Dict) -> InstanceData:
134135
name=db_name,
135136
ssl_options=mgr.get_django_ssl_options(),
136137
)
137-
create_db_sql = self.db_operator_template["CREATE_DATABASE"].format(engine=engine)
138+
139+
template = self.db_operator_template["CREATE_DATABASE"]
140+
141+
# 如果模板中需要动态 charset / collation, 将探测 MySQL 实例支持的字符集
142+
# utf8mb4 优先, 如果不支持将回退到 utf8mb3
143+
need_charset = self._template_needs_charset(template)
144+
if need_charset:
145+
charset, collation = self._detect_charset_capability(authorizer)
146+
create_db_sql = template.format(engine=engine, charset=charset, collation=collation)
147+
else:
148+
create_db_sql = template.format(engine=engine)
138149
engine.execute(create_db_sql)
139150

140-
logger.info("create mysql addons instance %s success", db_name)
151+
if need_charset:
152+
logger.info(
153+
"create mysql addons instance %s success, charset=%s, collation=%s", db_name, charset, collation
154+
)
155+
else:
156+
logger.info("create mysql addons instance %s success", db_name)
141157

142158
credentials = {
143159
"host": server.host,
@@ -193,3 +209,26 @@ def delete(self, instance_data: InstanceData):
193209

194210
def patch(self, instance_data: InstanceData, params: Dict) -> InstanceData:
195211
raise NotImplementedError
212+
213+
def _template_needs_charset(self, template: str) -> bool:
214+
"""判断模板是否包含 {charset} 或 {collation} 占位符"""
215+
field_names = {
216+
field_name for _, field_name, _, _ in string.Formatter().parse(template) if field_name is not None
217+
}
218+
219+
return "charset" in field_names or "collation" in field_names
220+
221+
def _detect_charset_capability(self, authorizer: MySQLAuthorizer) -> tuple[str, str]:
222+
"""探测 MySQL 字符集能力"""
223+
try:
224+
rows = authorizer.execute("SHOW CHARACTER SET LIKE 'utf8mb4'")
225+
charset, collation = ("utf8mb4", "utf8mb4_general_ci") if rows else ("utf8", "utf8_general_ci")
226+
except Exception: # noqa: BLE001
227+
logger.warning(
228+
"Failed to detect charset for MySQL instance %s:%s, fallback to utf8.",
229+
authorizer.host,
230+
authorizer.port,
231+
)
232+
charset, collation = ("utf8", "utf8_general_ci")
233+
234+
return (charset, collation)

svc-mysql/tests/test_provider.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# -*- coding: utf-8 -*-
2+
# TencentBlueKing is pleased to support the open source community by making
3+
# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
4+
# Copyright (C) Tencent. All rights reserved.
5+
# Licensed under the MIT License (the "License"); you may not use this file except
6+
# in compliance with the License. You may obtain a copy of the License at
7+
#
8+
# http://opensource.org/licenses/MIT
9+
#
10+
# Unless required by applicable law or agreed to in writing, software distributed under
11+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
12+
# either express or implied. See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# We undertake not to change the open source license (MIT license) applicable
16+
# to the current version of the project delivered to anyone in the future.
17+
18+
from unittest.mock import MagicMock, patch
19+
20+
import pytest
21+
from svc_mysql.vendor.provider import Provider
22+
23+
24+
@pytest.fixture
25+
def mysql_mocks():
26+
"""Mock 外部依赖,返回 (authorizer_mock, engine_mock)"""
27+
with (
28+
patch("svc_mysql.vendor.provider.gen_unique_id", return_value="test-db"),
29+
patch("svc_mysql.vendor.provider.generate_mysql_password", return_value="P@ssW0rd!"),
30+
patch("svc_mysql.vendor.provider.MySQLAuthorizer") as mock_auth_cls,
31+
patch("svc_mysql.vendor.provider.MySQLEngine") as mock_eng_cls,
32+
):
33+
mock_authorizer = MagicMock()
34+
mock_engine = MagicMock()
35+
mock_auth_cls.return_value = mock_authorizer
36+
mock_eng_cls.return_value = mock_engine
37+
yield mock_authorizer, mock_engine
38+
39+
40+
def make_provider(**kwargs):
41+
defaults = {"host": "10.0.0.1", "port": 3306, "user": "root", "password": "pwd"}
42+
defaults.update(kwargs)
43+
return Provider(**defaults)
44+
45+
46+
class TestTemplateNeedsCharset:
47+
"""验证 Formatter.parse() 对占位符的精确匹配"""
48+
49+
@pytest.mark.parametrize(
50+
("template", "expected"),
51+
[
52+
# {charset} / {collation} 占位符 -> True
53+
(
54+
"CREATE DATABASE IF NOT EXISTS `{engine.name}` DEFAULT CHARACTER SET {charset} COLLATE {collation};",
55+
True,
56+
),
57+
("CREATE DATABASE IF NOT EXISTS `{engine.name}` COLLATE {collation};", True),
58+
("CREATE DATABASE IF NOT EXISTS `{engine.name}` CHARACTER SET {charset};", True),
59+
# 不含占位符 -> False
60+
("CREATE DATABASE IF NOT EXISTS `{engine.name}`;", False),
61+
("DROP DATABASE IF EXISTS `{engine.name}`", False),
62+
# 字面值 charset=utf8 不是 {charset} 占位符 -> False
63+
("CREATE DATABASE IF NOT EXISTS `{engine.name}` DEFAULT charset=utf8 COLLATE utf8_general_ci;", False),
64+
],
65+
)
66+
def test(self, template, expected):
67+
assert make_provider()._template_needs_charset(template) is expected
68+
69+
70+
class TestCreateCharsetDetection:
71+
"""create() 集成测试: 验证最终建库 SQL"""
72+
73+
def test_utf8mb4_sql_generated(self, mysql_mocks):
74+
"""探测到 utf8mb4 -> 建库 SQL 含 utf8mb4 + utf8mb4_general_ci"""
75+
76+
mock_authorizer, mock_engine = mysql_mocks
77+
mock_authorizer.execute.return_value = [("utf8mb4", "utf8mb4_general_ci")]
78+
79+
make_provider().create({"engine_app_name": "test-app"})
80+
81+
create_sql = mock_engine.execute.call_args_list[0][0][0]
82+
assert "utf8mb4" in create_sql
83+
assert "utf8mb4_general_ci" in create_sql
84+
85+
def test_fallback_to_utf8(self, mysql_mocks):
86+
"""utf8mb4 不可用 -> 建库 SQL 回退 utf8"""
87+
88+
mock_authorizer, mock_engine = mysql_mocks
89+
mock_authorizer.execute.return_value = []
90+
91+
make_provider().create({"engine_app_name": "test-app"})
92+
93+
create_sql = mock_engine.execute.call_args_list[0][0][0]
94+
assert "utf8" in create_sql
95+
assert "utf8mb4" not in create_sql
96+
97+
def test_exception_still_creates_db(self, mysql_mocks):
98+
"""探测异常 -> 降级 utf8 且建库成功"""
99+
100+
mock_authorizer, mock_engine = mysql_mocks
101+
mock_authorizer.execute.side_effect = Exception("Connection timed out")
102+
103+
result = make_provider().create({"engine_app_name": "test-app"})
104+
105+
assert result.credentials["name"] == "test-db"
106+
create_sql = mock_engine.execute.call_args_list[0][0][0]
107+
assert "utf8" in create_sql
108+
109+
def test_external_template_skips_detection(self, mysql_mocks):
110+
"""外部模板不含 {charset}/{collation} 占位符 -> 不执行探测"""
111+
mock_authorizer, mock_engine = mysql_mocks
112+
113+
provider = make_provider()
114+
provider.db_operator_template["CREATE_DATABASE"] = "CREATE DATABASE IF NOT EXISTS `{engine.name}`;"
115+
provider.create({"engine_app_name": "test-app"})
116+
117+
mock_authorizer.execute.assert_not_called()
118+
119+
def test_literal_charset_template_skips_detection(self, mysql_mocks):
120+
"""自定义模板 charset=utf8 是字面值而非占位符 -> 不探测"""
121+
122+
mock_authorizer, mock_engine = mysql_mocks
123+
124+
provider = make_provider()
125+
provider.db_operator_template["CREATE_DATABASE"] = (
126+
"CREATE DATABASE IF NOT EXISTS `{engine.name}` DEFAULT charset=utf8 COLLATE utf8_general_ci;"
127+
)
128+
provider.create({"engine_app_name": "test-app"})
129+
130+
mock_authorizer.execute.assert_not_called()

0 commit comments

Comments
 (0)