feat(board-generator): add board code generator and sample CRUD artifacts

Add Node.js CLI tool with Handlebars templates for generating standard CRUD artifacts: Java entity, service, DAO, controller, MyBatis mapper XML, and Vue frontend pages.
Also generate the full SampleTableBoard CRUD reference implementation, update README with backend execution instructions, and add project plan documentation.
This commit is contained in:
2026-05-31 13:22:03 +09:00
parent d5ac812703
commit 12c40c6004
25 changed files with 3305 additions and 1 deletions
@@ -0,0 +1,23 @@
database:
client: postgresql
host: 192.168.0.60
port: 5432
database: casaos
user: casaos
password: casaos
schema: public
paths:
basePackage: com.samsung
javaRoot: src/main/java
mapperRoot: src/main/resources/sql/mybatis/postgresql
vueRoot: frontend/src/components/view/admin
vueBoardDir: pgBoard
generatedRoot: .
conventions:
controllerStyle: formula_like
dateType: java.util.Date
apiStyle: main_do_method
requestBasePrefix: /pg-board
indent: tab
+248
View File
@@ -0,0 +1,248 @@
{
"name": "board-generator",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "board-generator",
"version": "0.1.0",
"dependencies": {
"handlebars": "^4.7.8",
"pg": "^8.13.1",
"yaml": "^2.6.0"
},
"bin": {
"generate-board": "src/index.js"
},
"engines": {
"node": ">=20.14.0"
}
},
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "board-generator",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"generate-board": "./src/index.js"
},
"scripts": {
"start": "node ./src/index.js --help"
},
"engines": {
"node": ">=20.14.0"
},
"dependencies": {
"handlebars": "^4.7.8",
"pg": "^8.13.1",
"yaml": "^2.6.0"
}
}
+401
View File
@@ -0,0 +1,401 @@
import fs from 'node:fs/promises'; // 파일 시스템 비동기 API를 사용한다.
import path from 'node:path'; // 경로 유틸리티를 사용한다.
import YAML from 'yaml'; // YAML 설정 파일 파서를 사용한다.
import Handlebars from 'handlebars'; // 템플릿 렌더링 엔진을 사용한다.
import pg from 'pg'; // PostgreSQL 클라이언트를 사용한다.
const { Client } = pg; // PostgreSQL 클라이언트 생성자를 분리한다.
const DEFAULT_CONFIG = { // 기본 설정 객체를 정의한다.
database: { // 데이터베이스 기본 설정을 정의한다.
client: 'postgresql', // 데이터베이스 종류를 설정한다.
host: '127.0.0.1', // 기본 호스트를 설정한다.
port: 5432, // 기본 포트를 설정한다.
database: 'app', // 기본 데이터베이스명을 설정한다.
user: 'app', // 기본 사용자명을 설정한다.
password: 'app', // 기본 비밀번호를 설정한다.
schema: 'public', // 기본 스키마를 설정한다.
}, // database 설정을 종료한다.
paths: { // 출력 경로 기본 설정을 정의한다.
basePackage: 'com.samsung', // 베이스 패키지를 설정한다.
javaRoot: 'src/main/java', // Java 루트 경로를 설정한다.
mapperRoot: 'src/main/resources/sql/mybatis/postgresql', // 매퍼 루트 경로를 설정한다.
vueRoot: 'frontend/src/components/view/admin', // Vue 루트 경로를 설정한다.
vueBoardDir: 'pgBoard', // Vue 하위 보드 경로를 설정한다.
generatedRoot: '.', // 생성 루트 경로를 프로젝트 루트로 설정한다.
}, // paths 설정을 종료한다.
conventions: { // 코드 생성 규칙 기본 설정을 정의한다.
controllerStyle: 'formula_like', // 컨트롤러 스타일을 설정한다.
dateType: 'java.util.Date', // 날짜 타입을 설정한다.
apiStyle: 'main_do_method', // API 스타일을 설정한다.
requestBasePrefix: '/pg-board', // 요청 경로 접두어를 설정한다.
indent: 'tab', // 들여쓰기 유형을 설정한다.
}, // conventions 설정을 종료한다.
}; // 기본 설정 정의를 종료한다.
const AUDIT_COLUMNS = new Set([ // 감사 컬럼 집합을 정의한다.
'created_at', // 생성일 컬럼을 등록한다.
'created_datetime', // 생성일시 컬럼을 등록한다.
'updated_at', // 수정일 컬럼을 등록한다.
'updated_datetime', // 수정일시 컬럼을 등록한다.
'first_reg_datetime', // 최초 등록일시 컬럼을 등록한다.
'last_mod_datetime', // 최종 수정일시 컬럼을 등록한다.
]); // 감사 컬럼 집합 정의를 종료한다.
const TEMPLATE_MAP = { // 템플릿 파일 매핑을 정의한다.
entity: 'entity.hbs', // Entity 템플릿 파일명을 설정한다.
service: 'service.hbs', // Service 템플릿 파일명을 설정한다.
dao: 'dao.hbs', // DAO 템플릿 파일명을 설정한다.
serviceImpl: 'serviceImpl.hbs', // ServiceImpl 템플릿 파일명을 설정한다.
controller: 'controller.hbs', // Controller 템플릿 파일명을 설정한다.
mapper: 'mapper.hbs', // Mapper 템플릿 파일명을 설정한다.
page: 'page.vue.hbs', // Vue 템플릿 파일명을 설정한다.
}; // 템플릿 파일 매핑 정의를 종료한다.
function deepMerge(baseObject, overrideObject) { // 두 객체를 깊게 병합한다.
const mergedObject = { ...baseObject }; // 기본 객체를 얕게 복사한다.
for (const [key, value] of Object.entries(overrideObject || {})) { // 재정의 객체 항목을 순회한다.
if (value && typeof value === 'object' && !Array.isArray(value) && mergedObject[key] && typeof mergedObject[key] === 'object') { // 재귀 병합 대상인지 확인한다.
mergedObject[key] = deepMerge(mergedObject[key], value); // 중첩 객체를 재귀적으로 병합한다.
continue; // 다음 항목으로 이동한다.
} // 조건문을 종료한다.
mergedObject[key] = value; // 현재 값을 덮어쓴다.
} // 반복문을 종료한다.
return mergedObject; // 병합 결과를 반환한다.
} // 함수를 종료한다.
async function fileExists(filePath) { // 파일 존재 여부를 확인한다.
return fs.access(filePath).then(() => true).catch(() => false); // 접근 가능 여부를 불리언으로 반환한다.
} // 함수를 종료한다.
async function loadConfig(projectRoot) { // 설정 파일과 환경 변수를 읽는다.
const configPath = path.join(projectRoot, 'tools', 'board-generator', 'config', 'board-generator.yml'); // 설정 파일 경로를 계산한다.
let loadedConfig = DEFAULT_CONFIG; // 기본 설정으로 초기화한다.
if (await fileExists(configPath)) { // 설정 파일이 존재하는지 확인한다.
const rawConfig = await fs.readFile(configPath, 'utf8'); // YAML 설정 문자열을 읽는다.
loadedConfig = deepMerge(DEFAULT_CONFIG, YAML.parse(rawConfig) || {}); // 기본 설정과 사용자 설정을 병합한다.
} // 조건문을 종료한다.
return deepMerge(loadedConfig, { // 환경 변수 재정의를 반영해 반환한다.
database: { // 데이터베이스 재정의 항목을 정의한다.
host: process.env.BOARD_DB_HOST || loadedConfig.database.host, // 호스트 재정의를 반영한다.
port: Number(process.env.BOARD_DB_PORT || loadedConfig.database.port), // 포트 재정의를 반영한다.
database: process.env.BOARD_DB_NAME || loadedConfig.database.database, // 데이터베이스명 재정의를 반영한다.
user: process.env.BOARD_DB_USER || loadedConfig.database.user, // 사용자명 재정의를 반영한다.
password: process.env.BOARD_DB_PASSWORD || loadedConfig.database.password, // 비밀번호 재정의를 반영한다.
schema: process.env.BOARD_DB_SCHEMA || loadedConfig.database.schema, // 스키마 재정의를 반영한다.
}, // database 재정의 정의를 종료한다.
}); // 병합 결과 반환을 종료한다.
} // 함수를 종료한다.
function splitSchemaAndTable(inputTableName, defaultSchema) { // 스키마와 테이블명을 분리한다.
const normalizedValue = String(inputTableName || '').trim(); // 입력 테이블명을 문자열로 정규화한다.
if (!normalizedValue) { // 입력값이 비어 있는지 확인한다.
throw new Error('필수 옵션 --table 이 필요합니다.'); // 필수 옵션 누락 예외를 발생시킨다.
} // 조건문을 종료한다.
if (!normalizedValue.includes('.')) { // 스키마 구분자가 없는지 확인한다.
return { schema: defaultSchema, table: normalizedValue }; // 기본 스키마와 함께 반환한다.
} // 조건문을 종료한다.
const [schema, table] = normalizedValue.split('.'); // 스키마와 테이블명을 분리한다.
return { schema, table }; // 분리된 값을 반환한다.
} // 함수를 종료한다.
function singularizeTableName(tableName) { // 테이블명을 단수형에 가깝게 보정한다.
const normalizedValue = String(tableName || '').trim(); // 입력 테이블명을 정규화한다.
if (normalizedValue.endsWith('ies')) { // ies 로 끝나는지를 확인한다.
return `${normalizedValue.slice(0, -3)}y`; // y 형태로 단수형을 만든다.
} // 조건문을 종료한다.
if (normalizedValue.endsWith('s') && !normalizedValue.endsWith('ss')) { // 일반적인 복수형 s 인지 확인한다.
return normalizedValue.slice(0, -1); // 마지막 s 를 제거한다.
} // 조건문을 종료한다.
return normalizedValue; // 변경 없이 반환한다.
} // 함수를 종료한다.
function toPascalCase(value) { // 문자열을 PascalCase 로 변환한다.
return String(value || '') // 입력값을 문자열로 변환한다.
.replace(/([a-z0-9])([A-Z])/g, '$1_$2') // 기존 camelCase 경계를 구분 문자로 분리한다.
.split(/[^a-zA-Z0-9]+/) // 영숫자가 아닌 구분자로 분리한다.
.filter(Boolean) // 빈 문자열을 제거한다.
.map(token => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase()) // 각 토큰을 PascalCase 조각으로 변환한다.
.join(''); // 조각들을 하나의 문자열로 합친다.
} // 함수를 종료한다.
function toCamelCase(value) { // 문자열을 camelCase 로 변환한다.
const pascalCaseValue = toPascalCase(value); // PascalCase 값을 계산한다.
return pascalCaseValue ? pascalCaseValue.charAt(0).toLowerCase() + pascalCaseValue.slice(1) : ''; // 첫 글자를 소문자로 바꿔 반환한다.
} // 함수를 종료한다.
function toKebabCase(value) { // 문자열을 kebab-case 로 변환한다.
return String(value || '') // 입력값을 문자열로 변환한다.
.replace(/([a-z0-9])([A-Z])/g, '$1-$2') // camelCase 경계를 하이픈으로 분리한다.
.replace(/[_\s]+/g, '-') // 언더스코어와 공백을 하이픈으로 치환한다.
.toLowerCase(); // 전체를 소문자로 변환한다.
} // 함수를 종료한다.
function toDisplayLabel(value) { // 컬럼명을 화면용 라벨로 변환한다.
return String(value || '') // 입력값을 문자열로 변환한다.
.split('_') // 언더스코어 기준으로 분리한다.
.filter(Boolean) // 빈 항목을 제거한다.
.map(token => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase()) // 각 토큰을 화면용 대소문자로 변환한다.
.join(' '); // 공백으로 합쳐 반환한다.
} // 함수를 종료한다.
function toGetterName(propertyName) { // getter 메서드명을 만든다.
const normalizedValue = String(propertyName || ''); // 프로퍼티명을 정규화한다.
return `get${normalizedValue.charAt(0).toUpperCase()}${normalizedValue.slice(1)}`; // getter 메서드명을 반환한다.
} // 함수를 종료한다.
function buildModulePath(tableName) { // 테이블명을 기본 모듈 경로로 변환한다.
if (String(tableName).includes('_')) { // 언더스코어가 포함되는지 확인한다.
return String(tableName).split('_').filter(Boolean).join('/'); // 언더스코어를 슬래시 경로로 변환한다.
} // 조건문을 종료한다.
return String(tableName); // 원본 값을 그대로 반환한다.
} // 함수를 종료한다.
function mapColumnType(column, config) { // DB 타입을 Java 타입으로 매핑한다.
const normalizedDataType = String(column.dataType || '').toLowerCase(); // data_type 값을 소문자로 정규화한다.
const normalizedUdtName = String(column.udtName || '').toLowerCase(); // udt_name 값을 소문자로 정규화한다.
const dateImportType = config.conventions.dateType || 'java.util.Date'; // 날짜 타입 설정을 읽는다.
if (['varchar', 'text', 'char', 'bpchar'].includes(normalizedDataType) || ['varchar', 'text', 'bpchar'].includes(normalizedUdtName)) { // 문자열 타입인지 확인한다.
return { javaType: 'String', myBatisType: 'java.lang.String', importType: null, supported: true }; // 문자열 매핑 결과를 반환한다.
} // 조건문을 종료한다.
if (['smallint', 'integer'].includes(normalizedDataType) || ['int2', 'int4', 'serial', 'smallint', 'integer'].includes(normalizedUdtName)) { // Integer 타입인지 확인한다.
return { javaType: 'Integer', myBatisType: 'java.lang.Integer', importType: null, supported: true }; // Integer 매핑 결과를 반환한다.
} // 조건문을 종료한다.
if (normalizedDataType === 'bigint' || ['int8', 'bigserial', 'bigint'].includes(normalizedUdtName)) { // Long 타입인지 확인한다.
return { javaType: 'Long', myBatisType: 'java.lang.Long', importType: null, supported: true }; // Long 매핑 결과를 반환한다.
} // 조건문을 종료한다.
if (['numeric', 'decimal'].includes(normalizedDataType) || normalizedUdtName === 'numeric') { // BigDecimal 타입인지 확인한다.
return { javaType: 'BigDecimal', myBatisType: 'java.math.BigDecimal', importType: 'java.math.BigDecimal', supported: true }; // BigDecimal 매핑 결과를 반환한다.
} // 조건문을 종료한다.
if (normalizedDataType === 'boolean' || ['bool', 'boolean'].includes(normalizedUdtName)) { // Boolean 타입인지 확인한다.
return { javaType: 'Boolean', myBatisType: 'java.lang.Boolean', importType: null, supported: true }; // Boolean 매핑 결과를 반환한다.
} // 조건문을 종료한다.
if (['date', 'timestamp without time zone', 'timestamp with time zone'].includes(normalizedDataType) || ['date', 'timestamp', 'timestamptz'].includes(normalizedUdtName)) { // 날짜 계열 타입인지 확인한다.
return { javaType: 'Date', myBatisType: dateImportType, importType: dateImportType, supported: true }; // 날짜 타입 매핑 결과를 반환한다.
} // 조건문을 종료한다.
return { javaType: 'Object', myBatisType: 'java.lang.Object', importType: null, supported: false }; // 지원하지 않는 타입 결과를 반환한다.
} // 함수를 종료한다.
async function readMetadata(config, inputTableName) { // PostgreSQL 메타데이터를 조회한다.
const tableInfo = splitSchemaAndTable(inputTableName, config.database.schema || 'public'); // 스키마와 테이블명을 분리한다.
const client = new Client({ // PostgreSQL 연결 설정을 구성한다.
host: config.database.host, // DB 호스트를 설정한다.
port: config.database.port, // DB 포트를 설정한다.
database: config.database.database, // DB 이름을 설정한다.
user: config.database.user, // DB 사용자를 설정한다.
password: config.database.password, // DB 비밀번호를 설정한다.
}); // 클라이언트 생성을 종료한다.
const metadataSql = `
SELECT
c.column_name AS "columnName",
c.data_type AS "dataType",
c.udt_name AS "udtName",
(c.is_nullable = 'YES') AS "nullable",
c.ordinal_position AS "ordinalPosition",
c.column_default AS "columnDefault",
COALESCE(c.identity_generation, '') AS "identityGeneration",
EXISTS (
SELECT 1
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
AND tc.table_name = kcu.table_name
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = c.table_schema
AND tc.table_name = c.table_name
AND kcu.column_name = c.column_name
) AS "primaryKey"
FROM information_schema.columns c
WHERE c.table_schema = $1
AND c.table_name = $2
ORDER BY c.ordinal_position
`; // SQL 문자열 정의를 종료한다.
try { // DB 연결과 조회를 시도한다.
await client.connect(); // 데이터베이스에 연결한다.
const queryResult = await client.query(metadataSql, [tableInfo.schema, tableInfo.table]); // 메타데이터 조회 SQL 을 실행한다.
if (!queryResult.rows.length) { // 조회 결과가 없는지 확인한다.
throw new Error(`Table not found: ${tableInfo.schema}.${tableInfo.table}`); // 테이블 미존재 예외를 발생시킨다.
} // 조건문을 종료한다.
const columns = queryResult.rows.map(row => { // 각 컬럼의 파생 정보를 계산한다.
const typeInfo = mapColumnType(row, config); // 컬럼 타입 매핑 정보를 계산한다.
const autoIncrement = Boolean(row.identityGeneration) || String(row.columnDefault || '').includes('nextval'); // 자동 증가 여부를 계산한다.
return { ...row, ...typeInfo, autoIncrement }; // 컬럼 메타데이터와 파생 정보를 합쳐 반환한다.
}); // 컬럼 배열 생성 처리를 종료한다.
return { schema: tableInfo.schema, tableName: tableInfo.table, columns }; // 최종 메타데이터를 반환한다.
} finally { // 연결 종료를 보장한다.
await client.end().catch(() => undefined); // 연결 종료 실패는 무시한다.
} // try-finally 를 종료한다.
} // 함수를 종료한다.
function validateMetadata(metadata) { // 조회된 메타데이터를 검증한다.
const primaryKeyColumns = metadata.columns.filter(column => column.primaryKey); // 기본키 컬럼 목록을 구한다.
if (!primaryKeyColumns.length) { // 기본키가 없는지 확인한다.
throw new Error('기본키가 없는 테이블은 지원하지 않습니다.'); // 기본키 누락 예외를 발생시킨다.
} // 조건문을 종료한다.
if (primaryKeyColumns.length > 1) { // 기본키가 여러 개인지 확인한다.
throw new Error('복합 기본키는 지원하지 않습니다.'); // 복합 기본키 예외를 발생시킨다.
} // 조건문을 종료한다.
const unsupportedColumns = metadata.columns.filter(column => !column.supported); // 지원하지 않는 컬럼 목록을 구한다.
if (unsupportedColumns.length) { // 지원 불가 컬럼이 있는지 확인한다.
const unsupportedColumnMessage = unsupportedColumns.map(column => `${column.columnName}(${column.dataType || column.udtName})`).join(', '); // 메시지용 컬럼 문자열을 만든다.
throw new Error(`지원하지 않는 컬럼 타입이 있습니다: ${unsupportedColumnMessage}`); // 지원 불가 타입 예외를 발생시킨다.
} // 조건문을 종료한다.
} // 함수를 종료한다.
function buildPrimaryKeyParser(primaryKeyColumn) { // 기본키 문자열 파싱 식을 계산한다.
if (primaryKeyColumn.javaType === 'Integer') { // Integer 타입인지 확인한다.
return { parserExpression: 'Integer.valueOf(value)', importType: null }; // Integer 파싱 식을 반환한다.
} // 조건문을 종료한다.
if (primaryKeyColumn.javaType === 'Long') { // Long 타입인지 확인한다.
return { parserExpression: 'Long.valueOf(value)', importType: null }; // Long 파싱 식을 반환한다.
} // 조건문을 종료한다.
if (primaryKeyColumn.javaType === 'BigDecimal') { // BigDecimal 타입인지 확인한다.
return { parserExpression: 'new BigDecimal(value)', importType: 'java.math.BigDecimal' }; // BigDecimal 파싱 식을 반환한다.
} // 조건문을 종료한다.
return { parserExpression: 'value', importType: null }; // 기본 문자열 파싱 식을 반환한다.
} // 함수를 종료한다.
function buildContext(config, options, metadata) { // 템플릿 렌더링 컨텍스트를 생성한다.
const singularTableName = singularizeTableName(metadata.tableName); // 단수형 테이블명을 계산한다.
const entityName = options.entity || toPascalCase(singularTableName); // Entity 이름을 계산한다.
const modulePath = options.module || buildModulePath(singularTableName); // 모듈 경로를 계산한다.
const entityVarName = toCamelCase(entityName); // Entity 변수명을 계산한다.
const modulePackage = modulePath.split('/').filter(Boolean).join('.'); // 모듈 패키지명을 계산한다.
const basePackage = config.paths.basePackage; // 베이스 패키지를 읽는다.
const basePackagePath = basePackage.split('.').join('/'); // 베이스 패키지를 경로로 변환한다.
const rootPackage = modulePackage ? `${basePackage}.${modulePackage}` : basePackage; // 루트 패키지를 계산한다.
const requestBasePath = `${String(config.conventions.requestBasePrefix || '/pg-board').replace(/\/$/, '')}/${toKebabCase(metadata.tableName)}/`; // API 기본 경로를 계산한다.
const columns = metadata.columns.map(column => { // 컬럼별 파생 정보를 계산한다.
const propertyName = toCamelCase(column.columnName); // 프로퍼티명을 계산한다.
const isAuditColumn = AUDIT_COLUMNS.has(column.columnName); // 감사 컬럼 여부를 계산한다.
const isTextarea = column.dataType === 'text' || /content|desc|memo/i.test(column.columnName); // textarea 후보 여부를 계산한다.
return { // 파생 정보를 포함한 컬럼 객체를 반환한다.
...column, // 원본 컬럼 정보를 유지한다.
propertyName, // 프로퍼티명을 저장한다.
getterName: toGetterName(propertyName), // getter 메서드명을 저장한다.
label: toDisplayLabel(column.columnName), // 화면 라벨을 저장한다.
vueItemExpression: `{{ item.${propertyName} }}`, // Vue 목록 출력 표현식을 저장한다.
isAuditColumn, // 감사 컬럼 여부를 저장한다.
isTextarea, // textarea 여부를 저장한다.
isFormCandidate: !column.primaryKey && !column.autoIncrement && !isAuditColumn, // 폼 포함 여부를 저장한다.
isListCandidate: column.primaryKey || !isTextarea || isAuditColumn, // 목록 포함 여부를 저장한다.
}; // 컬럼 객체 생성을 종료한다.
}); // 컬럼 배열 생성을 종료한다.
const primaryKeyColumn = columns.find(column => column.primaryKey); // 기본키 컬럼을 찾는다.
const parserInfo = buildPrimaryKeyParser(primaryKeyColumn); // 기본키 파싱 정보를 계산한다.
const formColumns = columns.filter(column => column.isFormCandidate); // 폼 컬럼 목록을 계산한다.
const insertColumns = columns.filter(column => !column.primaryKey && !column.autoIncrement && !column.isAuditColumn); // insert 컬럼 목록을 계산한다.
const updateColumns = columns.filter(column => !column.primaryKey && !column.isAuditColumn); // update 컬럼 목록을 계산한다.
const listColumns = (columns.filter(column => column.isListCandidate).length ? columns.filter(column => column.isListCandidate) : columns).slice(0, 4); // 목록 컬럼을 계산한다.
const entityImports = Array.from(new Set(columns.map(column => column.importType).filter(Boolean))).sort(); // Entity import 목록을 계산한다.
const controllerImports = Array.from(new Set([parserInfo.importType].filter(Boolean))).sort(); // Controller import 목록을 계산한다.
const javaModuleRoot = path.posix.join(config.paths.javaRoot, basePackagePath, modulePath); // Java 모듈 루트 경로를 계산한다.
return { // 최종 렌더링 컨텍스트를 반환한다.
config, // 설정 객체를 포함한다.
metadata, // 메타데이터를 포함한다.
tableName: metadata.tableName, // 테이블명을 포함한다.
modulePath, // 모듈 경로를 포함한다.
entityName, // Entity 이름을 포함한다.
entityVarName, // Entity 변수명을 포함한다.
serviceName: `${entityName}Service`, // 서비스 인터페이스명을 포함한다.
serviceImplName: `${entityName}ServiceImpl`, // 서비스 구현체명을 포함한다.
daoName: `${entityName}Dao`, // DAO 이름을 포함한다.
controllerName: `${entityName}Controller`, // 컨트롤러 이름을 포함한다.
mapperNamespace: `${entityVarName}Mapper`, // 매퍼 네임스페이스를 포함한다.
mapperFileName: `mapper-mybatis-${modulePath.replace(/\//g, '-')}.xml`, // 매퍼 파일명을 포함한다.
vueComponentName: `${entityName}Page`, // Vue 컴포넌트명을 포함한다.
vueFileName: `${entityName}Page.vue`, // Vue 파일명을 포함한다.
menuName: options.menuName || entityName, // 화면 표시명을 포함한다.
requestBasePath, // API 기본 경로를 포함한다.
detailModeExpression: "{{ mode === 'CREATE' ? '등록' : '상세' }}", // 상세 제목 표현식을 포함한다.
totalCountExpression: '{{ itemList.length }}', // 목록 건수 표현식을 포함한다.
rootPackage, // 루트 패키지를 포함한다.
entityPackage: `${rootPackage}.entity`, // Entity 패키지를 포함한다.
daoPackage: `${rootPackage}.dao`, // DAO 패키지를 포함한다.
implPackage: `${rootPackage}.impl`, // 구현체 패키지를 포함한다.
controllerPackage: `${rootPackage}.controller`, // 컨트롤러 패키지를 포함한다.
entityFqcn: `${rootPackage}.entity.${entityName}`, // Entity 전체 클래스명을 포함한다.
primaryKeyColumn, // 기본키 컬럼 정보를 포함한다.
primaryKeyProperty: primaryKeyColumn.propertyName, // 기본키 프로퍼티명을 포함한다.
primaryKeyGetterName: primaryKeyColumn.getterName, // 기본키 getter 명을 포함한다.
primaryKeyJavaType: primaryKeyColumn.javaType, // 기본키 Java 타입을 포함한다.
primaryKeyMyBatisType: primaryKeyColumn.myBatisType, // 기본키 MyBatis 타입을 포함한다.
primaryKeyParseExpression: parserInfo.parserExpression, // 기본키 파싱 식을 포함한다.
columns, // 전체 컬럼 목록을 포함한다.
formColumns, // 폼 컬럼 목록을 포함한다.
insertColumns, // insert 컬럼 목록을 포함한다.
updateColumns, // update 컬럼 목록을 포함한다.
listColumns, // 목록 컬럼 목록을 포함한다.
entityImports, // Entity import 목록을 포함한다.
controllerImports, // Controller import 목록을 포함한다.
javaEntityPath: path.posix.join(javaModuleRoot, 'entity', `${entityName}.java`), // Entity 파일 경로를 포함한다.
javaServicePath: path.posix.join(javaModuleRoot, `${entityName}Service.java`), // Service 파일 경로를 포함한다.
javaDaoPath: path.posix.join(javaModuleRoot, 'dao', `${entityName}Dao.java`), // DAO 파일 경로를 포함한다.
javaServiceImplPath: path.posix.join(javaModuleRoot, 'impl', `${entityName}ServiceImpl.java`), // ServiceImpl 파일 경로를 포함한다.
javaControllerPath: path.posix.join(javaModuleRoot, 'controller', `${entityName}Controller.java`), // Controller 파일 경로를 포함한다.
xmlMapperPath: path.posix.join(config.paths.mapperRoot, `mapper-mybatis-${modulePath.replace(/\//g, '-')}.xml`), // XML 파일 경로를 포함한다.
vuePagePath: path.posix.join(config.paths.vueRoot, config.paths.vueBoardDir, `${entityName}Page.vue`), // Vue 파일 경로를 포함한다.
}; // 컨텍스트 반환을 종료한다.
} // 함수를 종료한다.
async function renderTemplate(projectRoot, templateName, context) { // 템플릿 파일을 렌더링한다.
const templatePath = path.join(projectRoot, 'tools', 'board-generator', 'templates', templateName); // 템플릿 파일 경로를 계산한다.
const source = await fs.readFile(templatePath, 'utf8'); // 템플릿 원문을 읽는다.
const template = Handlebars.compile(source, { noEscape: true }); // Handlebars 템플릿을 컴파일한다.
return template(context); // 렌더링 결과를 반환한다.
} // 함수를 종료한다.
async function buildFilePlan(projectRoot, context, scope) { // 생성 파일 계획을 구성한다.
const plannedFiles = []; // 생성 파일 목록을 초기화한다.
if (scope === 'all' || scope === 'backend') { // 백엔드 파일 생성 범위인지 확인한다.
plannedFiles.push({ relativePath: context.javaEntityPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.entity, context) }); // Entity 파일 계획을 추가한다.
plannedFiles.push({ relativePath: context.javaServicePath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.service, context) }); // Service 파일 계획을 추가한다.
plannedFiles.push({ relativePath: context.javaDaoPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.dao, context) }); // DAO 파일 계획을 추가한다.
plannedFiles.push({ relativePath: context.javaServiceImplPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.serviceImpl, context) }); // ServiceImpl 파일 계획을 추가한다.
plannedFiles.push({ relativePath: context.javaControllerPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.controller, context) }); // Controller 파일 계획을 추가한다.
} // 조건문을 종료한다.
if (scope === 'all' || scope === 'xml') { // XML 파일 생성 범위인지 확인한다.
plannedFiles.push({ relativePath: context.xmlMapperPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.mapper, context) }); // XML 파일 계획을 추가한다.
} // 조건문을 종료한다.
if (scope === 'all' || scope === 'frontend') { // 프론트 파일 생성 범위인지 확인한다.
plannedFiles.push({ relativePath: context.vuePagePath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.page, context) }); // Vue 파일 계획을 추가한다.
} // 조건문을 종료한다.
return plannedFiles; // 생성 파일 계획 목록을 반환한다.
} // 함수를 종료한다.
function previewFiles(projectRoot, outputRoot, files) { // preview 결과를 출력한다.
console.log('Preview mode enabled'); // preview 모드 시작 메시지를 출력한다.
for (const file of files) { // 파일 목록을 순회한다.
console.log(path.join(projectRoot, outputRoot, file.relativePath)); // 각 파일의 전체 경로를 출력한다.
} // 반복문을 종료한다.
} // 함수를 종료한다.
async function writeFiles(projectRoot, outputRoot, files, force) { // 파일들을 실제로 기록한다.
for (const file of files) { // 파일 목록을 순회한다.
const fullPath = path.join(projectRoot, outputRoot, file.relativePath); // 전체 저장 경로를 계산한다.
await fs.mkdir(path.dirname(fullPath), { recursive: true }); // 상위 디렉터리를 생성한다.
if (!force && (await fileExists(fullPath))) { // 강제 덮어쓰기가 아니고 파일이 이미 있는지 확인한다.
throw new Error(`Output path already exists. Use --force to overwrite: ${fullPath}`); // 파일 중복 예외를 발생시킨다.
} // 조건문을 종료한다.
await fs.writeFile(fullPath, file.content, 'utf8'); // 렌더링 결과를 UTF-8 로 저장한다.
} // 반복문을 종료한다.
} // 함수를 종료한다.
export async function runGenerator(projectRoot, options) { // 생성기 메인 흐름을 실행한다.
const config = await loadConfig(projectRoot); // 설정 파일과 환경 변수를 로드한다.
const metadata = await readMetadata(config, options.table); // DB 메타데이터를 조회한다.
validateMetadata(metadata); // 메타데이터를 검증한다.
const context = buildContext(config, options, metadata); // 템플릿 컨텍스트를 계산한다.
const outputRoot = options.output || config.paths.generatedRoot; // 출력 루트 경로를 결정한다.
const files = await buildFilePlan(projectRoot, context, options.scope || 'all'); // 생성 파일 계획을 만든다.
console.log(`Table found: ${metadata.schema}.${metadata.tableName}`); // 조회된 테이블 정보를 출력한다.
console.log(`Primary key: ${context.primaryKeyColumn.columnName}(${context.primaryKeyJavaType})`); // 기본키 정보를 출력한다.
console.log(`Files planned: ${files.length}`); // 생성 대상 파일 개수를 출력한다.
if (options.preview) { // preview 모드인지 확인한다.
previewFiles(projectRoot, outputRoot, files); // 파일 경로 미리보기를 출력한다.
return; // 파일 저장 없이 종료한다.
} // 조건문을 종료한다.
await writeFiles(projectRoot, outputRoot, files, options.force); // 렌더링된 파일들을 저장한다.
console.log(`Files generated: ${files.length}`); // 생성 완료 메시지를 출력한다.
} // 함수를 종료한다.
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
import path from 'node:path'; // 경로 유틸리티를 사용한다.
import { fileURLToPath } from 'node:url'; // 파일 URL 을 실제 경로로 변환한다.
import { runGenerator } from './generator.js'; // 생성기 실행 함수를 사용한다.
const HELP_TEXT = `
Board Generator CLI
Usage:
generate-board --table <table_name> [options]
Options:
--table <name> DB 테이블명
--module <path> 모듈 경로 예: sample/board
--entity <name> Entity 클래스명 예: SampleBoard
--menu-name <name> 화면 표시명 예: Sample Board
--output <path> 생성 루트 경로 예: . 또는 generated
--scope <scope> all | backend | xml | frontend
--preview 생성 경로만 미리 출력
--force 기존 파일 덮어쓰기 허용
--help 도움말 출력
`; // 도움말 문자열 정의를 종료한다.
function parseArgs(argv) { // 명령행 인자를 파싱한다.
const parsedOptions = {}; // 파싱 결과 객체를 초기화한다.
for (let index = 0; index < argv.length; index += 1) { // 전달된 인자 배열을 순회한다.
const currentArg = argv[index]; // 현재 인자를 저장한다.
if (currentArg === '--preview' || currentArg === '--force' || currentArg === '--help') { // 불리언 플래그 옵션인지 확인한다.
parsedOptions[currentArg.replace(/^--/, '')] = true; // 플래그 값을 true 로 저장한다.
continue; // 다음 인자로 이동한다.
} // 조건문을 종료한다.
if (!currentArg.startsWith('--')) { // 옵션 형식이 아닌 인자인지 확인한다.
continue; // 위치 인자는 무시하고 다음으로 이동한다.
} // 조건문을 종료한다.
const nextValue = argv[index + 1]; // 다음 값을 읽는다.
parsedOptions[currentArg.replace(/^--/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = nextValue; // 옵션 이름을 camelCase 키로 저장한다.
index += 1; // 값 인자를 소비했으므로 인덱스를 증가시킨다.
} // 반복문을 종료한다.
return parsedOptions; // 파싱 결과를 반환한다.
} // 함수를 종료한다.
async function main() { // CLI 메인 흐름을 실행한다.
const options = parseArgs(process.argv.slice(2)); // 명령행 인자를 파싱한다.
if (options.help || !options.table) { // 도움말 출력 조건인지 확인한다.
console.log(HELP_TEXT); // 도움말을 출력한다.
return; // 프로그램을 종료한다.
} // 조건문을 종료한다.
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); // 프로젝트 루트 경로를 계산한다.
await runGenerator(projectRoot, options); // 생성기 실행 함수를 호출한다.
} // 함수를 종료한다.
main().catch(error => { // 메인 실행 중 예외를 처리한다.
console.error(error.message); // 원인 중심 오류 메시지를 출력한다.
process.exitCode = 1; // 종료 코드를 실패로 설정한다.
}); // 예외 처리 구문을 종료한다.
@@ -0,0 +1,98 @@
package {{controllerPackage}};
import java.util.HashMap;
import java.util.Map;
{{#each controllerImports}}
import {{this}};
{{/each}}
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import {{rootPackage}}.{{serviceName}};
import {{entityFqcn}};
import io.swagger.v3.oas.annotations.Operation;
import lombok.extern.log4j.Log4j2;
@RestController
@RequestMapping("{{requestBasePath}}")
@Log4j2
public class {{controllerName}} {
private final {{serviceName}} {{entityVarName}}Service;
public {{controllerName}}({{serviceName}} {{entityVarName}}Service) {
this.{{entityVarName}}Service = {{entityVarName}}Service;
}
@Operation(summary = "page 초기정보.")
@PostMapping(value = "list/main.do", params = "method=page")
public Map<String, Object> page(@RequestBody Map<String, Object> params) throws Exception {
Map<String, Object> result = new HashMap<String, Object>();
result.put("menuName", "{{menuName}}");
result.put("result", {{entityVarName}}Service.get{{entityName}}List());
return result;
}
@Operation(summary = "{{menuName}} 목록 조회")
@PostMapping(value = "list/main.do", params = "method=search")
public Map<String, Object> list_search(@RequestBody Map<String, Object> params) throws Exception {
Map<String, Object> result = new HashMap<String, Object>();
result.put("result", {{entityVarName}}Service.get{{entityName}}List());
return result;
}
@Operation(summary = "{{menuName}} 상세 조회")
@PostMapping(value = "item/main.do", params = "method=get{{entityName}}")
public Map<String, Object> item_get{{entityName}}(@RequestBody Map<String, Object> params) throws Exception {
Map<String, Object> result = new HashMap<String, Object>();
result.put("result", {{entityVarName}}Service.get{{entityName}}(getPrimaryKey(params)));
return result;
}
@Operation(summary = "{{menuName}} 등록")
@PostMapping(value = "item/main.do", params = "method=regist")
public Map<String, Object> item_regist(@RequestBody {{entityName}} {{entityVarName}}) throws Exception {
Map<String, Object> result = new HashMap<String, Object>();
result.put("result", {{entityVarName}}Service.create{{entityName}}({{entityVarName}}));
return result;
}
@Operation(summary = "{{menuName}} 수정")
@PostMapping(value = "item/main.do", params = "method=update")
public Map<String, Object> item_update(@RequestBody {{entityName}} {{entityVarName}}) throws Exception {
if ({{entityVarName}}.{{primaryKeyGetterName}}() == null) {
throw new IllegalArgumentException("{{primaryKeyProperty}} 값은 필수입니다.");
}
Map<String, Object> result = new HashMap<String, Object>();
result.put("result", {{entityVarName}}Service.update{{entityName}}({{entityVarName}}));
return result;
}
@Operation(summary = "{{menuName}} 삭제")
@PostMapping(value = "item/main.do", params = "method=delete")
public String item_delete(@RequestBody Map<String, Object> params) throws Exception {
{{entityVarName}}Service.delete{{entityName}}(getPrimaryKey(params));
return "success";
}
private {{primaryKeyJavaType}} getPrimaryKey(Map<String, Object> params) {
if (params == null || params.get("{{primaryKeyProperty}}") == null) {
throw new IllegalArgumentException("{{primaryKeyProperty}} 값은 필수입니다.");
}
String value = params.get("{{primaryKeyProperty}}").toString();
return {{primaryKeyParseExpression}};
}
}
+46
View File
@@ -0,0 +1,46 @@
package {{daoPackage}};
import java.util.List;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Repository;
import {{entityFqcn}};
@Repository
public class {{daoName}} {
private static final String MAPPER = "{{mapperNamespace}}.";
private final SqlSession sqlSession;
public {{daoName}}(SqlSession sqlSession) {
this.sqlSession = sqlSession;
}
public List<{{entityName}}> select{{entityName}}List() {
return sqlSession.selectList(MAPPER + "select{{entityName}}List");
}
public {{entityName}} select{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}) {
return sqlSession.selectOne(MAPPER + "select{{entityName}}", {{primaryKeyProperty}});
}
public void insert{{entityName}}({{entityName}} {{entityVarName}}) {
sqlSession.insert(MAPPER + "insert{{entityName}}", {{entityVarName}});
}
public void update{{entityName}}({{entityName}} {{entityVarName}}) {
sqlSession.update(MAPPER + "update{{entityName}}", {{entityVarName}});
}
public void delete{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}) {
sqlSession.delete(MAPPER + "delete{{entityName}}", {{primaryKeyProperty}});
}
}
@@ -0,0 +1,15 @@
package {{entityPackage}};
{{#each entityImports}}
import {{this}};
{{/each}}
import lombok.Data;
@Data
public class {{entityName}} {
{{#each columns}}
private {{javaType}} {{propertyName}};
{{/each}}
}
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="{{mapperNamespace}}">
<resultMap id="{{entityVarName}}ResultMap" type="{{entityFqcn}}">
{{#each columns}}
{{#if primaryKey}}<id property="{{propertyName}}" column="{{columnName}}"/>{{else}}<result property="{{propertyName}}" column="{{columnName}}"/>{{/if}}
{{/each}}
</resultMap>
<select id="select{{entityName}}List" resultMap="{{entityVarName}}ResultMap">
SELECT {{#each columns}}{{columnName}}{{#unless @last}}, {{/unless}}{{/each}}
FROM {{metadata.schema}}.{{tableName}}
ORDER BY {{primaryKeyColumn.columnName}} DESC
</select>
<select id="select{{entityName}}" parameterType="{{primaryKeyMyBatisType}}" resultMap="{{entityVarName}}ResultMap">
SELECT {{#each columns}}{{columnName}}{{#unless @last}}, {{/unless}}{{/each}}
FROM {{metadata.schema}}.{{tableName}}
WHERE {{primaryKeyColumn.columnName}} = #{{'{'}}{{primaryKeyProperty}}{{'}'}}
</select>
<insert id="insert{{entityName}}" parameterType="{{entityFqcn}}"{{#if primaryKeyColumn.autoIncrement}} useGeneratedKeys="true" keyProperty="{{primaryKeyProperty}}"{{/if}}>
INSERT INTO {{metadata.schema}}.{{tableName}} (
{{#each insertColumns}}
{{columnName}}{{#unless @last}},{{/unless}}
{{/each}}
)
VALUES (
{{#each insertColumns}}
#{{'{'}}{{propertyName}}{{'}'}}{{#unless @last}},{{/unless}}
{{/each}}
)
</insert>
<update id="update{{entityName}}" parameterType="{{entityFqcn}}">
UPDATE {{metadata.schema}}.{{tableName}}
SET
{{#each updateColumns}}
{{columnName}} = #{{'{'}}{{propertyName}}{{'}'}}{{#unless @last}},{{/unless}}
{{/each}}
WHERE {{primaryKeyColumn.columnName}} = #{{'{'}}{{primaryKeyProperty}}{{'}'}}
</update>
<delete id="delete{{entityName}}" parameterType="{{primaryKeyMyBatisType}}">
DELETE FROM {{metadata.schema}}.{{tableName}}
WHERE {{primaryKeyColumn.columnName}} = #{{'{'}}{{primaryKeyProperty}}{{'}'}}
</delete>
</mapper>
@@ -0,0 +1,219 @@
<template>
<div class="ui--content-wrapper">
<sdl-breadcrumb></sdl-breadcrumb>
<template v-if="isDetailView">
<div class="ui--list-heading clearfix">
<span class="ui--text-total"><strong>{{menuName}}</strong> {{{detailModeExpression}}}</span>
<div class="float-end">
<button type="button" class="btn btn-secondary btn-sm" @click="moveToList()">목록</button>
</div>
</div>
<div class="ui--form-container">
{{#each formColumns}}
<div class="mb-3">
<label>{{label}}</label>
{{#if isTextarea}}
<textarea v-model.trim="form.{{propertyName}}" class="form-control" rows="6"></textarea>
{{else}}
<input v-model.trim="form.{{propertyName}}" type="text" class="form-control" />
{{/if}}
</div>
{{/each}}
<div class="ui--button-container">
<button type="button" class="btn btn-secondary btn-lg" @click="moveToList()">목록</button>
<button type="button" class="btn btn-primary btn-lg" @click="saveItem()">저장</button>
<button v-if="mode === 'EDIT'" type="button" class="btn btn-danger btn-lg" @click="deleteItem()">삭제</button>
</div>
</div>
</template>
<template v-else>
<div class="ui--list-heading clearfix">
<span class="ui--text-total"><strong>Total</strong> {{{totalCountExpression}}}</span>
<div class="float-end">
<button type="button" class="btn btn-primary btn-sm" @click="moveToCreate()">새 글</button>
</div>
</div>
<table class="table table-bordered ui--table">
<thead>
<tr>
{{#each listColumns}}
<th scope="col">{{label}}</th>
{{/each}}
</tr>
</thead>
<tbody>
<tr v-if="itemList.length === 0">
<td :colspan="{{listColumns.length}}" class="text-center">등록된 데이터가 없습니다.</td>
</tr>
<tr v-for="item in itemList" :key="item.{{primaryKeyProperty}}">
{{#each listColumns}}
<td class="text-center">
{{#if @first}}
<button type="button" class="btn btn-link ui--board-link" @click="openDetail(item.{{propertyName}})">{{{vueItemExpression}}}</button>
{{else}}
{{{vueItemExpression}}}
{{/if}}
</td>
{{/each}}
</tr>
</tbody>
</table>
</template>
</div>
</template>
<script>
import axios from 'axios';
import SDLUtil from '@/utils/SDLUtil';
export default {
name: '{{vueComponentName}}',
data() {
return {
itemList: [],
mode: 'CREATE',
form: this.createEmptyForm(),
};
},
computed: {
isDetailView() {
return Boolean(this.getRouteDetailId()) || this.$route.query.mode === 'create';
},
},
watch: {
'$route.query': {
handler() {
this.syncRouteState();
},
deep: true,
},
},
methods: {
createEmptyForm() {
return {
{{primaryKeyProperty}}: null,
{{#each formColumns}}
{{propertyName}}: '',
{{/each}}
};
},
init() {
this.fetchList();
this.syncRouteState();
},
getRouteDetailId() {
const detailId = this.$route.query.detailId;
return detailId || null;
},
syncRouteState() {
const detailId = this.getRouteDetailId();
if (detailId) {
this.fetchDetail(detailId);
return;
}
if (this.$route.query.mode === 'create') {
this.mode = 'CREATE';
this.form = this.createEmptyForm();
return;
}
this.mode = 'CREATE';
this.form = this.createEmptyForm();
},
async fetchList() {
SDLUtil.showLoadingBar(true);
try {
const { data } = await axios.post(`${SDLUtil.API_URL}{{requestBasePath}}list/main.do?method=search`, JSON.stringify({}), {
headers: { 'Content-Type': 'application/json' },
});
this.itemList = data.result || [];
} catch (error) {
SDLUtil.errorAlert(error);
} finally {
SDLUtil.showLoadingBar(false);
}
},
async fetchDetail(detailId) {
SDLUtil.showLoadingBar(true);
try {
const { data } = await axios.post(`${SDLUtil.API_URL}{{requestBasePath}}item/main.do?method=get{{entityName}}`, JSON.stringify({ {{primaryKeyProperty}}: detailId }), {
headers: { 'Content-Type': 'application/json' },
});
this.form = {
...this.createEmptyForm(),
...(data.result || {}),
};
this.mode = 'EDIT';
} catch (error) {
SDLUtil.errorAlert(error);
} finally {
SDLUtil.showLoadingBar(false);
}
},
moveToCreate() {
this.$router.push({ path: this.$route.path, query: { mode: 'create' } });
},
moveToList() {
this.$router.push({ path: this.$route.path, query: {} });
},
openDetail(detailId) {
this.$router.push({ path: this.$route.path, query: { detailId } });
},
async saveItem() {
SDLUtil.showLoadingBar(true);
try {
const requestUrl = this.mode === 'EDIT'
? `${SDLUtil.API_URL}{{requestBasePath}}item/main.do?method=update`
: `${SDLUtil.API_URL}{{requestBasePath}}item/main.do?method=regist`;
const { data } = await axios.post(requestUrl, JSON.stringify(this.form), {
headers: { 'Content-Type': 'application/json' },
});
await this.fetchList();
if (data.result && data.result.{{primaryKeyProperty}}) {
this.$router.replace({ path: this.$route.path, query: { detailId: data.result.{{primaryKeyProperty}} } });
}
SDLUtil.alert('저장되었습니다.');
} catch (error) {
SDLUtil.errorAlert(error);
} finally {
SDLUtil.showLoadingBar(false);
}
},
deleteItem() {
if (!this.form.{{primaryKeyProperty}}) {
return;
}
SDLUtil.confirm({
msg: '삭제하시겠습니까?',
onOkEvt: () => this.doDelete(),
});
},
async doDelete() {
SDLUtil.showLoadingBar(true);
try {
await axios.post(`${SDLUtil.API_URL}{{requestBasePath}}item/main.do?method=delete`, JSON.stringify({ {{primaryKeyProperty}}: this.form.{{primaryKeyProperty}} }), {
headers: { 'Content-Type': 'application/json' },
});
await this.fetchList();
this.moveToList();
SDLUtil.alert('삭제되었습니다.');
} catch (error) {
SDLUtil.errorAlert(error);
} finally {
SDLUtil.showLoadingBar(false);
}
},
},
mounted() {
this.$nextTick(() => {
this.init();
});
},
};
</script>
<style scoped>
.ui--board-link {
cursor: pointer;
padding: 0;
}
</style>
@@ -0,0 +1,18 @@
package {{rootPackage}};
import java.util.List;
import {{entityFqcn}};
public interface {{serviceName}} {
List<{{entityName}}> get{{entityName}}List();
{{entityName}} get{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}});
{{entityName}} create{{entityName}}({{entityName}} {{entityVarName}});
{{entityName}} update{{entityName}}({{entityName}} {{entityVarName}});
void delete{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}});
}
@@ -0,0 +1,52 @@
package {{implPackage}};
import java.util.List;
import org.springframework.stereotype.Service;
import {{rootPackage}}.{{serviceName}};
import {{daoPackage}}.{{daoName}};
import {{entityFqcn}};
@Service
public class {{serviceImplName}} implements {{serviceName}} {
private final {{daoName}} {{entityVarName}}Dao;
public {{serviceImplName}}({{daoName}} {{entityVarName}}Dao) {
this.{{entityVarName}}Dao = {{entityVarName}}Dao;
}
@Override
public List<{{entityName}}> get{{entityName}}List() {
return {{entityVarName}}Dao.select{{entityName}}List();
}
@Override
public {{entityName}} get{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}) {
return {{entityVarName}}Dao.select{{entityName}}({{primaryKeyProperty}});
}
@Override
public {{entityName}} create{{entityName}}({{entityName}} {{entityVarName}}) {
{{entityVarName}}Dao.insert{{entityName}}({{entityVarName}});
return {{entityVarName}}Dao.select{{entityName}}({{entityVarName}}.{{primaryKeyGetterName}}());
}
@Override
public {{entityName}} update{{entityName}}({{entityName}} {{entityVarName}}) {
{{entityVarName}}Dao.update{{entityName}}({{entityVarName}});
return {{entityVarName}}Dao.select{{entityName}}({{entityVarName}}.{{primaryKeyGetterName}}());
}
@Override
public void delete{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}) {
{{entityVarName}}Dao.delete{{entityName}}({{primaryKeyProperty}});
}
}