Browse Source

feat: add skill taxonomy schema migration

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
93477bd0bf
  1. 111
      mydreamjob-server/src/main/resources/db/migration/V35__skill_cloud_taxonomy.sql
  2. 334
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java

111
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='职位技能关系与热度';

334
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<String, Object> 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<String, Object> 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<String> 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<String, TreeMap<Short, String>> 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<String> 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));
}
}
Loading…
Cancel
Save