diff --git a/mydreamjob-server/src/main/resources/db/migration/V35__skill_cloud_taxonomy.sql b/mydreamjob-server/src/main/resources/db/migration/V35__skill_cloud_taxonomy.sql new file mode 100644 index 0000000..7c0e3fd --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V35__skill_cloud_taxonomy.sql @@ -0,0 +1,111 @@ +-- Flyway V35: 行业技能词云知识库表结构 +-- 来源与许可:ESCO v1.2.1 提供职位/技能/分类关系,O*NET 31.0 提供 Importance。 +-- 本迁移只建立结构,代表性种子数据由 V36 导入。 + +CREATE TABLE skill_source_versions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + source_name VARCHAR(100) NOT NULL COMMENT '来源名称:ESCO/O*NET', + source_version VARCHAR(150) NOT NULL COMMENT '固定来源版本', + license VARCHAR(255) NOT NULL COMMENT '来源许可证或复用条件', + attribution_url VARCHAR(500) NOT NULL COMMENT '官方来源或归属地址', + imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_source_versions_source UNIQUE (source_name, source_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词库来源版本'; + +CREATE TABLE skill_industries ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(80) NOT NULL COMMENT '项目行业稳定编码', + name_zh VARCHAR(120) NOT NULL COMMENT '中文行业名称', + source_code VARCHAR(120) COMMENT '上游或项目映射来源编码', + sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_industries_code UNIQUE (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词云行业'; + +CREATE TABLE skill_occupations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + industry_id BIGINT NOT NULL COMMENT '所属行业', + code VARCHAR(100) NOT NULL COMMENT '职位稳定编码', + name_zh VARCHAR(150) NOT NULL COMMENT '中文职位名称', + name_en VARCHAR(180) COMMENT '英文职位名称', + source_code VARCHAR(150) NOT NULL COMMENT 'ESCO 职位来源 ID', + source_version_id BIGINT NOT NULL COMMENT '来源版本', + sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_occupations_industry_code UNIQUE (industry_id, code), + CONSTRAINT fk_skill_occupations_industry FOREIGN KEY (industry_id) REFERENCES skill_industries (id), + CONSTRAINT fk_skill_occupations_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词云职位'; + +CREATE TABLE skill_categories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + occupation_id BIGINT NOT NULL COMMENT '所属职位', + code VARCHAR(100) NOT NULL COMMENT '技能分类稳定编码', + name_zh VARCHAR(120) NOT NULL COMMENT '中文分类名称', + suggestion_template_code VARCHAR(30) NOT NULL COMMENT '学习建议模板编码', + sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_categories_occupation_code UNIQUE (occupation_id, code), + CONSTRAINT ck_skill_categories_suggestion_template CHECK ( + suggestion_template_code IN ('PROGRAMMING_LANGUAGE', 'FRAMEWORK', 'TOOL', 'GENERAL') + ), + CONSTRAINT fk_skill_categories_occupation FOREIGN KEY (occupation_id) REFERENCES skill_occupations (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位技能分类'; + +CREATE TABLE skills ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(120) NOT NULL COMMENT '项目技能稳定编码', + name_zh VARCHAR(180) NOT NULL COMMENT '中文标准技能名称', + name_en VARCHAR(180) COMMENT '英文标准技能名称', + source_type VARCHAR(20) NOT NULL COMMENT '来源类型', + source_code VARCHAR(180) NOT NULL COMMENT 'ESCO/O*NET 或项目来源 ID', + source_version_id BIGINT NOT NULL COMMENT '来源版本', + sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skills_code UNIQUE (code), + CONSTRAINT uk_skills_source UNIQUE (source_type, source_code), + CONSTRAINT ck_skills_source_type CHECK (source_type IN ('ESCO', 'O*NET', 'PROJECT')), + CONSTRAINT fk_skills_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标准技能'; + +CREATE TABLE skill_aliases ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + skill_id BIGINT NOT NULL COMMENT '标准技能', + alias VARCHAR(180) NOT NULL COMMENT '技能别名', + normalized_alias VARCHAR(180) NOT NULL COMMENT '归一化技能别名', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_aliases_normalized_alias UNIQUE (normalized_alias), + CONSTRAINT uk_skill_aliases_skill_alias UNIQUE (skill_id, alias), + CONSTRAINT fk_skill_aliases_skill FOREIGN KEY (skill_id) REFERENCES skills (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标准技能别名'; + +CREATE TABLE occupation_skills ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + occupation_id BIGINT NOT NULL COMMENT '职位', + skill_id BIGINT NOT NULL COMMENT '标准技能', + category_id BIGINT NOT NULL COMMENT '职位内技能分类', + importance DECIMAL(4,2) COMMENT 'O*NET Importance,0-5,允许为空', + relationship_type VARCHAR(20) NOT NULL COMMENT '职位技能关系类型', + source_version_id BIGINT NOT NULL COMMENT 'Importance 来源版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_occupation_skills_occupation_skill UNIQUE (occupation_id, skill_id), + CONSTRAINT ck_occupation_skills_importance CHECK ( + importance IS NULL OR (importance >= 0.00 AND importance <= 5.00) + ), + CONSTRAINT ck_occupation_skills_relationship_type CHECK ( + relationship_type IN ('ESSENTIAL', 'OPTIONAL', 'PROJECT') + ), + CONSTRAINT fk_occupation_skills_occupation FOREIGN KEY (occupation_id) REFERENCES skill_occupations (id), + CONSTRAINT fk_occupation_skills_skill FOREIGN KEY (skill_id) REFERENCES skills (id), + CONSTRAINT fk_occupation_skills_category FOREIGN KEY (category_id) REFERENCES skill_categories (id), + CONSTRAINT fk_occupation_skills_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id), + KEY idx_occupation_skills_occupation_category (occupation_id, category_id), + KEY idx_occupation_skills_occupation_importance (occupation_id, importance) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位技能关系与热度'; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java new file mode 100644 index 0000000..1e284e9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java @@ -0,0 +1,334 @@ +package com.mydreamjob.skill; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_taxonomy;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class SkillTaxonomyMigrationTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DataSource dataSource; + + @MockBean + private RedisTemplate redisTemplate; + + private Long sourceVersionId; + private Long industryId; + private Long occupationId; + private Long categoryId; + private Long skillId; + private Long secondSkillId; + private Long occupationSkillId; + + @Test + void migrationCreatesTaxonomyTablesColumnsConstraintsAndIndexes() throws SQLException { + assertTableAndColumns("skill_source_versions", + "id", "source_name", "source_version", "license", "attribution_url", + "imported_at", "created_at", "updated_at"); + assertTableAndColumns("skill_industries", + "id", "code", "name_zh", "source_code", "sort_order", "created_at", "updated_at"); + assertTableAndColumns("skill_occupations", + "id", "industry_id", "code", "name_zh", "name_en", "source_code", + "source_version_id", "sort_order", "created_at", "updated_at"); + assertTableAndColumns("skill_categories", + "id", "occupation_id", "code", "name_zh", "suggestion_template_code", + "sort_order", "created_at", "updated_at"); + assertTableAndColumns("skills", + "id", "code", "name_zh", "name_en", "source_type", "source_code", + "source_version_id", "sort_order", "created_at", "updated_at"); + assertTableAndColumns("skill_aliases", + "id", "skill_id", "alias", "normalized_alias", "created_at"); + assertTableAndColumns("occupation_skills", + "id", "occupation_id", "skill_id", "category_id", "importance", + "relationship_type", "source_version_id", "created_at", "updated_at"); + + assertUniqueIndex("skill_source_versions", "source_name", "source_version"); + assertUniqueIndex("skill_industries", "code"); + assertUniqueIndex("skill_occupations", "industry_id", "code"); + assertUniqueIndex("skill_categories", "occupation_id", "code"); + assertUniqueIndex("skills", "code"); + assertUniqueIndex("skills", "source_type", "source_code"); + assertUniqueIndex("skill_aliases", "normalized_alias"); + assertUniqueIndex("skill_aliases", "skill_id", "alias"); + assertUniqueIndex("occupation_skills", "occupation_id", "skill_id"); + + assertForeignKey("skill_occupations", "industry_id", "skill_industries", "id"); + assertForeignKey("skill_occupations", "source_version_id", "skill_source_versions", "id"); + assertForeignKey("skill_categories", "occupation_id", "skill_occupations", "id"); + assertForeignKey("skills", "source_version_id", "skill_source_versions", "id"); + assertForeignKey("skill_aliases", "skill_id", "skills", "id"); + assertForeignKey("occupation_skills", "occupation_id", "skill_occupations", "id"); + assertForeignKey("occupation_skills", "skill_id", "skills", "id"); + assertForeignKey("occupation_skills", "category_id", "skill_categories", "id"); + assertForeignKey("occupation_skills", "source_version_id", "skill_source_versions", "id"); + + assertIndex("occupation_skills", "occupation_id", "category_id"); + assertIndex("occupation_skills", "occupation_id", "importance"); + } + + @Test + void minimumTaxonomyChainIsUsableAndCoversCoreConstraints() { + sourceVersionId = insertAndReturnId( + "INSERT INTO skill_source_versions " + + "(source_name, source_version, license, attribution_url, imported_at) " + + "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)", + "ESCO", "test-1.2.1", "Commission Decision 2011/833/EU", + "https://esco.ec.europa.eu/en/use-esco/download"); + industryId = insertAndReturnId( + "INSERT INTO skill_industries (code, name_zh, source_code, sort_order) " + + "VALUES (?, ?, ?, ?)", + "TEST_INTERNET", "互联网", "PROJECT-INTERNET", 1); + occupationId = insertAndReturnId( + "INSERT INTO skill_occupations " + + "(industry_id, code, name_zh, name_en, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + industryId, "TEST_FRONTEND", "前端开发工程师", "Frontend Developer", + "ESCO-2513.2", sourceVersionId, 1); + categoryId = insertAndReturnId( + "INSERT INTO skill_categories " + + "(occupation_id, code, name_zh, suggestion_template_code, sort_order) " + + "VALUES (?, ?, ?, ?, ?)", + occupationId, "TEST_PROGRAMMING_LANGUAGE", "编程语言", + "PROGRAMMING_LANGUAGE", 1); + skillId = insertAndReturnId( + "INSERT INTO skills " + + "(code, name_zh, name_en, source_type, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + "TEST_SKILL_JAVA", "Java", "Java", "ESCO", "ESCO-SKILL-JAVA", + sourceVersionId, 1); + insertAndReturnId( + "INSERT INTO skill_aliases (skill_id, alias, normalized_alias) VALUES (?, ?, ?)", + skillId, "Java SE", "java se"); + occupationSkillId = insertAndReturnId( + "INSERT INTO occupation_skills " + + "(occupation_id, skill_id, category_id, importance, relationship_type, source_version_id) " + + "VALUES (?, ?, ?, ?, ?, ?)", + occupationId, skillId, categoryId, null, "ESSENTIAL", sourceVersionId); + + Map joined = jdbcTemplate.queryForMap( + "SELECT s.name_zh AS skill_name, c.name_zh AS category_name, os.importance " + + "FROM occupation_skills os " + + "JOIN skills s ON s.id = os.skill_id " + + "JOIN skill_categories c ON c.id = os.category_id " + + "WHERE os.id = ?", occupationSkillId); + assertEquals("Java", joined.get("skill_name")); + assertEquals("编程语言", joined.get("category_name")); + assertNull(joined.get("importance")); + + jdbcTemplate.update("UPDATE occupation_skills SET importance = ? WHERE id = ?", + new BigDecimal("4.25"), occupationSkillId); + assertEquals(new BigDecimal("4.25"), jdbcTemplate.queryForObject( + "SELECT importance FROM occupation_skills WHERE id = ?", + BigDecimal.class, occupationSkillId)); + + assertConstraintFailure( + "INSERT INTO skill_source_versions " + + "(source_name, source_version, license, attribution_url) VALUES (?, ?, ?, ?)", + "ESCO", "test-1.2.1", "duplicate", "https://example.invalid"); + assertConstraintFailure( + "INSERT INTO skill_categories " + + "(occupation_id, code, name_zh, suggestion_template_code, sort_order) " + + "VALUES (?, ?, ?, ?, ?)", + occupationId, "TEST_INVALID_CATEGORY", "非法分类", "INVALID_TEMPLATE", 2); + assertConstraintFailure( + "INSERT INTO skills " + + "(code, name_zh, source_type, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?)", + "TEST_INVALID_SKILL", "非法技能", "INVALID_SOURCE", "INVALID-SOURCE-1", + sourceVersionId, 2); + secondSkillId = insertAndReturnId( + "INSERT INTO skills " + + "(code, name_zh, source_type, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?)", + "TEST_SKILL_SPRING", "Spring Boot", "ESCO", "ESCO-SKILL-SPRING", + sourceVersionId, 2); + assertConstraintFailure( + "INSERT INTO skills " + + "(code, name_zh, source_type, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?)", + "TEST_SKILL_NULL_NAME", null, "PROJECT", "PROJECT-NULL-NAME", + sourceVersionId, 3); + assertConstraintFailure( + "INSERT INTO occupation_skills " + + "(occupation_id, skill_id, category_id, relationship_type, source_version_id) " + + "VALUES (?, ?, ?, ?, ?)", + occupationId, secondSkillId, categoryId, "INVALID_RELATIONSHIP", sourceVersionId); + assertConstraintFailure( + "INSERT INTO skill_aliases (skill_id, alias, normalized_alias) VALUES (?, ?, ?)", + secondSkillId, "Java SE alternate", "java se"); + + assertTrue(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM skill_aliases WHERE skill_id = ? AND normalized_alias = ?", + Integer.class, skillId, "java se") == 1); + } + + @AfterEach + void cleanUp() { + if (occupationSkillId != null) { + jdbcTemplate.update("DELETE FROM occupation_skills WHERE id = ?", occupationSkillId); + } + if (secondSkillId != null) { + jdbcTemplate.update("DELETE FROM skill_aliases WHERE skill_id = ?", secondSkillId); + jdbcTemplate.update("DELETE FROM skills WHERE id = ?", secondSkillId); + } + if (skillId != null) { + jdbcTemplate.update("DELETE FROM skill_aliases WHERE skill_id = ?", skillId); + jdbcTemplate.update("DELETE FROM skills WHERE id = ?", skillId); + } + if (categoryId != null) { + jdbcTemplate.update("DELETE FROM skill_categories WHERE id = ?", categoryId); + } + if (occupationId != null) { + jdbcTemplate.update("DELETE FROM skill_occupations WHERE id = ?", occupationId); + } + if (industryId != null) { + jdbcTemplate.update("DELETE FROM skill_industries WHERE id = ?", industryId); + } + if (sourceVersionId != null) { + jdbcTemplate.update("DELETE FROM skill_source_versions WHERE id = ?", sourceVersionId); + } + } + + private void assertTableAndColumns(String table, String... columns) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + assertTrue(actualTable != null, "Missing table: " + table); + + List actualColumns = new ArrayList<>(); + try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) { + while (resultSet.next()) { + actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + for (String column : columns) { + assertTrue(actualColumns.contains(column.toLowerCase()), + "Missing column " + table + "." + column + "; actual=" + actualColumns); + } + } + } + + private void assertUniqueIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, true, columns), + "Missing unique index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private void assertIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, false, columns), + "Missing index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private boolean hasIndex(String table, boolean unique, String... expectedColumns) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + return false; + } + Map> indexes = new LinkedHashMap<>(); + try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) { + while (resultSet.next()) { + String indexName = resultSet.getString("INDEX_NAME"); + if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) { + continue; + } + indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>()) + .put(resultSet.getShort("ORDINAL_POSITION"), + resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + List expected = new ArrayList<>(); + for (String column : expectedColumns) { + expected.add(column.toLowerCase()); + } + return indexes.values().stream() + .map(TreeMap::values) + .map(values -> new ArrayList<>(values)) + .anyMatch(expected::equals); + } + } + + private void assertForeignKey(String childTable, String childColumn, + String parentTable, String parentColumn) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualChildTable = findTable(metadata, childTable); + String actualParentTable = findTable(metadata, parentTable); + if (actualChildTable == null || actualParentTable == null) { + throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn); + } + try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) { + while (resultSet.next()) { + if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME")) + && childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME")) + && parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) { + return; + } + } + } + } + throw new AssertionError("Missing foreign key " + childTable + "." + childColumn + + " -> " + parentTable + "." + parentColumn); + } + + private String findTable(DatabaseMetaData metadata, String expected) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + String actual = resultSet.getString("TABLE_NAME"); + if (expected.equalsIgnoreCase(actual)) { + return actual; + } + } + } + return null; + } + + private Long insertAndReturnId(String sql, Object... args) { + jdbcTemplate.update(sql, args); + String table = extractTable(sql); + return jdbcTemplate.queryForObject("SELECT MAX(id) FROM " + table, Long.class); + } + + private String extractTable(String sql) { + String upper = sql.toUpperCase(); + int fromIndex = upper.indexOf(" INTO "); + int openIndex = sql.indexOf('(', fromIndex); + return sql.substring(fromIndex + " INTO ".length(), openIndex).trim(); + } + + private void assertConstraintFailure(String sql, Object... args) { + assertThrows(DataAccessException.class, () -> jdbcTemplate.update(sql, args)); + } +} +