diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java new file mode 100644 index 0000000..f4b6103 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java @@ -0,0 +1,152 @@ +package com.mydreamjob.skill; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.skill.domain.SkillAlias; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import com.mydreamjob.skill.repository.SkillAliasRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Service +@Transactional(readOnly = true) +public class SkillMatchService { + + private static final int TOP_LIMIT = 10; + + private final SkillCloudQueryService skillCloudQueryService; + private final StudentProfileRepository studentProfileRepository; + private final SkillAliasRepository skillAliasRepository; + private final SkillNameNormalizer skillNameNormalizer; + private final ObjectMapper objectMapper; + + public SkillMatchService(SkillCloudQueryService skillCloudQueryService, + StudentProfileRepository studentProfileRepository, + SkillAliasRepository skillAliasRepository, + SkillNameNormalizer skillNameNormalizer, + ObjectMapper objectMapper) { + this.skillCloudQueryService = skillCloudQueryService; + this.studentProfileRepository = studentProfileRepository; + this.skillAliasRepository = skillAliasRepository; + this.skillNameNormalizer = skillNameNormalizer; + this.objectMapper = objectMapper; + } + + public SkillMatchResponse match(Long studentUserId, + Long industryId, + Long occupationId, + Long categoryId) { + SkillCloudResponse cloud = skillCloudQueryService.query(industryId, occupationId, categoryId); + int actualTopSize = Math.min(TOP_LIMIT, cloud.items().size()); + List topSkills = cloud.items().subList(0, actualTopSize); + Set studentSkills = loadStudentSkills(studentUserId); + Map> aliasesBySkillId = loadAliases(topSkills); + + List matchedSkills = new ArrayList<>(); + List missingSkills = new ArrayList<>(); + for (SkillCloudItem skill : topSkills) { + if (matches(skill, studentSkills, aliasesBySkillId.getOrDefault(skill.skillId(), List.of()))) { + matchedSkills.add(skill); + } else { + missingSkills.add(skill); + } + } + + int matchScore = actualTopSize == 0 + ? 0 + : (int) Math.round(matchedSkills.size() * 100.0 / actualTopSize); + return new SkillMatchResponse( + matchScore, + matchedSkills.size(), + actualTopSize, + matchedSkills, + missingSkills + ); + } + + private Set loadStudentSkills(Long studentUserId) { + if (studentUserId == null) { + return Set.of(); + } + + StudentProfile profile = studentProfileRepository.findByUserId(studentUserId).orElse(null); + if (profile == null || profile.getSkillsJson() == null || profile.getSkillsJson().isBlank()) { + return Set.of(); + } + + try { + List parsed = objectMapper.readValue( + profile.getSkillsJson(), + new TypeReference>() {} + ); + if (parsed == null || parsed.isEmpty()) { + return Set.of(); + } + + Set normalizedSkills = new HashSet<>(); + for (String skill : parsed) { + if (skill == null) { + continue; + } + String normalized = skillNameNormalizer.normalize(skill); + if (!normalized.isBlank()) { + normalizedSkills.add(normalized); + } + } + return Set.copyOf(normalizedSkills); + } catch (Exception exception) { + log.warn("Failed to parse student skills JSON: profileId={}, errorType={}", + profile.getId(), exception.getClass().getSimpleName()); + return Set.of(); + } + } + + private Map> loadAliases(List topSkills) { + Collection skillIds = topSkills.stream() + .map(SkillCloudItem::skillId) + .filter(java.util.Objects::nonNull) + .toList(); + if (skillIds.isEmpty()) { + return Map.of(); + } + + Map> aliasesBySkillId = new HashMap<>(); + for (SkillAlias alias : skillAliasRepository.findAllBySkillIdInOrderByIdAsc(skillIds)) { + aliasesBySkillId.computeIfAbsent(alias.getSkillId(), ignored -> new ArrayList<>()).add(alias); + } + return aliasesBySkillId; + } + + private boolean matches(SkillCloudItem skill, + Set studentSkills, + List aliases) { + if (containsNormalized(studentSkills, skill.nameZh()) + || containsNormalized(studentSkills, skill.nameEn()) + || containsNormalized(studentSkills, skill.skillCode())) { + return true; + } + return aliases.stream().anyMatch(alias -> containsNormalized(studentSkills, alias.getAlias())); + } + + private boolean containsNormalized(Set studentSkills, String candidate) { + if (candidate == null) { + return false; + } + String normalized = skillNameNormalizer.normalize(candidate); + return !normalized.isBlank() && studentSkills.contains(normalized); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java new file mode 100644 index 0000000..84ddf2e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java @@ -0,0 +1,28 @@ +package com.mydreamjob.skill; + +import org.springframework.stereotype.Component; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.regex.Pattern; + +@Component +public class SkillNameNormalizer { + + private static final Pattern UNICODE_HYPHENS = Pattern.compile("\\p{Pd}"); + private static final Pattern WHITESPACE = Pattern.compile("\\s+", Pattern.UNICODE_CHARACTER_CLASS); + + public String normalize(String value) { + if (value == null) { + return ""; + } + + String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC) + .trim() + .toLowerCase(Locale.ROOT); + normalized = UNICODE_HYPHENS.matcher(normalized).replaceAll(" "); + normalized = normalized.replace(".", ""); + normalized = WHITESPACE.matcher(normalized).replaceAll(" "); + return normalized.trim(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java new file mode 100644 index 0000000..25953ea --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java @@ -0,0 +1,16 @@ +package com.mydreamjob.skill.dto; + +import java.util.List; + +public record SkillMatchResponse( + int matchScore, + int matchedCount, + int totalCount, + List matchedSkills, + List missingSkills +) { + public SkillMatchResponse { + matchedSkills = List.copyOf(matchedSkills); + missingSkills = List.copyOf(missingSkills); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillMatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillMatchServiceTest.java new file mode 100644 index 0000000..d50b6e8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillMatchServiceTest.java @@ -0,0 +1,482 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.skill.domain.SkillAlias; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudNode; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import com.mydreamjob.skill.repository.SkillAliasRepository; +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +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 org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.doReturn; + +@SpringBootTest +@ExtendWith(OutputCaptureExtension.class) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_match;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillMatchServiceTest$SkillMatchSchemaFilterProvider" +}) +@ActiveProfiles("integration-test") +@Transactional +class SkillMatchServiceTest { + + private static final Long USER_ID = 7001L; + + @Autowired + private SkillMatchService service; + + @SpyBean + private SkillCloudQueryService queryService; + + @SpyBean + private StudentProfileRepository profileRepository; + + @SpyBean + private SkillAliasRepository aliasRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void normalizerAppliesUnicodeRulesAndPreservesProgrammingSymbols() { + SkillNameNormalizer normalizer = new SkillNameNormalizer(); + + assertThat(normalizer.normalize(" Spring‐Boot. ")).isEqualTo("spring boot"); + assertThat(normalizer.normalize(" JAVA\n\tSCRIPT ")).isEqualTo("java script"); + assertThat(normalizer.normalize("Node.js")).isEqualTo("nodejs"); + assertThat(normalizer.normalize("C++")).isEqualTo("c++"); + assertThat(normalizer.normalize("C#")).isEqualTo("c#"); + } + + @Test + void usesOnlyTopTenWhenMoreThanTenSkillsExist() { + List items = IntStream.rangeClosed(1, 11) + .mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index)) + .toList(); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(items.stream().map(SkillCloudItem::nameZh).toList())); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.totalCount()).isEqualTo(10); + assertThat(result.matchedCount()).isEqualTo(10); + assertThat(result.matchScore()).isEqualTo(100); + assertThat(result.matchedSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactlyElementsOf(IntStream.rangeClosed(1, 10).mapToLong(Long::valueOf).boxed().toList()); + assertThat(result.missingSkills()).isEmpty(); + } + + @Test + void usesTenAsDenominatorWhenExactlyTenSkillsExist() { + List items = IntStream.rangeClosed(1, 10) + .mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index)) + .toList(); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(items.stream().limit(5).map(SkillCloudItem::nameZh).toList())); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.totalCount()).isEqualTo(10); + assertThat(result.matchedCount()).isEqualTo(5); + assertThat(result.matchScore()).isEqualTo(50); + assertThat(result.missingSkills()).hasSize(5); + } + + @Test + void usesActualSizeAsDenominatorAndRoundsOneThirdToThirtyThree() { + List items = List.of( + item(1L, "Java", "Java", "JAVA"), + item(2L, "Python", "Python", "PYTHON"), + item(3L, "SQL", "SQL", "SQL") + ); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(List.of("Java"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.totalCount()).isEqualTo(3); + assertThat(result.matchedCount()).isEqualTo(1); + assertThat(result.matchScore()).isEqualTo(33); + assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L); + assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L, 3L); + } + + @Test + void returnsZeroAndEmptyListsWhenCloudIsEmpty() { + stubCloud(cloud(List.of())); + saveProfile(USER_ID, json(List.of("Java"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isZero(); + assertThat(result.matchedCount()).isZero(); + assertThat(result.totalCount()).isZero(); + assertThat(result.matchedSkills()).isEmpty(); + assertThat(result.missingSkills()).isEmpty(); + } + + @Test + void treatsMissingProfileAsNoSkills() { + stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA")))); + + SkillMatchResponse result = service.match(9999L, 1L, 1L, null); + + assertThat(result.matchScore()).isZero(); + assertThat(result.matchedSkills()).isEmpty(); + assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L); + } + + @Test + void treatsBlankAndEmptyArrayAsNoSkills() { + stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"), item(2L, "SQL", "SQL", "SQL")))); + doReturn(Optional.of(StudentProfile.builder() + .id(100L) + .userId(USER_ID) + .skillsJson(" ") + .build())) + .when(profileRepository).findByUserId(USER_ID); + + SkillMatchResponse blank = service.match(USER_ID, 1L, 1L, null); + + assertThat(blank.matchScore()).isZero(); + assertThat(blank.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); + + doReturn(Optional.of(StudentProfile.builder() + .id(101L) + .userId(USER_ID) + .skillsJson("[]") + .build())) + .when(profileRepository).findByUserId(USER_ID); + + SkillMatchResponse empty = service.match(USER_ID, 1L, 1L, null); + + assertThat(empty.matchScore()).isZero(); + assertThat(empty.matchedSkills()).isEmpty(); + } + + @Test + void treatsMalformedJsonAsNoSkillsWithoutLeakingRawJson(CapturedOutput output) { + String malformedJson = "{\"skills\":[\"Java\"]"; + stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA")))); + doReturn(Optional.of(StudentProfile.builder() + .id(102L) + .userId(USER_ID) + .skillsJson(malformedJson) + .build())) + .when(profileRepository).findByUserId(USER_ID); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isZero(); + assertThat(result.matchedSkills()).isEmpty(); + assertThat(output.getAll()).contains("Failed to parse student skills JSON"); + assertThat(output.getAll()).doesNotContain(malformedJson); + } + + @Test + void matchesNameZhNameEnSkillCodeAndAliasAndDeduplicatesStudentSkills() { + List items = List.of( + item(1L, "数据库", "Database Systems", "DATA_STORE_CLASS"), + item(2L, "中文技能", "database technology", "UNRELATED_CODE"), + item(3L, "代码技能", "Code English", "DATABASE_ENGINE") + ); + stubCloud(cloud(items)); + doReturn(List.of(alias(1L, "JVM Language", "jvm language"))) + .when(aliasRepository).findAllBySkillIdInOrderByIdAsc(anyCollection()); + saveProfile(USER_ID, json(List.of( + "jvm-language", + "JVM LANGUAGE", + "数据库", + "DATABASE TECHNOLOGY", + "database_engine" + ))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isEqualTo(100); + assertThat(result.matchedCount()).isEqualTo(3); + assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L, 3L); + assertThat(result.missingSkills()).isEmpty(); + } + + @Test + void matchesOnlyNameZhForV36JavaWhenOtherIdentityFieldsDoNotMatch() { + jdbcTemplate.update("UPDATE skills SET code = ? WHERE id = ?", "JAVA_NAME_ZH_ISOLATED", 1L); + saveProfile(USER_ID, json(List.of("Java"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); + + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.matchScore()).isEqualTo(50); + assertThat(result.matchedSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(1L); + assertThat(result.missingSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(2L); + } + + @Test + void matchesOnlyInsertedAliasForV36JavaWhenAliasIsTheOnlyIdentityMatch() { + jdbcTemplate.update(""" + INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) + VALUES (?, ?, ?, ?) + """, 10001L, 1L, "ONLY.ALIAS-JVM-XYZ", "only alias jvm xyz"); + saveProfile(USER_ID, json(List.of("ONLY.ALIAS-JVM-XYZ"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); + + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.matchScore()).isEqualTo(50); + assertThat(result.matchedSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(1L); + assertThat(result.missingSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(2L); + } + + @Test + void doesNotApplyAliasOwnedByAnotherTopSkillToV36Java() { + jdbcTemplate.update(""" + INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) + VALUES (?, ?, ?, ?) + """, 10002L, 2L, "ONLY.ALIAS-OWNER-SKILL-2", "only alias owner skill 2"); + saveProfile(USER_ID, json(List.of("ONLY.ALIAS-OWNER-SKILL-2"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); + + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.matchScore()).isEqualTo(50); + assertThat(result.matchedSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(2L); + assertThat(result.missingSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(1L); + } + + @Test + void keepsMatchedAndMissingListsOrderedAndTheirUnionEqualToTopTen() { + List items = List.of( + item(1L, "Java", "Java", "JAVA"), + item(2L, "Python", "Python", "PYTHON"), + item(3L, "SQL", "SQL", "SQL") + ); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(List.of("Python"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + List union = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream()) + .map(SkillCloudItem::skillId) + .toList(); + + assertThat(result.matchScore()).isEqualTo(33); + assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L); + assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 3L); + assertThat(union).containsExactlyInAnyOrder(1L, 2L, 3L); + } + + @Test + void returnsZeroWhenNoTopSkillMatches() { + List items = List.of( + item(1L, "Java", "Java", "JAVA"), + item(2L, "SQL", "SQL", "SQL") + ); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(List.of("Marketing"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isZero(); + assertThat(result.matchedSkills()).isEmpty(); + assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); + } + + @Test + void returnsOneHundredAndNoMissingSkillsWhenAllTopSkillsMatch() { + List items = List.of( + item(1L, "Java", "Java", "JAVA"), + item(2L, "SQL", "SQL", "SQL") + ); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(List.of("Java", "SQL"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isEqualTo(100); + assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); + assertThat(result.missingSkills()).isEmpty(); + } + + @Test + void excludesCrossOccupationCategoryMismatchFromMatchSet() { + jdbcTemplate.update(""" + INSERT INTO occupation_skills ( + id, occupation_id, skill_id, category_id, importance, + importance_source_code, importance_element_id, importance_is_proxy, + relationship_type, source_version_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + 10000L, 1L, 1L, 201L, new BigDecimal("5.00"), + "15-1254.00", "2.E.7.h", true, "ESSENTIAL", 2L); + saveProfile(USER_ID, json(List.of("Java"))); + + SkillCloudResponse cloud = queryService.query(1L, 1L, null); + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + List expectedTop = cloud.items().stream().limit(10).map(SkillCloudItem::skillId).toList(); + List actualUnion = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream()) + .map(SkillCloudItem::skillId) + .toList(); + + assertThat(result.matchedCount()).isZero(); + assertThat(actualUnion).containsExactlyElementsOf(expectedTop); + assertThat(actualUnion).doesNotContain(1L); + } + + private void stubCloud(SkillCloudResponse response) { + doReturn(response).when(queryService).query(any(), any(), any()); + } + + private StudentProfile saveProfile(Long userId, String skillsJson) { + jdbcTemplate.update(""" + INSERT INTO users (id, phone, role, is_active, is_verified, created_at, updated_at) + VALUES (?, ?, 'STUDENT', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, userId, String.valueOf(userId)); + return profileRepository.saveAndFlush(StudentProfile.builder() + .userId(userId) + .skillsJson(skillsJson) + .build()); + } + + private SkillCloudResponse cloud(List items) { + return new SkillCloudResponse( + new SkillCloudNode(1L, "INTERNET", "互联网"), + new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"), + null, + items.size(), + items + ); + } + + private SkillCloudItem item(Long id, String nameZh, String nameEn, String code) { + return new SkillCloudItem( + id.intValue(), + id, + code, + nameZh, + nameEn, + "O*NET", + "test-source", + 1L, + "测试分类", + BigDecimal.TEN, + null, + null, + false + ); + } + + private SkillAlias alias(Long skillId, String alias, String normalizedAlias) { + return SkillAlias.builder() + .id(1L) + .skillId(skillId) + .alias(alias) + .normalizedAlias(normalizedAlias) + .build(); + } + + private String json(List values) { + return values.stream() + .map(value -> "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") + .collect(java.util.stream.Collectors.joining(",", "[", "]")); + } + + public static class SkillMatchSchemaFilterProvider implements SchemaFilterProvider { + + private static final Set VALIDATED_TABLES = Set.of( + "skill_source_versions", + "skill_industries", + "skill_occupations", + "skill_categories", + "skills", + "skill_aliases", + "occupation_skills" + ); + + @Override + public SchemaFilter getCreateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getDropFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getMigrateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getValidateFilter() { + return new SchemaFilter() { + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + @Override + public boolean includeTable(Table table) { + return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT)); + } + + @Override + public boolean includeSequence(Sequence sequence) { + return false; + } + }; + } + } +}