forked from baskbuddy/zhangshun
6 changed files with 329 additions and 1 deletions
@ -0,0 +1,90 @@ |
|||||
|
package com.mydreamjob.config; |
||||
|
|
||||
|
import java.io.IOException; |
||||
|
import java.io.Reader; |
||||
|
import java.io.StringReader; |
||||
|
import java.nio.charset.Charset; |
||||
|
import java.sql.Connection; |
||||
|
import java.sql.DatabaseMetaData; |
||||
|
import java.sql.SQLException; |
||||
|
import java.util.Arrays; |
||||
|
import java.util.Collection; |
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.Map; |
||||
|
import org.flywaydb.core.api.ResourceProvider; |
||||
|
import org.flywaydb.core.api.configuration.FluentConfiguration; |
||||
|
import org.flywaydb.core.api.resource.LoadableResource; |
||||
|
import org.springframework.boot.autoconfigure.flyway.FlywayConfigurationCustomizer; |
||||
|
import org.springframework.context.annotation.Bean; |
||||
|
import org.springframework.context.annotation.Configuration; |
||||
|
import org.springframework.core.io.ClassPathResource; |
||||
|
import org.springframework.core.io.Resource; |
||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver; |
||||
|
|
||||
|
/** 仅替换 MySQL 5.7 的 V49 资源;既有 SQL、其他数据库及历史 checksum 不改写。 */ |
||||
|
@Configuration(proxyBeanMethods = false) |
||||
|
public class MySql57MigrationConfiguration { |
||||
|
private static final String V49 = "V49__skill_cloud_boolean_type.sql"; |
||||
|
|
||||
|
@Bean |
||||
|
FlywayConfigurationCustomizer mysql57MigrationResources() { |
||||
|
return MySql57MigrationConfiguration::configure; |
||||
|
} |
||||
|
|
||||
|
public static void configure(FluentConfiguration configuration) { |
||||
|
try (Connection connection = configuration.getDataSource().getConnection()) { |
||||
|
DatabaseMetaData metadata = connection.getMetaData(); |
||||
|
if (!"MySQL".equals(metadata.getDatabaseProductName()) |
||||
|
|| metadata.getDatabaseMajorVersion() != 5 |
||||
|
|| metadata.getDatabaseMinorVersion() != 7) { |
||||
|
return; |
||||
|
} |
||||
|
// 不静默忽略调用者的自定义迁移路径或资源提供器。
|
||||
|
if (configuration.getResourceProvider() != null |
||||
|
|| configuration.getLocations().length != 1 |
||||
|
|| !"classpath:db/migration".equals(configuration.getLocations()[0].getDescriptor())) { |
||||
|
throw new IllegalStateException("MySQL 5.7 migration configuration requires the standard migration location"); |
||||
|
} |
||||
|
configuration.resourceProvider(resources(configuration.getEncoding())); |
||||
|
} catch (SQLException | IOException ex) { |
||||
|
// 不把连接 URL、账号、驱动异常原文带进启动日志。
|
||||
|
throw new IllegalStateException("Database migration compatibility initialization failed"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static ResourceProvider resources(Charset encoding) throws IOException { |
||||
|
Map<String, LoadableResource> resources = new LinkedHashMap<>(); |
||||
|
Resource[] originals = new PathMatchingResourcePatternResolver() |
||||
|
.getResources("classpath*:db/migration/*"); |
||||
|
for (Resource original : originals) { |
||||
|
String filename = original.getFilename(); |
||||
|
if (filename == null || !original.isReadable()) continue; |
||||
|
Resource selected = V49.equals(filename) |
||||
|
? new ClassPathResource("db/mysql57/" + V49) : original; |
||||
|
String text = selected.getContentAsString(encoding); |
||||
|
String path = selected.getURL().toExternalForm(); |
||||
|
LoadableResource resource = new LoadableResource() { |
||||
|
@Override public Reader read() { return new StringReader(text); } |
||||
|
@Override public String getAbsolutePath() { return path; } |
||||
|
@Override public String getAbsolutePathOnDisk() { return path; } |
||||
|
@Override public String getFilename() { return filename; } |
||||
|
@Override public String getRelativePath() { return "db/migration/" + filename; } |
||||
|
}; |
||||
|
if (resources.putIfAbsent(filename, resource) != null) { |
||||
|
throw new IllegalStateException("Duplicate database migration resource"); |
||||
|
} |
||||
|
} |
||||
|
if (!resources.containsKey(V49)) throw new IllegalStateException("Required database migration resource missing"); |
||||
|
return new ResourceProvider() { |
||||
|
@Override public LoadableResource getResource(String name) { |
||||
|
return resources.values().stream().filter(resource -> name.equals(resource.getFilename()) |
||||
|
|| name.equals(resource.getRelativePath()) || name.equals(resource.getAbsolutePath())) |
||||
|
.findFirst().orElse(null); |
||||
|
} |
||||
|
@Override public Collection<LoadableResource> getResources(String prefix, String[] suffixes) { |
||||
|
return resources.values().stream().filter(resource -> resource.getFilename().startsWith(prefix) |
||||
|
&& Arrays.stream(suffixes).anyMatch(resource.getFilename()::endsWith)).toList(); |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
-- MySQL 5.7 专用 V49:CHECK 在该版本被忽略,不存在可删除的 CHECK 约束。 |
||||
|
-- BOOLEAN 是 TINYINT(1) 的别名;显式规范化类型,不执行不支持的 DROP CONSTRAINT。 |
||||
|
-- 原 db/migration/V49 保持不变,H2 / MySQL 8.4 继续执行原版本。 |
||||
|
-- 本变体具有独立 checksum,未来数据库大版本升级必须单独审核迁移历史。 |
||||
|
ALTER TABLE occupation_skills |
||||
|
MODIFY COLUMN importance_is_proxy BOOLEAN NOT NULL DEFAULT FALSE; |
||||
@ -0,0 +1,73 @@ |
|||||
|
package com.mydreamjob.config; |
||||
|
|
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.sql.Connection; |
||||
|
import java.sql.DatabaseMetaData; |
||||
|
import javax.sql.DataSource; |
||||
|
import org.flywaydb.core.Flyway; |
||||
|
import org.flywaydb.core.api.configuration.FluentConfiguration; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.core.io.ClassPathResource; |
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
class MySql57MigrationConfigurationTest { |
||||
|
@Test void h2AndMysql8KeepDefaultResources() throws Exception { |
||||
|
for (String product : new String[]{"H2", "MySQL"}) { |
||||
|
FluentConfiguration config = configuration(product, 8, 4); |
||||
|
MySql57MigrationConfiguration.configure(config); |
||||
|
assertThat(config.getResourceProvider()).isNull(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test void mysql57ReplacesOnlyV49AndRetainsOriginalResourceNamesAndContents() throws Exception { |
||||
|
FluentConfiguration config = configuration("MySQL", 5, 7); |
||||
|
MySql57MigrationConfiguration.configure(config); |
||||
|
var provider = config.getResourceProvider(); |
||||
|
assertThat(provider).isNotNull(); |
||||
|
var resources = provider.getResources("V", new String[]{".sql"}); |
||||
|
assertThat(resources).hasSize(53); |
||||
|
for (var resource : resources) { |
||||
|
String path = "V49__skill_cloud_boolean_type.sql".equals(resource.getFilename()) |
||||
|
? "db/mysql57/" : "db/migration/"; |
||||
|
String actual; |
||||
|
try (var reader = new java.io.BufferedReader(resource.read())) { |
||||
|
actual = reader.lines().collect(java.util.stream.Collectors.joining("\n")); |
||||
|
} |
||||
|
String expected = new ClassPathResource(path + resource.getFilename()) |
||||
|
.getContentAsString(StandardCharsets.UTF_8).replace("\r\n", "\n").stripTrailing(); |
||||
|
assertThat(actual.stripTrailing()).as(resource.getFilename()).isEqualTo(expected); |
||||
|
assertThat(provider.getResource(resource.getFilename())).isSameAs(resource); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test void customLocationsAndProvidersFailClosedRatherThanDisappear() throws Exception { |
||||
|
FluentConfiguration config = configuration("MySQL", 5, 7).locations("classpath:other"); |
||||
|
assertThatThrownBy(() -> MySql57MigrationConfiguration.configure(config)) |
||||
|
.isInstanceOf(IllegalStateException.class).hasMessageContaining("standard migration location"); |
||||
|
FluentConfiguration custom = configuration("MySQL", 5, 7) |
||||
|
.resourceProvider(mock(org.flywaydb.core.api.ResourceProvider.class)); |
||||
|
assertThatThrownBy(() -> MySql57MigrationConfiguration.configure(custom)) |
||||
|
.isInstanceOf(IllegalStateException.class); |
||||
|
} |
||||
|
|
||||
|
@Test void connectionFailureHasFixedSafeMessage() throws Exception { |
||||
|
DataSource dataSource = mock(DataSource.class); |
||||
|
when(dataSource.getConnection()).thenThrow(new java.sql.SQLException("private-connection-value")); |
||||
|
assertThatThrownBy(() -> MySql57MigrationConfiguration.configure(Flyway.configure().dataSource(dataSource))) |
||||
|
.hasMessage("Database migration compatibility initialization failed").hasNoCause(); |
||||
|
} |
||||
|
|
||||
|
private FluentConfiguration configuration(String product, int major, int minor) throws Exception { |
||||
|
DataSource dataSource = mock(DataSource.class); |
||||
|
Connection connection = mock(Connection.class); |
||||
|
DatabaseMetaData metadata = mock(DatabaseMetaData.class); |
||||
|
when(dataSource.getConnection()).thenReturn(connection); |
||||
|
when(connection.getMetaData()).thenReturn(metadata); |
||||
|
when(metadata.getDatabaseProductName()).thenReturn(product); |
||||
|
when(metadata.getDatabaseMajorVersion()).thenReturn(major); |
||||
|
when(metadata.getDatabaseMinorVersion()).thenReturn(minor); |
||||
|
return Flyway.configure().dataSource(dataSource); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
package com.mydreamjob.integration; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import java.nio.file.Files; |
||||
|
import java.nio.file.Path; |
||||
|
import java.util.HashSet; |
||||
|
import java.util.Set; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.context.DynamicPropertyRegistry; |
||||
|
import org.springframework.test.context.DynamicPropertySource; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import org.testcontainers.junit.jupiter.Container; |
||||
|
import org.testcontainers.junit.jupiter.Testcontainers; |
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** 服务器真实版本的全迁移、Hibernate validate 和接口集合门禁,不能静默跳过。 */ |
||||
|
@Testcontainers(disabledWithoutDocker = false) |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("mysql-it") |
||||
|
class LxqModulesMySql57IntegrationTest { |
||||
|
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:5.7.36") |
||||
|
.withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci") |
||||
|
.withDatabaseName("module57_test") |
||||
|
.withUsername("module_test").withPassword("module-test-only"); |
||||
|
@DynamicPropertySource static void properties(DynamicPropertyRegistry r) { |
||||
|
r.add("spring.datasource.url", MYSQL::getJdbcUrl); |
||||
|
r.add("spring.datasource.username", MYSQL::getUsername); |
||||
|
r.add("spring.datasource.password", MYSQL::getPassword); |
||||
|
} |
||||
|
@MockBean RedisTemplate<String, Object> redis; |
||||
|
@Autowired JdbcTemplate jdbc; |
||||
|
@Autowired MockMvc mvc; |
||||
|
@Autowired ObjectMapper mapper; |
||||
|
|
||||
|
@Test void fullMigrationAndSeedsValidateOnActualServerVersion() { |
||||
|
assertThat(jdbc.queryForObject("select version()", String.class)).startsWith("5.7.36"); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from flyway_schema_history where success=0", Long.class)).isZero(); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from flyway_schema_history where version in ('47','48','49','50','51','52','53') and success=1", Long.class)).isEqualTo(7); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from salary_city_policy", Long.class)).isEqualTo(359); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from occupation_skills where importance_is_proxy not in (0,1)", Long.class)).isZero(); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from onboarding_checklist_items", Long.class)).isEqualTo(12); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history", Long.class)).isZero(); |
||||
|
} |
||||
|
|
||||
|
@Test void runtimeAndStaticOperationsRemainIdentical() throws Exception { |
||||
|
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) |
||||
|
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); |
||||
|
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); |
||||
|
assertThat(operations(runtime)).hasSize(239).isEqualTo(operations(contract)); |
||||
|
assertThat(runtime.path("paths").size()).isEqualTo(196); |
||||
|
} |
||||
|
|
||||
|
private Set<String> operations(JsonNode document) { |
||||
|
Set<String> result = new HashSet<>(); |
||||
|
document.path("paths").fields().forEachRemaining(path -> |
||||
|
path.getValue().fieldNames().forEachRemaining(method -> { |
||||
|
if (Set.of("get", "post", "put", "patch", "delete", "head", "options", "trace").contains(method)) |
||||
|
result.add(method + " " + path.getKey()); |
||||
|
})); |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,77 @@ |
|||||
|
package com.mydreamjob.integration; |
||||
|
|
||||
|
import com.mydreamjob.config.MySql57MigrationConfiguration; |
||||
|
import java.sql.Connection; |
||||
|
import java.sql.DriverManager; |
||||
|
import java.sql.ResultSet; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import org.flywaydb.core.Flyway; |
||||
|
import org.flywaydb.core.api.configuration.FluentConfiguration; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import org.testcontainers.junit.jupiter.Container; |
||||
|
import org.testcontainers.junit.jupiter.Testcontainers; |
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
|
||||
|
/** 仅在一次性容器中演练精确失败记录恢复;生产启动绝不自动 repair。 */ |
||||
|
@Testcontainers(disabledWithoutDocker = false) |
||||
|
class MySql57FailedMigrationRecoveryTest { |
||||
|
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:5.7.36") |
||||
|
.withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci") |
||||
|
.withDatabaseName("recovery_test").withUsername("recovery_test").withPassword("recovery-test-only"); |
||||
|
|
||||
|
@Test void exactFailedV49RecoveryPreservesSuccessfulHistoryAndEverySkillRow() throws Exception { |
||||
|
original().target("48").load().migrate(); |
||||
|
List<String> history = snapshot("select installed_rank,version,description,type,script,checksum,installed_by,installed_on,execution_time,success from flyway_schema_history order by installed_rank"); |
||||
|
List<String> skills = snapshot("select * from occupation_skills order by id"); |
||||
|
assertThatThrownBy(() -> original().load().migrate()).hasMessageContaining("V49__skill_cloud_boolean_type.sql"); |
||||
|
assertThat(snapshot("select version,success from flyway_schema_history where success=0")) |
||||
|
.containsExactly("49|0|"); |
||||
|
assertThat(snapshot("select * from occupation_skills order by id")).isEqualTo(skills); |
||||
|
|
||||
|
FluentConfiguration compatible = original(); |
||||
|
MySql57MigrationConfiguration.configure(compatible); |
||||
|
// 新应用不绕过验证,更不会顺手删除失败历史。
|
||||
|
assertThatThrownBy(() -> compatible.load().migrate()).hasMessageContaining("failed migration"); |
||||
|
assertThat(snapshot("select version,success from flyway_schema_history where success=0")) |
||||
|
.containsExactly("49|0|"); |
||||
|
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); |
||||
|
var statement = connection.prepareStatement("delete from flyway_schema_history where version=? and success=0 and script=?")) { |
||||
|
statement.setString(1, "49"); |
||||
|
statement.setString(2, "V49__skill_cloud_boolean_type.sql"); |
||||
|
assertThat(statement.executeUpdate()).isEqualTo(1); |
||||
|
} |
||||
|
compatible.load().migrate(); |
||||
|
compatible.load().validate(); |
||||
|
assertThat(snapshot("select installed_rank,version,description,type,script,checksum,installed_by,installed_on,execution_time,success from flyway_schema_history where cast(version as unsigned)<=48 order by installed_rank")) |
||||
|
.isEqualTo(history); |
||||
|
assertThat(snapshot("select * from occupation_skills order by id")).isEqualTo(skills); |
||||
|
assertThat(snapshot("select count(*) from flyway_schema_history where success=0")).containsExactly("0|"); |
||||
|
assertThat(snapshot("select count(*) from salary_city_policy")).containsExactly("359|"); |
||||
|
assertThat(snapshot("select count(*) from flyway_schema_history where version='49' and success=1")).containsExactly("1|"); |
||||
|
List<String> completed = snapshot("select * from flyway_schema_history order by installed_rank"); |
||||
|
assertThat(compatible.load().migrate().migrationsExecuted).isZero(); |
||||
|
assertThat(snapshot("select * from flyway_schema_history order by installed_rank")).isEqualTo(completed); |
||||
|
} |
||||
|
|
||||
|
private FluentConfiguration original() { |
||||
|
return Flyway.configure().dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); |
||||
|
} |
||||
|
|
||||
|
private List<String> snapshot(String sql) throws Exception { |
||||
|
List<String> rows = new ArrayList<>(); |
||||
|
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); |
||||
|
var statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { |
||||
|
while (result.next()) { |
||||
|
StringBuilder row = new StringBuilder(); |
||||
|
for (int column = 1; column <= result.getMetaData().getColumnCount(); column++) { |
||||
|
row.append(result.getString(column)).append('|'); |
||||
|
} |
||||
|
rows.add(row.toString()); |
||||
|
} |
||||
|
} |
||||
|
return rows; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue