import * as fs from 'fs/promises'
import * as path from 'path'
import * as vscode from 'vscode'
type CryptoTarget = {
tableName: string
plainColumn: string
cryptoType: string
encColumn: string
hashColumn: string
javaProperty: string
encryptTypeHandler: string
hashTypeHandler: string
}
type ConvertRule = {
before: string
tobe: string
tableMustExist: boolean
}
type ConvertPlaceholder = {
key: string
htype?: string
camelHtype?: string
}
type ConvertCaptureResult = {
captured: Map
targets: CryptoTarget[]
}
type ResultMapEntry = {
kind: 'id' | 'result'
column: string
property: string
typeHandler?: string
}
type ResultMapDefinition = {
id: string
type: string
entries: ResultMapEntry[]
}
type MapperItem = {
tag: 'insert' | 'update' | 'select' | 'delete'
id: string
attrs: string
text: string
}
type MapperItemRange = {
start: number
end: number
text: string
}
const LEGACY_DECRYPT_TARGET_COMMENT_PATTERN = String.raw`/\*\s*복호화 대상\s*\*/`
const DECRYPT_TARGET_COMMENT_PATTERN = String.raw`/\* \[KMS-CIPHER\]\s*수정일자:\s*\d{4}-\d{2}-\d{2}\s*수정자\s*:\s*[^*]*\*/`
const CRYPTO_TARGET_CHECK_COMMENT = '/* @@@ 암호화 대상 확인 필요 @@@ */'
const CONVERT_XML_PATH = 'c:/xml/convert.xml'
const SQL_RESERVED_WORDS = new Set([
'AND',
'AS',
'ASC',
'BETWEEN',
'BY',
'CASE',
'CONNECT',
'CROSS',
'DESC',
'ELSE',
'END',
'FROM',
'FULL',
'GROUP',
'HAVING',
'INNER',
'JOIN',
'LEFT',
'LIKE',
'NOT',
'ON',
'OR',
'ORDER',
'OUTER',
'RIGHT',
'START',
'THEN',
'UNION',
'WHEN',
'WHERE',
'WITH',
])
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand('cryptoMapper.copyTypeHandlerRecommendation', async () => {
try {
await applySelectedSelectColumnTypeHandlerRecommendation()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper 추천 생성 실패: ${message}`)
}
}),
vscode.commands.registerCommand('cryptoMapper.copyFullFileTypeHandlerRecommendation', async () => {
try {
await copyFullFileTypeHandlerRecommendation()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper full XML recommendation failed: ${message}`)
}
}),
vscode.commands.registerCommand('cryptoMapper.markCryptoTargetColumns', async () => {
try {
await markCryptoTargetColumnsInFullFile()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper target mark failed: ${message}`)
}
}),
vscode.commands.registerCommand('cryptoMapper.markCryptoTargetColumnsInCurrentItem', async () => {
try {
await markCryptoTargetColumnsInCurrentItem()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper current item target mark failed: ${message}`)
}
}),
vscode.commands.registerCommand('cryptoMapper.applyTypeHandlerRecommendationInCurrentItem', async () => {
try {
await applyFocusedMapperItemTypeHandlerRecommendation()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper current item conversion failed: ${message}`)
}
}),
vscode.commands.registerCommand('cryptoMapper.applyConvertXmlRulesToSelection', async () => {
try {
await applyConvertXmlRulesToSelection()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper convert.xml replace failed: ${message}`)
}
}),
vscode.commands.registerCommand('cryptoMapper.markSelectedColumnsHashInfo', async () => {
try {
await markSelectedColumnsHashInfo()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper hash column info mark failed: ${message}`)
}
}),
vscode.commands.registerCommand('cryptoMapper.convertSelectionToLowerCamel', async () => {
try {
await replaceSelections((text) => toCamel(text), 'lower camel')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper lower camel conversion failed: ${message}`)
}
}),
vscode.commands.registerCommand('cryptoMapper.convertSelectionToUpperSnake', async () => {
try {
await replaceSelections((text) => toUpperSnake(text), 'upper snake')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper upper snake conversion failed: ${message}`)
}
}),
)
}
export function deactivate() {
// no-op
}
async function replaceSelections(convert: (text: string) => string, label: string) {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('No active editor.')
const selections = editor.selections.filter((selection) => !selection.isEmpty)
if (selections.length === 0) throw new Error(`Select text before running ${label} conversion.`)
await editor.edit((editBuilder) => {
for (const selection of selections) {
editBuilder.replace(selection, convert(editor.document.getText(selection)))
}
})
vscode.window.showInformationMessage(`Crypto Mapper ${label} conversion completed. changed selections: ${selections.length}`)
}
async function applySelectedSelectColumnTypeHandlerRecommendation() {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('No active editor.')
if (editor.selection.isEmpty) throw new Error('Ctrl+Shift+4 needs a selected range inside a mapper item.')
const originalXml = editor.document.getText()
const selectionStart = editor.document.offsetAt(editor.selection.start)
const selectionEnd = editor.document.offsetAt(editor.selection.end)
const focusedItem = findMapperItemRangeAtOffset(originalXml, selectionStart)
if (!focusedItem || selectionEnd > focusedItem.end) {
throw new Error('Select a range inside one mapper item, then press Ctrl+Shift+4.')
}
const mapperItem = parseSelectedMapperItem(focusedItem.text)
const workspaceRoot = getWorkspaceRoot(editor.document.uri)
const dbIniPath = await findFirstFile(workspaceRoot, 'db.ini')
if (!dbIniPath) throw new Error(`db.ini not found under workspace root: ${workspaceRoot}`)
const cryptoTargetsPath = await readCryptoTargetsPath(dbIniPath, workspaceRoot)
const markerUserId = await readDbIniUserId(dbIniPath)
const targets = await readCryptoTargets(cryptoTargetsPath)
const date = formatLocalDate(new Date())
const relativeSelectionStart = selectionStart - focusedItem.start
const relativeSelectionEnd = selectionEnd - focusedItem.start
const expandedFocusedItemText = expandMapperItemIncludesForTargets(focusedItem.text, originalXml, targets, mapperItem.tag).text
if (mapperItem.tag !== 'select' || !isRangeInsideSelectColumnRange(focusedItem.text, relativeSelectionStart, relativeSelectionEnd)) {
throw new Error('Ctrl+Shift+4 supports only a selected range inside the SELECT column list. Use Ctrl+Shift+1 for the whole mapper item.')
}
const selectedText = focusedItem.text.slice(relativeSelectionStart, relativeSelectionEnd)
const expandedSelectedText = expandIncludeTagsInTextForTargets(selectedText, focusedItem.text, originalXml, targets, mapperItem.tag)
const selectedRewrite = rewriteSelectedSelectColumns(expandedFocusedItemText, expandedSelectedText, targets, date, markerUserId)
if (selectedRewrite.changedTargets.length === 0) {
if (selectedRewrite.text !== selectedText) {
await editor.edit((editBuilder) => {
editBuilder.replace(editor.selection, selectedRewrite.text)
})
vscode.window.showInformationMessage('Crypto Mapper selected-column conversion: target check comments removed.')
return
}
vscode.window.showInformationMessage('Crypto Mapper selected-column conversion: no matching crypto target column found in selection.')
return
}
const itemWithSelectionRewrite = [
focusedItem.text.slice(0, relativeSelectionStart),
selectedRewrite.text,
focusedItem.text.slice(relativeSelectionEnd),
].join('')
let desiredResultMap = desiredResultMapDefinitionForTargets(mapperItem, itemWithSelectionRewrite, originalXml, selectedRewrite.changedTargets)
const existingResultMap = findExistingResultMap(originalXml, desiredResultMap)
if (!existingResultMap) {
desiredResultMap = {
...desiredResultMap,
id: uniqueResultMapId(desiredResultMap.id, originalXml),
}
}
const resultMapId = existingResultMap?.id || desiredResultMap.id
const updatedItem = applySelectResultMap(itemWithSelectionRewrite, resultMapId)
const insertOffset = lineStartOffset(originalXml, focusedItem.start)
const itemRange = new vscode.Range(
editor.document.positionAt(insertOffset),
editor.document.positionAt(focusedItem.end),
)
const lineIndent = originalXml.slice(insertOffset, focusedItem.start)
const resultMapInsertText = existingResultMap ? '' : `${indentResultMapSnippet(resultMapSnippet(desiredResultMap), lineIndent)}\n\n`
await editor.edit((editBuilder) => {
editBuilder.replace(itemRange, `${resultMapInsertText}${lineIndent}${updatedItem}`)
})
vscode.window.showInformationMessage(
`Crypto Mapper selected-column conversion completed. changed columns: ${selectedRewrite.changedTargets.length}, resultMap: ${resultMapId}`,
)
}
async function applyFocusedMapperItemTypeHandlerRecommendation() {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('No active editor.')
const originalXml = editor.document.getText()
const focusedItem = findMapperItemRangeAtOffset(originalXml, editor.document.offsetAt(editor.selection.active))
if (!focusedItem) {
vscode.window.showInformationMessage('Crypto Mapper current item conversion: cursor is not inside a mapper item.')
return
}
const mapperItem = parseSelectedMapperItem(focusedItem.text)
const workspaceRoot = getWorkspaceRoot(editor.document.uri)
const dbIniPath = await findFirstFile(workspaceRoot, 'db.ini')
if (!dbIniPath) throw new Error(`db.ini not found under workspace root: ${workspaceRoot}`)
const cryptoTargetsPath = await readCryptoTargetsPath(dbIniPath, workspaceRoot)
const markerUserId = await readDbIniUserId(dbIniPath)
const targets = await readCryptoTargets(cryptoTargetsPath)
const date = formatLocalDate(new Date())
await applyCurrentMapperItemTypeHandlerRecommendation(
editor,
originalXml,
focusedItem,
mapperItem,
targets,
date,
markerUserId,
)
}
async function applyCurrentMapperItemTypeHandlerRecommendation(
editor: vscode.TextEditor,
originalXml: string,
focusedItem: MapperItemRange,
mapperItem: MapperItem,
targets: CryptoTarget[],
date: string,
markerUserId: string,
) {
const expandedItemText = expandMapperItemIncludesForTargets(focusedItem.text, originalXml, targets, mapperItem.tag).text
const matchedTargets = targets.filter((target) => matchesTarget(expandedItemText, target, mapperItem.tag))
if (matchedTargets.length === 0) {
vscode.window.showInformationMessage('Crypto Mapper current item conversion: no matching crypto target found in current mapper item.')
return
}
const rewrite = rewriteMapperItemForFullFile(mapperItem, expandedItemText, originalXml, matchedTargets, date, markerUserId)
if (rewrite.afterSql === focusedItem.text && rewrite.supportSnippets.length === 0) {
vscode.window.showInformationMessage('Crypto Mapper current item conversion: no rewrite point found in current mapper item.')
return
}
const insertOffset = lineStartOffset(originalXml, focusedItem.start)
const itemRange = new vscode.Range(
editor.document.positionAt(insertOffset),
editor.document.positionAt(focusedItem.end),
)
const lineIndent = originalXml.slice(insertOffset, focusedItem.start)
const resultMapInsertText = rewrite.supportSnippets.length === 0
? ''
: `${rewrite.supportSnippets.map((snippet) => indentResultMapSnippet(snippet, lineIndent)).join('\n\n')}\n\n`
await editor.edit((editBuilder) => {
editBuilder.replace(itemRange, `${resultMapInsertText}${lineIndent}${rewrite.afterSql}`)
})
vscode.window.showInformationMessage(
`Crypto Mapper current item conversion completed. changed targets: ${matchedTargets.length}, resultMaps: ${rewrite.supportSnippets.length}`,
)
}
async function copyTypeHandlerRecommendation() {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('열려 있는 editor가 없습니다.')
if (editor.selection.isEmpty) throw new Error('mapper item 전체를 선택한 뒤 Ctrl+Shift+5를 눌러주세요.')
const selectedText = editor.document.getText(editor.selection)
const mapperItem = parseSelectedMapperItem(selectedText)
const workspaceRoot = getWorkspaceRoot(editor.document.uri)
const dbIniPath = await findFirstFile(workspaceRoot, 'db.ini')
if (!dbIniPath) throw new Error(`프로젝트 루트 아래에서 db.ini를 찾지 못했습니다: ${workspaceRoot}`)
const cryptoTargetsPath = await readCryptoTargetsPath(dbIniPath, workspaceRoot)
const markerUserId = await readDbIniUserId(dbIniPath)
const targets = await readCryptoTargets(cryptoTargetsPath)
const fullDocumentText = editor.document.getText()
const expandedSelectedText = expandMapperItemIncludesForTargets(selectedText, fullDocumentText, targets, mapperItem.tag).text
const matchedTargets = targets.filter((target) => matchesTarget(expandedSelectedText, target, mapperItem.tag))
if (matchedTargets.length === 0) {
throw new Error('선택 영역에서 crypto-targets.json의 테이블명과 컬럼명이 함께 경계 일치하는 대상을 찾지 못했습니다.')
}
const date = formatLocalDate(new Date())
const recommendation = buildRecommendation(mapperItem, expandedSelectedText, fullDocumentText, matchedTargets, date, markerUserId)
await vscode.env.clipboard.writeText(recommendation.clipboardText)
vscode.window.showInformationMessage(
`Crypto Mapper 추천을 클립보드에 복사했습니다. 대상 ${matchedTargets.length}개, ${recommendation.supportSnippets.length}개 보조 snippet.`,
)
}
async function copyFullFileTypeHandlerRecommendation() {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('No active editor.')
const workspaceRoot = getWorkspaceRoot(editor.document.uri)
const dbIniPath = await findFirstFile(workspaceRoot, 'db.ini')
if (!dbIniPath) throw new Error(`db.ini not found under workspace root: ${workspaceRoot}`)
const cryptoTargetsPath = await readCryptoTargetsPath(dbIniPath, workspaceRoot)
const markerUserId = await readDbIniUserId(dbIniPath)
const targets = await readCryptoTargets(cryptoTargetsPath)
const originalXml = editor.document.getText()
const date = formatLocalDate(new Date())
const recommendation = buildFullFileRecommendation(originalXml, targets, date, markerUserId)
await vscode.env.clipboard.writeText(recommendation.clipboardText)
vscode.window.showInformationMessage(
`Crypto Mapper full XML recommendation copied. changed mapper items: ${recommendation.changedItemCount}, resultMaps: ${recommendation.supportSnippets.length}`,
)
}
async function markCryptoTargetColumnsInFullFile() {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('No active editor.')
const workspaceRoot = getWorkspaceRoot(editor.document.uri)
const dbIniPath = await findFirstFile(workspaceRoot, 'db.ini')
if (!dbIniPath) throw new Error(`db.ini not found under workspace root: ${workspaceRoot}`)
const cryptoTargetsPath = await readCryptoTargetsPath(dbIniPath, workspaceRoot)
const targets = await readCryptoTargets(cryptoTargetsPath)
const originalXml = editor.document.getText()
const result = markCryptoTargetColumns(originalXml, targets)
if (result.changedCount === 0) {
vscode.window.showInformationMessage('Crypto Mapper target mark: no matching mapper item found.')
return
}
const fullRange = new vscode.Range(
editor.document.positionAt(0),
editor.document.positionAt(originalXml.length),
)
await editor.edit((editBuilder) => {
editBuilder.replace(fullRange, result.text)
})
vscode.window.showInformationMessage(`Crypto Mapper target mark completed. changed columns: ${result.changedCount}`)
}
async function markCryptoTargetColumnsInCurrentItem() {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('No active editor.')
const originalXml = editor.document.getText()
const focusedItem = findMapperItemRangeAtOffset(originalXml, editor.document.offsetAt(editor.selection.active))
if (!focusedItem) {
vscode.window.showInformationMessage('Crypto Mapper current item target mark: cursor is not inside a mapper item.')
return
}
const workspaceRoot = getWorkspaceRoot(editor.document.uri)
const dbIniPath = await findFirstFile(workspaceRoot, 'db.ini')
if (!dbIniPath) throw new Error(`db.ini not found under workspace root: ${workspaceRoot}`)
const cryptoTargetsPath = await readCryptoTargetsPath(dbIniPath, workspaceRoot)
const targets = await readCryptoTargets(cryptoTargetsPath)
const result = markCryptoTargetColumnsInMapperItem(focusedItem.text, targets, originalXml)
if (result.changedCount === 0) {
vscode.window.showInformationMessage('Crypto Mapper current item target mark: no matching target found in current mapper item.')
return
}
const itemRange = new vscode.Range(
editor.document.positionAt(focusedItem.start),
editor.document.positionAt(focusedItem.end),
)
await editor.edit((editBuilder) => {
editBuilder.replace(itemRange, result.text)
})
vscode.window.showInformationMessage(`Crypto Mapper current item target mark completed. changed columns: ${result.changedCount}`)
}
async function applyConvertXmlRulesToSelection() {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('No active editor.')
if (editor.selection.isEmpty) throw new Error('Ctrl+Shift+4 convert.xml replace needs a selected range.')
const selectedText = editor.document.getText(editor.selection)
if (selectedText.includes('작업완료')) {
vscode.window.showInformationMessage('Crypto Mapper convert.xml replace skipped: selection already contains 작업완료.')
return
}
const workspaceRoot = getWorkspaceRoot(editor.document.uri)
const dbIniPath = await findFirstFile(workspaceRoot, 'db.ini')
if (!dbIniPath) throw new Error(`db.ini not found under workspace root: ${workspaceRoot}`)
const cryptoTargetsPath = await readCryptoTargetsPath(dbIniPath, workspaceRoot)
const markerUserId = await readDbIniUserId(dbIniPath)
const targets = await readCryptoTargets(cryptoTargetsPath)
const rules = await readConvertRules(CONVERT_XML_PATH)
const result = applyConvertRules(selectedText, rules, targets, new Date(), markerUserId)
if (result.changedCount === 0) {
vscode.window.showInformationMessage('Crypto Mapper convert.xml replace: no matching text found in selection.')
return
}
await editor.edit((editBuilder) => {
editBuilder.replace(editor.selection, result.text)
})
vscode.window.showInformationMessage(`Crypto Mapper convert.xml replace completed. changed: ${result.changedCount}`)
}
async function markSelectedColumnsHashInfo() {
const editor = vscode.window.activeTextEditor
if (!editor) throw new Error('No active editor.')
if (editor.selection.isEmpty) throw new Error('Ctrl+Shift+6 needs a selected range.')
const workspaceRoot = getWorkspaceRoot(editor.document.uri)
const dbIniPath = await findFirstFile(workspaceRoot, 'db.ini')
if (!dbIniPath) throw new Error(`db.ini not found under workspace root: ${workspaceRoot}`)
const cryptoTargetsPath = await readCryptoTargetsPath(dbIniPath, workspaceRoot)
const targets = await readCryptoTargets(cryptoTargetsPath)
const selectedText = editor.document.getText(editor.selection)
const result = markHashColumnInfoInText(selectedText, targets)
if (result.changedCount === 0) {
vscode.window.showInformationMessage('Crypto Mapper hash column info mark: no matching column found in selection.')
return
}
await editor.edit((editBuilder) => {
editBuilder.replace(editor.selection, result.text)
})
vscode.window.showInformationMessage(`Crypto Mapper hash column info mark completed. changed columns: ${result.changedCount}`)
}
function getWorkspaceRoot(uri: vscode.Uri) {
const folder = vscode.workspace.getWorkspaceFolder(uri) ?? vscode.workspace.workspaceFolders?.[0]
if (!folder) throw new Error('VS Code workspace folder가 없습니다.')
return folder.uri.fsPath
}
async function findFirstFile(root: string, fileName: string): Promise {
const queue = [root]
const ignored = new Set(['.git', 'node_modules', 'dist', 'out', '.next', 'target', 'build'])
while (queue.length > 0) {
const current = queue.shift()!
let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean }>
try {
entries = await fs.readdir(current, { withFileTypes: true })
} catch {
continue
}
const file = entries.find((entry) => entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase())
if (file) return path.join(current, file.name)
for (const entry of entries) {
if (entry.isDirectory() && !ignored.has(entry.name)) {
queue.push(path.join(current, entry.name))
}
}
}
return undefined
}
async function readCryptoTargetsPath(dbIniPath: string, workspaceRoot: string) {
const text = await fs.readFile(dbIniPath, 'utf8')
const targetPath = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && !line.startsWith(';'))
.map((line) => line.match(/^targetpath\s*=\s*(.+)$/i)?.[1]?.trim())
.find(Boolean)
if (!targetPath) throw new Error(`${dbIniPath} 파일에 targetpath 값이 없습니다.`)
const unquoted = targetPath.replace(/^['"]|['"]$/g, '')
const candidates = path.isAbsolute(unquoted)
? [unquoted]
: [path.resolve(path.dirname(dbIniPath), unquoted), path.resolve(workspaceRoot, unquoted)]
for (const candidate of candidates) {
try {
const stat = await fs.stat(candidate)
if (stat.isFile()) return candidate
if (stat.isDirectory()) return path.join(candidate, 'crypto-targets.json')
} catch {
// try next
}
}
throw new Error(`targetpath 경로를 찾지 못했습니다: ${unquoted}`)
}
async function readDbIniUserId(dbIniPath: string) {
const text = await fs.readFile(dbIniPath, 'utf8')
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && !line.startsWith(';'))
.map((line) => line.match(/^userid\s*=\s*(.+)$/i)?.[1]?.trim())
.find(Boolean)
?.replace(/^['"]|['"]$/g, '') ?? ''
}
async function readCryptoTargets(filePath: string): Promise {
const text = await fs.readFile(filePath, 'utf8')
const parsed = JSON.parse(text) as Partial[]
if (!Array.isArray(parsed)) throw new Error(`crypto-targets.json 형식이 배열이 아닙니다: ${filePath}`)
return parsed
.map((target) => ({
tableName: normalizeRequired(target.tableName),
plainColumn: normalizeRequired(target.plainColumn),
cryptoType: normalizeOptional(target.cryptoType),
encColumn: normalizeRequired(target.encColumn),
hashColumn: normalizeOptional(target.hashColumn),
javaProperty: normalizeOptional(target.javaProperty) || toCamel(normalizeRequired(target.plainColumn)),
encryptTypeHandler: normalizeRequired(target.encryptTypeHandler),
hashTypeHandler: normalizeOptional(target.hashTypeHandler),
}))
.filter((target) => target.tableName && target.plainColumn && target.encColumn && target.encryptTypeHandler)
}
function normalizeRequired(value: unknown) {
return String(value ?? '').trim()
}
async function readConvertRules(filePath: string): Promise {
const text = await fs.readFile(filePath, 'utf8')
const rules: ConvertRule[] = []
const itemPattern = /- ]*>([\s\S]*?)<\/item>/gi
for (const itemMatch of text.matchAll(itemPattern)) {
const itemText = itemMatch[1]
const before = tagText(itemText, 'before').trim()
const tobeText = optionalTagText(itemText, 'tobe')
const tobe = tobeText?.trim()
const tableMustExist = equalsIgnoreCase(looseTagText(itemText, 'tablemustexist').trim(), 'Y')
if (before && tobe !== undefined) rules.push({ before, tobe, tableMustExist })
}
if (rules.length === 0) throw new Error(`convert.xml has no valid
/ rules: ${filePath}`)
return rules
}
function tagText(text: string, tagName: string) {
return optionalTagText(text, tagName) ?? ''
}
function optionalTagText(text: string, tagName: string) {
const match = text.match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'))
return match ? decodeXmlText(match[1]) : undefined
}
function looseTagText(text: string, tagName: string) {
const normal = tagText(text, tagName)
if (normal) return normal
const match = text.match(new RegExp(`<${tagName}\\b[^>]*>\\s*([^<\\r\\n]+)\\s*(?:<${tagName}\\b|$)`, 'i'))
return match ? decodeXmlText(match[1]) : ''
}
function decodeXmlText(text: string) {
return text
.replace(//g, '$1')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/g, '&')
}
function applyConvertRules(text: string, rules: ConvertRule[], targets: CryptoTarget[], date: Date, markerUserId = '') {
let next = text
let changedCount = 0
for (const rule of rules) {
if (next.includes('작업완료')) break
const compiled = compileConvertBeforePattern(rule.before, targets)
next = next.replace(compiled.pattern, (...args: unknown[]) => {
const match = String(args[0])
const captureResult = convertCaptures(args, compiled.placeholders, targets)
if (!captureResult) return match
if (rule.tableMustExist && !convertTargetsHaveTableInText(captureResult.targets, text)) return match
changedCount += 1
return replaceConvertReservedWords(applyConvertTobe(rule.tobe, captureResult.captured), date, markerUserId)
})
}
return { text: next, changedCount }
}
function compileConvertBeforePattern(before: string, targets: CryptoTarget[]) {
const placeholders: ConvertPlaceholder[] = []
let patternText = ''
let lastIndex = 0
const placeholderPattern = /@@\(([^)]+)\)@@/g
const placeholderMatches = Array.from(before.matchAll(placeholderPattern))
for (let placeholderIndex = 0; placeholderIndex < placeholderMatches.length; placeholderIndex += 1) {
const match = placeholderMatches[placeholderIndex]
const token = match[0]
const rawKey = match[1]
const index = match.index ?? 0
const key = rawKey.trim()
const htype = parseConvertPlaceholderHtype(key)
const camelHtype = parseConvertPlaceholderCamelHtype(key)
const previousFragment = before.slice(lastIndex, index)
const nextMatch = placeholderMatches[placeholderIndex + 1]
const nextFragment = nextMatch ? before.slice(index + token.length, nextMatch.index ?? index + token.length) : ''
const nextHtype = nextMatch ? parseConvertPlaceholderHtype(nextMatch[1].trim()) : undefined
const nextCamelHtype = nextMatch ? parseConvertPlaceholderCamelHtype(nextMatch[1].trim()) : undefined
patternText += compileConvertRegexFragment(previousFragment)
patternText += htype
? convertHtypeColumnPattern(htype, targets, !convertFragmentEndsWithDot(previousFragment))
: camelHtype
? convertCamelHtypePattern(camelHtype, targets)
: convertGenericPlaceholderPattern(key, Boolean((nextHtype || nextCamelHtype) && convertFragmentStartsWithDot(nextFragment)))
placeholders.push({ key, htype, camelHtype })
lastIndex = index + token.length
}
patternText += compileConvertRegexFragment(before.slice(lastIndex))
return {
pattern: new RegExp(patternText, 'gi'),
placeholders,
}
}
function parseConvertPlaceholderHtype(key: string) {
return key.match(/^HTYPE\s*:\s*(.+)$/i)?.[1]?.trim()
}
function parseConvertPlaceholderCamelHtype(key: string) {
return key.match(/^CAMELHTP\s*:\s*(.+)$/i)?.[1]?.trim()
}
function convertHtypeColumnPattern(htype: string, targets: CryptoTarget[], allowAlias: boolean) {
const columns = uniqueIgnoreCase(
targets
.filter((target) => equalsIgnoreCase(target.cryptoType, htype))
.map((target) => target.plainColumn)
.filter(Boolean),
)
if (columns.length === 0) return '((?=a)b)'
const aliasPattern = allowAlias ? String.raw`(?:(?:[A-Za-z_][A-Za-z0-9_]*|"[^"]+"|\[[^\]]+\]|` + '`[^`]+`' + String.raw`)\s*\.\s*)?` : ''
const columnAlternation = columns
.sort((left, right) => right.length - left.length)
.map((column) => escapeRegExp(column))
.join('|')
return `(${aliasPattern}(?:${columnAlternation}))`
}
function convertCamelHtypePattern(htype: string, targets: CryptoTarget[]) {
const columns = uniqueIgnoreCase(
targets
.filter((target) => equalsIgnoreCase(target.cryptoType, htype))
.map((target) => toCamel(target.plainColumn))
.filter(Boolean),
)
if (columns.length === 0) return '((?=a)b)'
const columnAlternation = columns
.sort((left, right) => right.length - left.length)
.map((column) => escapeRegExp(column))
.join('|')
return `(${columnAlternation})`
}
function convertGenericPlaceholderPattern(key: string, sqlIdentifierOnly: boolean) {
if (/^\d+$/.test(key)) return String.raw`(\S+?)`
return sqlIdentifierOnly ? String.raw`([A-Za-z_][A-Za-z0-9_]*|"[^"]+"|\[[^\]]+\]|` + '`[^`]+`' + ')' : '([\\s\\S]*?)'
}
function convertFragmentStartsWithDot(fragment: string) {
return /^\s*(?:\\\.)?\./.test(fragment)
}
function convertFragmentEndsWithDot(fragment: string) {
return /(?:\\\.)?\.\s*$/.test(fragment)
}
function uniqueIgnoreCase(values: string[]) {
const seen = new Set()
const unique: string[] = []
for (const value of values) {
const key = value.toUpperCase()
if (seen.has(key)) continue
seen.add(key)
unique.push(value)
}
return unique
}
function compileConvertRegexFragment(fragment: string) {
return makeRegexQuantifiersLazy(makeRegexWhitespaceFlexible(disableRegexCapturingGroups(fragment)))
}
function makeRegexWhitespaceFlexible(pattern: string) {
let next = ''
let inCharClass = false
let pendingWhitespace = false
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index]
const previous = pattern[index - 1]
if (char === '[' && previous !== '\\') inCharClass = true
if (char === ']' && previous !== '\\') inCharClass = false
if (!inCharClass && previous !== '\\' && /\s/.test(char)) {
pendingWhitespace = true
continue
}
if (pendingWhitespace) {
next += String.raw`\s+`
pendingWhitespace = false
}
next += char
}
if (pendingWhitespace) next += String.raw`\s+`
return next
}
function disableRegexCapturingGroups(pattern: string) {
let next = ''
let inCharClass = false
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index]
const previous = pattern[index - 1]
if (char === '[' && previous !== '\\') inCharClass = true
if (char === ']' && previous !== '\\') inCharClass = false
if (!inCharClass && char === '(' && previous !== '\\' && pattern[index + 1] !== '?') {
next += '(?:'
continue
}
next += char
}
return next
}
function makeRegexQuantifiersLazy(pattern: string) {
return pattern
.replace(/((?:\\.|\.|\[[^\]]+\]|\([^?][\s\S]*?\))[*+])(?![?+{])/g, '$1?')
.replace(/(\{(?:\d+,|\d+,\d+)\})(?![?+])/g, '$1?')
}
function convertCaptures(
args: unknown[],
placeholders: ConvertPlaceholder[],
targets: CryptoTarget[],
): ConvertCaptureResult | undefined {
const captured = new Map()
const matchedTargets: CryptoTarget[] = []
for (let index = 0; index < placeholders.length; index += 1) {
const placeholder = placeholders[index]
const value = String(args[index + 1] ?? '')
if (placeholder.htype) {
const columnName = stripAlias(value)
const target = targets.find((item) => (
equalsIgnoreCase(item.cryptoType, placeholder.htype ?? '')
&& equalsIgnoreCase(item.plainColumn, columnName)
))
if (!target) return undefined
captured.set(placeholder.key, target.plainColumn)
captured.set(`HTYPE:${placeholder.htype}`, target.plainColumn)
matchedTargets.push(target)
} else if (placeholder.camelHtype) {
const camelName = stripAlias(value)
const target = targets.find((item) => (
equalsIgnoreCase(item.cryptoType, placeholder.camelHtype ?? '')
&& equalsIgnoreCase(toCamel(item.plainColumn), camelName)
))
if (!target) return undefined
const camelColumn = toCamel(target.plainColumn)
captured.set(placeholder.key, camelColumn)
captured.set(`CAMELHTP:${placeholder.camelHtype}`, camelColumn)
matchedTargets.push(target)
} else {
captured.set(placeholder.key, value)
}
}
return { captured, targets: matchedTargets }
}
function convertTargetsHaveTableInText(targets: CryptoTarget[], text: string) {
if (targets.length === 0) return false
return targets.every((target) => includesIgnoreCase(text, target.tableName))
}
function applyConvertTobe(tobe: string, captured: Map) {
const replaced = tobe.replace(/@@\(([^)]+)\)@@/g, (match: string, rawKey: string) => {
const key = rawKey.trim()
return captured.get(key) ?? match
})
return applyConvertCaseMacros(replaced)
}
function applyConvertCaseMacros(text: string) {
return text.replace(/\b(upper|lower|camel)#\{\s*([^}]*?)\s*\}/gi, (_match: string, command: string, value: string) => {
const normalized = value.trim()
if (equalsIgnoreCase(command, 'upper')) return normalized.toUpperCase()
if (equalsIgnoreCase(command, 'lower')) return normalized.toLowerCase()
return toCamel(normalized)
})
}
function replaceConvertReservedWords(text: string, date: Date, markerUserId = '') {
return text
.replace(/!!!dateyyyymmddhhmm!!!/gi, formatLocalDateTimeMinute(date))
.replace(/!!!dateyyyymmdd!!!/gi, formatLocalDate(date))
.replace(/!!!userid!!!/gi, markerUserId)
}
function normalizeOptional(value: unknown) {
return String(value ?? '').trim()
}
function parseSelectedMapperItem(text: string): MapperItem {
const match = text.trim().match(/^<(insert|update|select|delete)\b([^>]*)>[\s\S]*<\/\1>$/i)
if (!match) {
throw new Error(', , , 중 하나의 XML 태그 전체를 선택해야 합니다.')
}
return {
tag: match[1].toLowerCase() as MapperItem['tag'],
attrs: match[2],
id: attrValue(match[2], 'id') || 'anonymousSql',
text,
}
}
function expandMapperItemIncludesForTargets(itemText: string, mapperXml: string, targets: CryptoTarget[], tag?: MapperItem['tag']) {
const tableTargets = targets.filter((target) => hasBoundaryToken(itemText, target.tableName))
if (tableTargets.length === 0 || !/ hasBoundaryToken(itemText, target.tableName))
if (tableTargets.length === 0 || !/]*(?:\/>|>[\s\S]*?<\/include\s*>)/gi, (includeText: string) => {
const attrs = includeText.match(/^]*?)(?:\/>|>)/i)?.[1] ?? ''
const refid = attrValue(attrs, 'refid')
const fragment = findSqlFragment(fragments, refid)
if (!fragment) return includeText
const replacement = applyIncludeProperties(fragment, includeText)
if (!includeFragmentHasCryptoTargetColumn(replacement, tableTargets, tag)) return includeText
changed = true
return replacement
})
if (!changed) break
}
return next
}
function parseSqlFragments(mapperXml: string) {
const fragments = new Map()
const sqlPattern = /]*)>([\s\S]*?)<\/sql\s*>/gi
for (const match of mapperXml.matchAll(sqlPattern)) {
const id = attrValue(match[1], 'id')
if (!id) continue
fragments.set(id, trimSqlFragmentBody(match[2]))
}
return fragments
}
function trimSqlFragmentBody(body: string) {
return body.replace(/^\s*\r?\n/, '').replace(/\r?\n\s*$/, '')
}
function findSqlFragment(fragments: Map, refid: string) {
if (!refid) return ''
return fragments.get(refid) ?? fragments.get(refid.replace(/^.*\./, '')) ?? ''
}
function applyIncludeProperties(fragment: string, includeText: string) {
const properties = new Map()
const propertyPattern = /]*?)\/?>/gi
for (const match of includeText.matchAll(propertyPattern)) {
const name = attrValue(match[1], 'name')
if (!name) continue
properties.set(name, attrValue(match[1], 'value'))
}
if (properties.size === 0) return fragment
return fragment.replace(/\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g, (match: string, name: string) => {
return properties.has(name) ? properties.get(name)! : match
})
}
function includeFragmentHasCryptoTargetColumn(fragment: string, tableTargets: CryptoTarget[], tag?: MapperItem['tag']) {
return tableTargets.some((target) => {
if (tag === 'select') return hasSelectRewritePoint(fragment, target)
return hasBoundaryToken(fragment, target.plainColumn)
|| hasBoundaryToken(fragment, target.encColumn)
|| Boolean(target.hashColumn && hasBoundaryToken(fragment, target.hashColumn))
})
}
function buildRecommendation(
mapperItem: MapperItem,
selectedText: string,
mapperXml: string,
matchedTargets: CryptoTarget[],
date: string,
markerUserId: string,
) {
const tag = mapperItem.tag.toUpperCase()
const warnings: string[] = []
let afterSql = selectedText
let supportSnippets: string[] = []
if (tag === 'INSERT') {
afterSql = matchedTargets.reduce((next, target) => addInsertEncryption(next, target, date, markerUserId), afterSql)
} else if (tag === 'UPDATE') {
afterSql = matchedTargets.reduce((next, target) => addUpdateEncryption(next, target, date, markerUserId), afterSql)
} else if (tag === 'SELECT') {
afterSql = addSelectEncryption(selectedText, matchedTargets, date, markerUserId)
if (shouldApplySelectResultMap(selectedText, afterSql, matchedTargets)) {
const desiredResultMap = desiredResultMapDefinition(mapperItem, selectedText, mapperXml, matchedTargets)
const existingResultMap = findExistingResultMap(mapperXml, desiredResultMap)
const resultMapId = existingResultMap?.id || desiredResultMap.id
afterSql = applySelectResultMap(afterSql, resultMapId)
if (!existingResultMap) {
supportSnippets = [resultMapSnippet(desiredResultMap)]
warnings.push(`resultMap 추가 추천: ${desiredResultMap.id}`)
} else {
warnings.push(`기존 resultMap 재사용: ${existingResultMap.id}`)
}
}
} else {
afterSql = matchedTargets.reduce((next, target) => addWhereHashRewrite(next, target, date, markerUserId), afterSql)
}
if (afterSql === selectedText && supportSnippets.length === 0) {
throw new Error('대상은 찾았지만 적용 가능한 치환 지점을 찾지 못했습니다.')
}
const header = [
``,
].filter(Boolean).join('\n')
const clipboardText = [header, afterSql.trim(), ...supportSnippets.map((snippet) => snippet.trim())].join('\n\n')
return { clipboardText, supportSnippets }
}
function buildFullFileRecommendation(mapperXml: string, targets: CryptoTarget[], date: string, markerUserId: string) {
const itemPattern = /<(insert|update|select|delete)\b([^>]*)>[\s\S]*?<\/\1>/gi
const supportSnippets: string[] = []
const chunks: string[] = []
let changedItemCount = 0
let lastIndex = 0
let mapperXmlForLookup = mapperXml
for (const match of mapperXml.matchAll(itemPattern)) {
const itemText = match[0]
const index = match.index ?? 0
chunks.push(mapperXml.slice(lastIndex, index))
const mapperItem = parseSelectedMapperItem(itemText)
const expandedItemText = expandMapperItemIncludesForTargets(itemText, mapperXmlForLookup, targets, mapperItem.tag).text
const matchedTargets = targets.filter((target) => matchesTarget(expandedItemText, target, mapperItem.tag))
if (matchedTargets.length === 0) {
chunks.push(itemText)
lastIndex = index + itemText.length
continue
}
const rewrite = rewriteMapperItemForFullFile(mapperItem, expandedItemText, mapperXmlForLookup, matchedTargets, date, markerUserId)
chunks.push(rewrite.afterSql)
if (rewrite.afterSql !== itemText || rewrite.supportSnippets.length > 0) changedItemCount += 1
for (const snippet of rewrite.supportSnippets) {
if (appendUniqueResultMapSnippet(supportSnippets, mapperXmlForLookup, snippet)) {
mapperXmlForLookup += `\n${snippet}`
}
}
lastIndex = index + itemText.length
}
chunks.push(mapperXml.slice(lastIndex))
if (changedItemCount === 0) {
throw new Error('No mapper item matched crypto targets or no rewrite point was found.')
}
const rewrittenXml = insertResultMapsAtMapperTop(chunks.join(''), supportSnippets)
return {
clipboardText: rewrittenXml,
changedItemCount,
supportSnippets,
}
}
function markCryptoTargetColumns(mapperXml: string, targets: CryptoTarget[]) {
const itemPattern = /<(insert|update|select|delete)\b([^>]*)>[\s\S]*?<\/\1>/gi
const chunks: string[] = []
let changedCount = 0
let lastIndex = 0
for (const match of mapperXml.matchAll(itemPattern)) {
const itemText = match[0]
const index = match.index ?? 0
chunks.push(mapperXml.slice(lastIndex, index))
const result = markCryptoTargetColumnsInMapperItem(itemText, targets, mapperXml)
changedCount += result.changedCount
chunks.push(result.text)
lastIndex = index + itemText.length
}
chunks.push(mapperXml.slice(lastIndex))
return { text: chunks.join(''), changedCount }
}
function markCryptoTargetColumnsInMapperItem(itemText: string, targets: CryptoTarget[], mapperXml = itemText) {
let nextItemText = expandMapperItemIncludesForTargets(itemText, mapperXml, targets).text
let changedCount = 0
const matchedTargets = targets.filter((target) => hasBoundaryToken(nextItemText, target.tableName) && hasBoundaryToken(nextItemText, target.plainColumn))
for (const target of matchedTargets) {
const result = markCryptoTargetColumnInItem(nextItemText, target)
nextItemText = result.text
changedCount += result.changedCount
}
return { text: nextItemText, changedCount }
}
function findMapperItemRangeAtOffset(mapperXml: string, offset: number): MapperItemRange | undefined {
const itemPattern = /<(insert|update|select|delete)\b([^>]*)>[\s\S]*?<\/\1>/gi
for (const match of mapperXml.matchAll(itemPattern)) {
const start = match.index ?? 0
const end = start + match[0].length
if (offset >= start && offset <= end) {
return { start, end, text: match[0] }
}
}
return undefined
}
function markCryptoTargetColumnInItem(itemText: string, target: CryptoTarget) {
const commentRanges = commentTokenRanges(itemText)
let changedCount = 0
const pattern = new RegExp(
`(^|[^A-Za-z0-9_\\.])((?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?${escapeRegExp(target.plainColumn)})(?=$|[^A-Za-z0-9_])`,
'gi',
)
const text = itemText.replace(pattern, (match: string, prefix: string, column: string, offset: number) => {
const columnStart = offset + prefix.length
const afterMatch = itemText.slice(offset + match.length)
if (isInsideRanges(columnStart, commentRanges)) return match
if (new RegExp(`^\\s*${escapeRegExp(CRYPTO_TARGET_CHECK_COMMENT)}`).test(afterMatch)) return match
changedCount += 1
return `${prefix}${column} ${CRYPTO_TARGET_CHECK_COMMENT}`
})
return { text, changedCount }
}
function markHashColumnInfoInText(text: string, targets: CryptoTarget[]) {
const targetsByColumn = new Map()
for (const target of targets) {
const key = target.plainColumn.toUpperCase()
const items = targetsByColumn.get(key) ?? []
items.push(target)
targetsByColumn.set(key, items)
}
const commentRanges = commentTokenRanges(text)
const columnPattern = /\b[A-Za-z_][A-Za-z0-9_]*\b/g
let changedCount = 0
let next = ''
let lastIndex = 0
for (const match of text.matchAll(columnPattern)) {
const column = match[0]
const start = match.index ?? 0
const columnTargets = targetsByColumn.get(column.toUpperCase())
if (!columnTargets || isInsideRanges(start, commentRanges) || hasHashColumnInfoAfter(text, start + column.length)) {
continue
}
next += text.slice(lastIndex, start + column.length)
next += ` ${hashColumnInfoComment(columnTargets)}`
lastIndex = start + column.length
changedCount += 1
}
next += text.slice(lastIndex)
return { text: changedCount ? next : text, changedCount }
}
function hasHashColumnInfoAfter(text: string, offset: number) {
return /^\s*\/\*\s*\([^*]*해시컬럼(?:존재|없음)[^*]*\)\s*\*\//.test(text.slice(offset))
}
function hashColumnInfoComment(targets: CryptoTarget[]) {
const infos = targets
.map((target) => target.hashColumn
? `${target.tableName}.${target.hashColumn} 해시컬럼존재`
: `${target.tableName} 해시컬럼없음`)
.filter((value, index, array) => array.indexOf(value) === index)
return `/* (${infos.join(', ')}) */`
}
function commentTokenRanges(text: string) {
const ranges: Array<{ start: number; end: number }> = []
const commentPattern = /|\/\*[\s\S]*?\*\//g
for (const match of text.matchAll(commentPattern)) {
const start = match.index ?? 0
ranges.push({ start, end: start + match[0].length })
}
return ranges
}
function isInsideRanges(index: number, ranges: Array<{ start: number; end: number }>) {
return ranges.some((range) => index >= range.start && index < range.end)
}
function rewriteMapperItemForFullFile(
mapperItem: MapperItem,
itemText: string,
mapperXml: string,
matchedTargets: CryptoTarget[],
date: string,
markerUserId: string,
) {
const tag = mapperItem.tag.toUpperCase()
let afterSql = itemText
let supportSnippets: string[] = []
if (tag === 'INSERT') {
afterSql = matchedTargets.reduce((next, target) => addInsertEncryption(next, target, date, markerUserId), afterSql)
} else if (tag === 'UPDATE') {
afterSql = matchedTargets.reduce((next, target) => addUpdateEncryption(next, target, date, markerUserId), afterSql)
} else if (tag === 'SELECT') {
afterSql = addSelectEncryption(itemText, matchedTargets, date, markerUserId)
if (shouldApplySelectResultMap(itemText, afterSql, matchedTargets)) {
let desiredResultMap = desiredResultMapDefinition(mapperItem, itemText, mapperXml, matchedTargets)
const existingResultMap = findExistingResultMap(mapperXml, desiredResultMap)
if (!existingResultMap) {
desiredResultMap = {
...desiredResultMap,
id: uniqueResultMapId(desiredResultMap.id, mapperXml),
}
supportSnippets = [resultMapSnippet(desiredResultMap)]
}
afterSql = applySelectResultMap(afterSql, existingResultMap?.id || desiredResultMap.id)
}
} else {
afterSql = matchedTargets.reduce((next, target) => addWhereHashRewrite(next, target, date, markerUserId), afterSql)
}
return { afterSql, supportSnippets }
}
function appendUniqueResultMapSnippet(snippets: string[], mapperXml: string, snippet: string) {
const id = attrValue(snippet.match(/^]*)>/i)?.[1] ?? '', 'id')
if (!id) return false
if (parseResultMaps(mapperXml).some((resultMap) => resultMap.id === id)) return false
if (snippets.some((item) => attrValue(item.match(/^]*)>/i)?.[1] ?? '', 'id') === id)) return false
snippets.push(snippet)
return true
}
function insertResultMapsAtMapperTop(mapperXml: string, supportSnippets: string[]) {
if (supportSnippets.length === 0) return mapperXml
const block = supportSnippets.map((snippet) => indentResultMapSnippet(snippet, ' ')).join('\n\n')
return mapperXml.replace(/(]*>\s*)/i, `$1\n${block}\n\n`)
}
function indentResultMapSnippet(snippet: string, indent: string) {
return snippet
.split(/\r?\n/)
.map((line) => `${indent}${line}`)
.join('\n')
}
function uniqueResultMapId(baseId: string, mapperXml: string) {
const existingIds = new Set(parseResultMaps(mapperXml).map((resultMap) => resultMap.id))
if (!existingIds.has(baseId)) return baseId
for (let index = 2; index < 1000; index += 1) {
const candidate = `${baseId}${index}`
if (!existingIds.has(candidate)) return candidate
}
return `${baseId}${Date.now()}`
}
function addInsertEncryption(xmlText: string, target: CryptoTarget, date: string, markerUserId: string) {
if (hasBoundaryToken(xmlText, target.encColumn) && (!target.hashColumn || hasBoundaryToken(xmlText, target.hashColumn))) {
return xmlText
}
const valuesAt = xmlText.search(/\bVALUES\b/i)
if (valuesAt < 0) return xmlText
const head = xmlText.slice(0, valuesAt)
const tail = xmlText.slice(valuesAt)
const columnLine = new RegExp(`^(\\s*)${escapeRegExp(target.plainColumn)}(\\s*,?)\\s*$`, 'mi')
const bindLine = new RegExp(`^(\\s*)#\\{\\s*(${bindPropertyPatternText(target.javaProperty)})\\s*\\}(\\s*,?)\\s*$`, 'mi')
const columns = [target.plainColumn, target.encColumn, target.hashColumn].filter(Boolean)
const nextHead = head.replace(columnLine, (_match, indent: string, comma: string) => {
return columns.map((column, index) => {
const suffix = index < columns.length - 1 || comma ? ',' : ''
return `${indent}${column}${suffix}`
}).join('\n')
})
const nextTail = tail.replace(bindLine, (_match, indent: string, propertyExpression: string, comma: string) => {
const binds = insertEncryptionBinds(target, normalizeBindPropertyExpression(propertyExpression), date, markerUserId)
return binds.map((bind, index) => {
const suffix = index < binds.length - 1 || comma ? ',' : ''
return `${indent}${bind}${suffix}`
}).join('\n')
})
if (nextHead !== head && nextTail !== tail) return nextHead + nextTail
const propertyExpression = findBindPropertyExpression(tail, target.javaProperty) || target.javaProperty
const binds = insertEncryptionBinds(target, propertyExpression, date, markerUserId)
return replaceFirstBoundary(head, target.plainColumn, columns.join(',\n '))
+ replaceFirstPropertyBind(tail, target.javaProperty, binds.join(',\n '))
}
function addUpdateEncryption(xmlText: string, target: CryptoTarget, date: string, markerUserId: string) {
if (hasBoundaryToken(xmlText, target.encColumn) && (!target.hashColumn || hasBoundaryToken(xmlText, target.hashColumn))) {
return xmlText
}
const plainAssign = new RegExp(
`(^\\s*${escapeRegExp(target.plainColumn)}\\s*=\\s*#\\{\\s*(${bindPropertyPatternText(target.javaProperty)})\\s*\\}\\s*,?)(?:\\s*(?:|/\\*[\\s\\S]*?\\*/))?`,
'mi',
)
return xmlText.replace(plainAssign, (match, plainAssignment: string, propertyExpression: string) => {
const indent = plainAssignment.match(/^\s*/)?.[0] ?? ''
const bindProperty = normalizeBindPropertyExpression(propertyExpression)
const additions = [
hasBoundaryToken(xmlText, target.encColumn) ? '' : `${indent}${target.encColumn} = ${typeHandlerBindWithKmsComment(bindProperty, target.encryptTypeHandler, date, markerUserId)},`,
target.hashColumn && !hasBoundaryToken(xmlText, target.hashColumn)
? `${indent}${target.hashColumn} = ${typeHandlerBindWithKmsComment(bindProperty, target.hashTypeHandler, date, markerUserId)},`
: '',
].filter(Boolean)
if (additions.length === 0) return match
const plainAssignmentWithComma = /,\s*$/.test(plainAssignment) ? plainAssignment : `${plainAssignment},`
return [plainAssignmentWithComma, ...additions].join('\n')
})
}
function insertEncryptionBinds(target: CryptoTarget, propertyExpression: string, date: string, markerUserId: string) {
return [
`#{${propertyExpression}}`,
typeHandlerBindWithKmsComment(propertyExpression, target.encryptTypeHandler, date, markerUserId),
target.hashColumn ? typeHandlerBindWithKmsComment(propertyExpression, target.hashTypeHandler, date, markerUserId) : '',
].filter(Boolean)
}
function addSelectEncryption(xmlText: string, targets: CryptoTarget[], date: string, markerUserId: string) {
let next = xmlText
for (const target of targets) {
const tableAlias = findTableAlias(next, target.tableName)
const aliasPattern = new RegExp(
`\\b${escapeRegExp(target.encColumn)}\\s+AS\\s+${escapeRegExp(target.plainColumn)}\\b(?:\\s*(?:${LEGACY_DECRYPT_TARGET_COMMENT_PATTERN}|${DECRYPT_TARGET_COMMENT_PATTERN}))?`,
'i',
)
if (!aliasPattern.test(next)) {
next = replaceFirstSelectColumn(next, target, tableAlias, date, markerUserId)
} else {
next = next.replace(aliasPattern, (match) => {
if (decryptTargetCommentPattern().test(match)) return match
return `${match.replace(legacyDecryptTargetCommentPattern(), '').trimEnd()} ${decryptTargetComment(date, markerUserId)}`
})
}
next = addWhereHashRewrite(next, target, date, markerUserId)
}
return next
}
function applySelectResultMap(xmlText: string, resultMapId: string) {
return xmlText.replace(/^(\s*)]*)>/i, (_match, indent: string, attrs: string) => {
const nextAttrs = attrs
.replace(/\s+resultType\s*=\s*['"][^'"]*['"]/i, '')
.replace(/\s+resultMap\s*=\s*['"][^'"]*['"]/i, '')
.trimEnd()
return `${indent}`
})
}
function hasDecryptTargetComment(xmlText: string) {
return decryptTargetCommentPattern().test(xmlText) || legacyDecryptTargetCommentPattern().test(xmlText)
}
function shouldApplySelectResultMap(originalSql: string, rewrittenSql: string, targets: CryptoTarget[]) {
if (hasDecryptTargetComment(rewrittenSql)) return true
return targets.some((target) => hasSelectColumnItemForTarget(originalSql, target))
}
function decryptTargetComment(date: string, markerUserId: string) {
return `/* [KMS-CIPHER] 수정일자: ${date} 수정자 : ${markerUserId} */`
}
function decryptTargetCommentPattern() {
return new RegExp(DECRYPT_TARGET_COMMENT_PATTERN, 'i')
}
function legacyDecryptTargetCommentPattern() {
return new RegExp(LEGACY_DECRYPT_TARGET_COMMENT_PATTERN, 'i')
}
function selectDecryptAlias(target: CryptoTarget, tableAlias: string, date: string, markerUserId: string, outputAlias = target.plainColumn) {
const qualifier = tableAlias ? `${tableAlias}.` : ''
return `NVL(${qualifier}${target.encColumn}, ${qualifier}${target.plainColumn}) AS ${outputAlias} ${decryptTargetComment(date, markerUserId)}`
}
function selectDecryptAliasPattern(target: CryptoTarget) {
const qualifier = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?`
return new RegExp(
String.raw`\bNVL\s*\(\s*${qualifier}${escapeRegExp(target.encColumn)}\s*,\s*${qualifier}${escapeRegExp(target.plainColumn)}\s*\)\s+AS\s+[A-Za-z_][A-Za-z0-9_]*\b`,
'i',
)
}
function replaceFirstSelectColumn(xmlText: string, target: CryptoTarget, tableAlias: string, date: string, markerUserId: string) {
if (selectDecryptAliasPattern(target).test(xmlText)) return xmlText
const columnPattern = selectPlainColumnPattern(target)
for (const columnRange of selectColumnRanges(xmlText)) {
const { columnsStart, columnsEnd } = columnRange
const before = xmlText.slice(0, columnsStart)
const columns = xmlText.slice(columnsStart, columnsEnd)
const after = xmlText.slice(columnsEnd)
const columnItems = splitTopLevelCsvItems(columns)
for (const item of columnItems) {
const cleanItemText = removeCryptoTargetCheckComments(removeDecryptTargetComments(item.text))
if (!isTargetSelectColumnItem(cleanItemText, target)) continue
const outputAlias = targetSelectAlias(cleanItemText, target) || target.plainColumn
const itemWithoutAlias = stripTargetSelectAlias(cleanItemText, target)
const replacedItem = itemWithoutAlias.replace(columnPattern, (_match, prefix: string, column: string) => {
const columnAlias = column.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*\./)?.[1] ?? ''
return `${prefix}${selectDecryptAlias(target, columnAlias || tableAlias, date, markerUserId, outputAlias)}`
})
return `${before}${columns.slice(0, item.start)}${removeLeftAliasBeforeTrailingAlias(replacedItem, target)}${columns.slice(item.end)}${after}`
}
}
return xmlText
}
function selectPlainColumnPattern(target: CryptoTarget) {
return new RegExp(
`(^|[^A-Za-z0-9_\\.])((?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?${escapeRegExp(target.plainColumn)})(?=$|[^A-Za-z0-9_])`,
'i',
)
}
function isTargetSelectColumnItem(itemText: string, target: CryptoTarget) {
const column = stripAlias(itemText)
return equalsIgnoreCase(column, target.plainColumn)
|| equalsIgnoreCase(column, target.encColumn)
|| selectColumnItemReferencesColumn(itemText, target.plainColumn)
|| selectColumnItemReferencesColumn(itemText, target.encColumn)
}
function hasSelectColumnItemForTarget(xmlText: string, target: CryptoTarget) {
return selectColumnRanges(xmlText).some((columnRange) => {
return splitTopLevelCsvItems(xmlText.slice(columnRange.columnsStart, columnRange.columnsEnd))
.some((item) => isTargetSelectColumnItem(removeCryptoTargetCheckComments(removeDecryptTargetComments(item.text)), target))
})
}
function selectColumnItemReferencesColumn(columnItemText: string, columnName: string) {
if (!columnName) return false
return new RegExp(
`(^|[^A-Za-z0-9_\\.])(?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?${escapeRegExp(columnName)}(?=$|[^A-Za-z0-9_])`,
'i',
).test(removeCryptoTargetCheckComments(removeDecryptTargetComments(columnItemText)))
}
function findTableAlias(xmlText: string, tableName: string) {
const fromMatch = xmlText.match(/\bfrom\b([\s\S]*?)(?:\bwhere\b|\bgroup\s+by\b|\border\s+by\b|\bhaving\b|\bunion\b|<\/select>|$)/i)
if (!fromMatch) return ''
const tablePattern = new RegExp(
`\\b${escapeRegExp(tableName)}\\b\\s+(?:AS\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\b`,
'i',
)
const alias = fromMatch[1].match(tablePattern)?.[1] ?? ''
return alias && !/^(where|inner|left|right|full|cross|join|on|group|order|having|union)$/i.test(alias) ? alias : ''
}
function addWhereHashRewrite(xmlText: string, target: CryptoTarget, date: string, markerUserId = '') {
if (!target.hashColumn || !target.hashTypeHandler) return xmlText
const whereMatch = xmlText.match(/\bwhere\b/i)
if (!whereMatch || whereMatch.index === undefined) return xmlText
const beforeWhereBody = xmlText.slice(0, whereMatch.index + whereMatch[0].length)
const whereBody = xmlText.slice(whereMatch.index + whereMatch[0].length)
const tableAlias = findTableAlias(xmlText, target.tableName)
const qualifier = String.raw`(?:(?[A-Za-z_][A-Za-z0-9_]*)\s*\.\s*)?`
const column = escapeRegExp(target.plainColumn)
const property = escapeRegExp(target.javaProperty)
const parameter = String.raw`#\{\s*${property}\s*\}`
const equalsPattern = new RegExp(String.raw`${qualifier}\b${column}\b\s*=\s*${parameter}`, 'i')
const likePattern = new RegExp(
String.raw`${qualifier}\b${column}\b\s+LIKE\s*'%'\s*\|\|\s*${parameter}\s*\|\|\s*'%'`,
'i',
)
const replaceCondition = (match: string, alias: string | undefined, offset: number, source: string) => {
const conditionAlias = alias || tableAlias
return hashWhereFallbackCondition(target, conditionAlias, date, markerUserId, lineLeadingWhitespace(source, offset))
}
if (equalsPattern.test(whereBody)) {
return `${beforeWhereBody}${whereBody.replace(equalsPattern, replaceCondition)}`
}
if (likePattern.test(whereBody)) {
return `${beforeWhereBody}${whereBody.replace(likePattern, replaceCondition)}`
}
return xmlText
}
function hashWhereFallbackCondition(target: CryptoTarget, alias: string, date: string, markerUserId: string, continuationIndent = '') {
const qualifier = alias ? `${alias}.` : ''
const hashColumn = `${qualifier}${target.hashColumn}`
const plainColumn = `${qualifier}${target.plainColumn}`
const marker = cipherMarkerComment(date, markerUserId)
return [
`(${hashColumn} IS NOT NULL AND ${hashColumn} = ${typeHandlerBind(target.javaProperty, target.hashTypeHandler)})`,
`${continuationIndent}OR (${hashColumn} IS NULL AND UPPER(${plainColumn}) = UPPER(#{${target.javaProperty}})) ${marker}`,
].join('\n')
}
function lineLeadingWhitespace(text: string, offset: number) {
const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1
return text.slice(lineStart, offset).match(/^[ \t]*/)?.[0] ?? ''
}
function cipherMarkerComment(date: string, markerUserId: string) {
return `/* [KMS-CIPHER] 수정일자: ${date} 수정자 : ${markerUserId} */`
}
function desiredResultMapDefinition(
mapperItem: MapperItem,
selectedText: string,
mapperXml: string,
targets: CryptoTarget[],
): ResultMapDefinition {
const existingResultMapId = attrValue(mapperItem.attrs, 'resultMap')
const existingResultMap = existingResultMapId ? parseResultMaps(mapperXml).find((item) => item.id === existingResultMapId) : undefined
const type = attrValue(mapperItem.attrs, 'resultType')
|| existingResultMap?.type
|| inferDtoTypeFromMapperNamespace(mapperXml)
|| 'java.util.Map'
return {
id: resultMapId(mapperItem, selectedText, targets),
type,
entries: resultMapEntries(selectedText, targets),
}
}
function resultMapEntries(selectedText: string, targets: CryptoTarget[]) {
const entries: ResultMapEntry[] = []
const seen = new Set()
const selected = selectedColumns(selectedText)
for (const target of targets) {
const isSelectedTarget = selected.some((column) => equalsIgnoreCase(target.plainColumn, column) || equalsIgnoreCase(target.encColumn, column))
|| hasSelectColumnItemForTarget(selectedText, target)
if (!isSelectedTarget) continue
const normalizedColumn = resultMapColumnForTarget(selectedText, target) || target.plainColumn
const property = resultMapPropertyForColumn(normalizedColumn, target)
const key = `${normalizedColumn.toUpperCase()}|${property}`
if (seen.has(key)) continue
seen.add(key)
entries.push({
kind: entries.length === 0 && /_ID$/i.test(normalizedColumn) ? 'id' : 'result',
column: normalizedColumn,
property,
typeHandler: fqcn(target.encryptTypeHandler),
})
}
return entries
}
function resultMapColumnForTarget(selectedText: string, target: CryptoTarget) {
if (!selectedText) return ''
for (const columnRange of selectColumnRanges(selectedText)) {
const columns = selectedText.slice(columnRange.columnsStart, columnRange.columnsEnd)
for (const item of splitTopLevelCsvItems(columns)) {
const cleanItemText = removeCryptoTargetCheckComments(removeDecryptTargetComments(item.text))
if (!isTargetSelectColumnItem(cleanItemText, target)) continue
return selectOutputAlias(cleanItemText, target) || target.plainColumn
}
}
return ''
}
function resultMapPropertyForColumn(column: string, target: CryptoTarget) {
if (equalsIgnoreCase(column, target.plainColumn) || equalsIgnoreCase(column, target.encColumn)) {
return target.javaProperty
}
return toCamel(column)
}
function selectOutputAlias(columnItemText: string, target: CryptoTarget) {
const targetAlias = targetSelectAlias(columnItemText, target)
if (targetAlias) return targetAlias
const normalized = columnItemText
.replace(//g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim()
const explicitAlias = normalized.match(/\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i)
return explicitAlias?.[1] ?? ''
}
function selectedColumns(selectedText: string) {
const columnRanges = selectColumnRanges(selectedText)
if (columnRanges.length === 0) return []
return columnRanges.flatMap((columnRange) => splitTopLevelCsv(selectedText.slice(columnRange.columnsStart, columnRange.columnsEnd)))
.map((item) => stripAlias(item))
.filter((item) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(item))
}
function rewriteSelectedSelectColumns(itemText: string, selectedText: string, targets: CryptoTarget[], date: string, markerUserId: string) {
const textWithoutCheckComments = removeCryptoTargetCheckComments(selectedText)
const columnItems = splitTopLevelCsvItems(textWithoutCheckComments)
const changedTargets: CryptoTarget[] = []
const seenTargets = new Set()
let next = ''
let lastIndex = 0
for (const item of columnItems) {
next += textWithoutCheckComments.slice(lastIndex, item.start)
const cleanItemText = removeDecryptTargetComments(item.text)
const target = targets.find((candidate) => isSelectedCryptoColumnItem(itemText, cleanItemText, candidate))
if (!target) {
next += item.text
lastIndex = item.end
continue
}
const tableAlias = findTableAlias(itemText, target.tableName)
const replaced = replaceSelectedColumnItem(cleanItemText, target, tableAlias, date, markerUserId)
if (replaced !== cleanItemText) {
const key = `${target.tableName.toUpperCase()}|${target.plainColumn.toUpperCase()}`
if (!seenTargets.has(key)) {
seenTargets.add(key)
changedTargets.push(target)
}
}
next += replaced
lastIndex = item.end
}
next += textWithoutCheckComments.slice(lastIndex)
return { text: next, changedTargets }
}
function removeCryptoTargetCheckComments(text: string) {
return text
.split(CRYPTO_TARGET_CHECK_COMMENT)
.join('')
.replace(/\/\*\s*@@@[\s\S]*?@@@\s*\*\//g, '')
}
function removeDecryptTargetComments(text: string) {
return text
.replace(decryptTargetCommentPattern(), '')
.replace(legacyDecryptTargetCommentPattern(), '')
}
function isSelectedCryptoColumnItem(itemText: string, columnItemText: string, target: CryptoTarget) {
if (!hasBoundaryToken(itemText, target.tableName)) return false
return selectColumnItemReferencesTarget(columnItemText, itemText, target, target.plainColumn)
|| selectColumnItemReferencesTarget(columnItemText, itemText, target, target.encColumn)
}
function selectColumnItemReferencesTarget(columnItemText: string, itemText: string, target: CryptoTarget, columnName: string) {
if (!columnName) return false
const tableAlias = findTableAlias(itemText, target.tableName)
const pattern = new RegExp(
`(^|[^A-Za-z0-9_\\.])((?:([A-Za-z_][A-Za-z0-9_]*)\\s*\\.\\s*)?${escapeRegExp(columnName)})(?=$|[^A-Za-z0-9_])`,
'i',
)
const match = columnItemText.match(pattern)
if (!match) return false
const columnAlias = match[3] ?? ''
return !columnAlias || !tableAlias || equalsIgnoreCase(columnAlias, tableAlias)
}
function replaceSelectedColumnItem(columnItemText: string, target: CryptoTarget, tableAlias: string, date: string, markerUserId: string) {
if (selectDecryptAliasPattern(target).test(columnItemText)) return columnItemText
const columnPattern = new RegExp(
`(^|[^A-Za-z0-9_\\.])((?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?(?:${escapeRegExp(target.plainColumn)}|${escapeRegExp(target.encColumn)}))(?=$|[^A-Za-z0-9_])`,
'i',
)
const outputAlias = targetSelectAlias(columnItemText, target) || target.plainColumn
const itemWithoutAlias = stripTargetSelectAlias(columnItemText, target)
const replacedItem = itemWithoutAlias.replace(columnPattern, (_match, prefix: string, column: string) => {
const columnAlias = column.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*\./)?.[1] ?? ''
return `${prefix}${selectDecryptAlias(target, columnAlias || tableAlias, date, markerUserId, outputAlias)}`
})
return removeLeftAliasBeforeTrailingAlias(replacedItem, target)
}
function targetSelectAlias(columnItemText: string, target: CryptoTarget) {
const targetColumn = targetSelectColumnPatternText(target)
const explicitAlias = columnItemText.match(new RegExp(`^\\s*${targetColumn}\\s+AS\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*$`, 'i'))
if (explicitAlias) return explicitAlias[1]
const implicitAlias = columnItemText.match(new RegExp(`^\\s*${targetColumn}\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*$`, 'i'))
if (implicitAlias && !isSqlReservedWord(implicitAlias[1])) return implicitAlias[1]
return ''
}
function stripTargetSelectAlias(columnItemText: string, target: CryptoTarget) {
const targetColumn = targetSelectColumnPatternText(target)
const explicitAliasPattern = new RegExp(`^(\\s*${targetColumn})(\\s+AS\\s+[A-Za-z_][A-Za-z0-9_]*)(\\s*)$`, 'i')
const explicitAlias = columnItemText.match(explicitAliasPattern)
if (explicitAlias) return `${explicitAlias[1]}${explicitAlias[3]}`
const implicitAliasPattern = new RegExp(`^(\\s*${targetColumn})\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s*)$`, 'i')
const implicitAlias = columnItemText.match(implicitAliasPattern)
if (implicitAlias && !isSqlReservedWord(implicitAlias[2])) {
return `${implicitAlias[1]}${implicitAlias[3]}`
}
return columnItemText
}
function targetSelectColumnPatternText(target: CryptoTarget) {
const qualifier = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?`
return `${qualifier}(?:${escapeRegExp(target.plainColumn)}|${escapeRegExp(target.encColumn)})`
}
function removeLeftAliasBeforeTrailingAlias(columnItemText: string, target: CryptoTarget) {
const columnName = escapeRegExp(target.plainColumn)
return columnItemText.replace(
new RegExp(`\\s+AS\\s+${columnName}\\s+(${DECRYPT_TARGET_COMMENT_PATTERN})(\\s+AS\\s+\\b${columnName}\\b)`, 'i'),
' $1$2',
)
}
function desiredResultMapDefinitionForTargets(
mapperItem: MapperItem,
selectedText: string,
mapperXml: string,
targets: CryptoTarget[],
): ResultMapDefinition {
const existingResultMapId = attrValue(mapperItem.attrs, 'resultMap')
const existingResultMap = existingResultMapId ? parseResultMaps(mapperXml).find((item) => item.id === existingResultMapId) : undefined
const type = attrValue(mapperItem.attrs, 'resultType')
|| existingResultMap?.type
|| inferDtoTypeFromMapperNamespace(mapperXml)
|| 'java.util.Map'
return {
id: resultMapId(mapperItem, selectedText, targets),
type,
entries: resultMapEntriesFromTargets(targets, selectedText),
}
}
function resultMapEntriesFromTargets(targets: CryptoTarget[], selectedText = '') {
const entries: ResultMapEntry[] = []
const seen = new Set()
for (const target of targets) {
const normalizedColumn = resultMapColumnForTarget(selectedText, target) || target.plainColumn
const property = resultMapPropertyForColumn(normalizedColumn, target)
const key = `${normalizedColumn.toUpperCase()}|${property}`
if (seen.has(key)) continue
seen.add(key)
entries.push({
kind: entries.length === 0 && /_ID$/i.test(normalizedColumn) ? 'id' : 'result',
column: normalizedColumn,
property,
typeHandler: fqcn(target.encryptTypeHandler),
})
}
return entries
}
function lineStartOffset(text: string, offset: number) {
return text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1
}
function isRangeInsideSelectColumnRange(xmlText: string, start: number, end: number) {
return selectColumnRanges(xmlText).some((columnRange) => start >= columnRange.columnsStart && end <= columnRange.columnsEnd)
}
function selectColumnRange(xmlText: string) {
return selectColumnRanges(xmlText)[0]
}
function selectColumnRanges(xmlText: string) {
const ranges: Array<{ columnsStart: number; columnsEnd: number }> = []
const openTag = xmlText.match(/]*>/i)
const bodyStart = openTag && openTag.index !== undefined ? openTag.index + openTag[0].length : 0
const closeTag = xmlText.match(/<\/select\s*>/i)
const bodyEnd = closeTag && closeTag.index !== undefined ? closeTag.index : xmlText.length
const body = xmlText.slice(bodyStart, bodyEnd)
for (let index = 0; index < body.length; index += 1) {
if (!matchesSqlKeywordAt(body, 'select', index)) continue
const fromIndex = findTopLevelSqlKeyword(body.slice(index), 'from', 'select'.length)
if (fromIndex < 0) continue
ranges.push({
columnsStart: bodyStart + index + 'select'.length,
columnsEnd: bodyStart + index + fromIndex,
})
}
return ranges
}
function findTopLevelSqlKeyword(text: string, keyword: string, start: number) {
let quote = ''
let depth = 0
for (let index = start; index < text.length; index += 1) {
const char = text[index]
const next = text[index + 1]
if (quote) {
if (char === quote) {
if (quote === "'" && next === "'") {
index += 1
} else {
quote = ''
}
}
continue
}
if (char === '-' && next === '-') {
index = skipLineComment(text, index + 2)
continue
}
if (char === '/' && next === '*') {
index = skipBlockComment(text, index + 2)
continue
}
if (char === "'" || char === '"') {
quote = char
continue
}
if (char === '(') {
depth += 1
continue
}
if (char === ')') {
depth = Math.max(0, depth - 1)
continue
}
if (depth === 0 && matchesSqlKeywordAt(text, keyword, index)) return index
}
return -1
}
function matchesSqlKeywordAt(text: string, keyword: string, index: number) {
return text.toLowerCase().startsWith(keyword.toLowerCase(), index)
&& isSqlKeywordBoundary(text, index, keyword.length)
}
function isSqlKeywordBoundary(text: string, index: number, length: number) {
const before = text[index - 1] ?? ''
const after = text[index + length] ?? ''
return !/[A-Za-z0-9_]/.test(before) && !/[A-Za-z0-9_]/.test(after)
}
function skipLineComment(text: string, start: number) {
const end = text.indexOf('\n', start)
return end < 0 ? text.length : end
}
function skipBlockComment(text: string, start: number) {
const end = text.indexOf('*/', start)
return end < 0 ? text.length : end + 1
}
function splitTopLevelCsv(text: string) {
return splitTopLevelCsvItems(text).map((item) => item.text.trim())
}
function splitTopLevelCsvItems(text: string) {
const items: Array<{ text: string; start: number; end: number }> = []
let current = ''
let quote = false
let depth = 0
let itemStart = 0
for (let index = 0; index < text.length; index += 1) {
const char = text[index]
const next = text[index + 1]
if (char === "'") {
current += char
if (quote && next === "'") {
current += next
index += 1
} else {
quote = !quote
}
continue
}
if (!quote && char === '(') depth += 1
if (!quote && char === ')') depth = Math.max(0, depth - 1)
if (!quote && depth === 0 && char === ',') {
items.push({ text: current, start: itemStart, end: index })
current = ''
itemStart = index + 1
continue
}
current += char
}
if (current.trim()) items.push({ text: current, start: itemStart, end: text.length })
return items
}
function stripAlias(value: string): string {
const normalized = value
.replace(//g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim()
const explicitAlias = normalized.match(/\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i)
if (explicitAlias) return explicitAlias[1]
const implicitAlias = normalized.match(/^((?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?[A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)$/i)
if (implicitAlias && !isSqlReservedWord(implicitAlias[2])) return stripAlias(implicitAlias[1])
return normalized
.replace(/^.*\.([A-Za-z_][A-Za-z0-9_]*)$/, '$1')
.trim()
}
function isSqlReservedWord(value: string) {
return SQL_RESERVED_WORDS.has(value.toUpperCase())
}
function resultMapId(mapperItem: MapperItem, selectedText: string, targets: CryptoTarget[]) {
const existing = attrValue(mapperItem.attrs, 'resultMap')
if (existing) return existing
const dtoResultMapId = resultMapIdFromDtoResultType(attrValue(mapperItem.attrs, 'resultType'))
if (dtoResultMapId) return dtoResultMapId
const sqlPart = toPascal(mapperItem.id.replace(/^(select|get|find|query|list)/i, '')) || toPascal(mapperItem.id)
const tablePart = toPascal(commonTableName(targets))
const suffix = /result$/i.test(sqlPart) ? 'Map' : 'ResultMap'
const id = `${lowerFirst(sqlPart || tablePart)}${tablePart && !sqlPart.includes(tablePart) ? tablePart : ''}Crypto${suffix}`
return id.replace(/^[A-Z]/, (char) => char.toLowerCase())
}
function resultMapIdFromDtoResultType(resultType: string) {
if (!resultType) return ''
const dtoName = resultType.split('.').pop() ?? ''
return /^[A-Za-z_][A-Za-z0-9_]*Dto$/.test(dtoName) ? `${dtoName}Result` : ''
}
function commonTableName(targets: CryptoTarget[]) {
const table = targets[0]?.tableName || 'Crypto'
return table.replace(/^TB_/i, '')
}
function parseResultMaps(mapperXml: string): ResultMapDefinition[] {
const resultMaps: ResultMapDefinition[] = []
const resultMapPattern = /]*)>([\s\S]*?)<\/resultMap>/gi
for (const match of mapperXml.matchAll(resultMapPattern)) {
const attrs = match[1]
const body = match[2]
const entries: ResultMapEntry[] = []
for (const entryMatch of body.matchAll(/<(id|result)\b([^/>]*?)\/?>/gi)) {
const entryAttrs = entryMatch[2]
const column = attrValue(entryAttrs, 'column')
const property = attrValue(entryAttrs, 'property')
if (!column || !property) continue
entries.push({
kind: entryMatch[1].toLowerCase() === 'id' ? 'id' : 'result',
column,
property,
typeHandler: attrValue(entryAttrs, 'typeHandler') || undefined,
})
}
resultMaps.push({
id: attrValue(attrs, 'id'),
type: attrValue(attrs, 'type'),
entries,
})
}
return resultMaps
}
function findExistingResultMap(mapperXml: string, desired: ResultMapDefinition) {
return parseResultMaps(mapperXml).find((resultMap) => sameResultMapDefinition(resultMap, desired))
}
function sameResultMapDefinition(left: ResultMapDefinition, right: ResultMapDefinition) {
if (left.type !== right.type) return false
const leftEntries = left.entries.map(resultMapEntryKey).sort()
const rightEntries = right.entries.map(resultMapEntryKey).sort()
return leftEntries.length === rightEntries.length && leftEntries.every((entry, index) => entry === rightEntries[index])
}
function resultMapEntryKey(entry: ResultMapEntry) {
return [
entry.kind,
entry.column.toUpperCase(),
entry.property,
fqcn(entry.typeHandler).toLowerCase(),
].join('|')
}
function resultMapSnippet(definition: ResultMapDefinition) {
const lines = [
``,
...definition.entries.map((entry) => {
const typeHandler = entry.typeHandler ? ` typeHandler="${fqcn(entry.typeHandler)}"` : ''
return ` <${entry.kind} column="${entry.column}" property="${entry.property}"${typeHandler}/>`
}),
` `,
]
return lines.join('\n')
}
function inferDtoTypeFromMapperNamespace(mapperXml: string) {
const namespace = mapperXml.match(/]*\bnamespace\s*=\s*['"]([^'"]+)['"]/i)?.[1]
if (!namespace) return ''
if (/Mapper$/i.test(namespace)) return namespace.replace(/Mapper$/i, 'Dto')
return `${namespace}Dto`
}
function matchesTarget(text: string, target: CryptoTarget, tag?: MapperItem['tag']) {
if (!hasBoundaryToken(text, target.tableName)) return false
if (tag === 'select') return hasSelectRewritePoint(text, target)
return hasBoundaryToken(text, target.plainColumn)
|| hasBoundaryToken(text, target.encColumn)
|| Boolean(target.hashColumn && hasBoundaryToken(text, target.hashColumn))
}
function hasSelectRewritePoint(text: string, target: CryptoTarget) {
return selectedColumns(text).some((column) => equalsIgnoreCase(column, target.plainColumn) || equalsIgnoreCase(column, target.encColumn))
|| hasBoundaryToken(text, target.plainColumn)
|| hasBoundaryToken(text, target.encColumn)
|| hasWhereRewritePoint(text, target)
}
function hasWhereRewritePoint(text: string, target: CryptoTarget) {
const whereMatch = text.match(/\bwhere\b/i)
if (!whereMatch || whereMatch.index === undefined) return false
const whereBody = text.slice(whereMatch.index + whereMatch[0].length)
const qualifier = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?`
const column = escapeRegExp(target.plainColumn)
const property = escapeRegExp(target.javaProperty)
const parameter = String.raw`#\{\s*${property}\s*\}`
const equalsPattern = new RegExp(String.raw`${qualifier}\b${column}\b\s*=\s*${parameter}`, 'i')
const likePattern = new RegExp(
String.raw`${qualifier}\b${column}\b\s+LIKE\s*'%'\s*\|\|\s*${parameter}\s*\|\|\s*'%'`,
'i',
)
return equalsPattern.test(whereBody) || likePattern.test(whereBody)
}
function hasBoundaryToken(text: string, token: string) {
return token ? boundaryPattern(token).test(text) : false
}
function replaceFirstBoundary(text: string, token: string, replacement: string) {
const pattern = new RegExp(`(^|[^A-Za-z0-9_])(${escapeRegExp(token)})(?=$|[^A-Za-z0-9_])`, 'i')
return text.replace(pattern, (_match, prefix: string) => `${prefix}${replacement}`)
}
function boundaryPattern(token: string) {
return new RegExp(`(^|[^A-Za-z0-9_])${escapeRegExp(token)}(?=$|[^A-Za-z0-9_])`, 'i')
}
function attrValue(text: string, name: string) {
return text.match(new RegExp(`\\b${name}\\s*=\\s*['"]([^'"]+)['"]`, 'i'))?.[1] ?? ''
}
function bindPropertyPatternText(property: string) {
return `(?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?${escapeRegExp(property)}`
}
function normalizeBindPropertyExpression(propertyExpression: string) {
return propertyExpression.trim().replace(/\s*\.\s*/g, '.')
}
function findBindPropertyExpression(text: string, property: string) {
const match = text.match(new RegExp(`#\\{\\s*(${bindPropertyPatternText(property)})\\s*\\}`, 'i'))
return match ? normalizeBindPropertyExpression(match[1]) : ''
}
function replaceFirstPropertyBind(text: string, property: string, replacement: string) {
return text.replace(new RegExp(`#\\{\\s*${bindPropertyPatternText(property)}\\s*\\}`, 'i'), replacement)
}
function typeHandlerBind(property: string, handler?: string) {
const typeHandler = fqcn(handler)
return typeHandler ? `#{${property}, typeHandler=${typeHandler}}` : `#{${property}}`
}
function typeHandlerBindWithKmsComment(property: string, handler: string | undefined, date: string, markerUserId: string) {
return `${typeHandlerBind(property, handler)} ${kmsTypeHandlerComment(date, markerUserId)}`
}
function kmsTypeHandlerComment(date: string, markerUserId: string) {
return `/* [KMS-CIPHER] 수정일자 : ${date} 수정자 : ${markerUserId} */`
}
function fqcn(handler?: string) {
if (!handler) return ''
return handler.includes('.') ? handler : `${handler}`
}
function withComment(text: string, date: string) {
return `${text} `
}
function toCamel(value: string) {
return value
.toLowerCase()
.replace(/_([a-z0-9])/g, (_match, char: string) => char.toUpperCase())
}
function toUpperSnake(value: string) {
return value
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase()
}
function toPascal(value: string) {
const words = value
.replace(/^TB_/i, '')
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.split(/[^A-Za-z0-9]+|_/)
.filter(Boolean)
return words.map((word) => word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase()).join('')
}
function lowerFirst(value: string) {
return value.replace(/^./, (char) => char.toLowerCase())
}
function equalsIgnoreCase(left: string, right: string) {
return left.toUpperCase() === right.toUpperCase()
}
function includesIgnoreCase(text: string, value: string) {
return value ? text.toUpperCase().includes(value.toUpperCase()) : false
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function formatLocalDate(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatLocalDateTimeMinute(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${formatLocalDate(date)} ${hours}:${minutes}`
}
==============================
{
"name": "typehandler-helper",
"displayName": "TypeHandler Helper",
"description": "MyBatis mapper XML 선택 영역에 암복호화 TypeHandler 적용 추천 텍스트를 생성해 클립보드에 복사합니다.",
"version": "0.1.40",
"publisher": "leesumin",
"engines": {
"vscode": "^1.74.0",
"node": ">=16.0.0"
},
"categories": [
"Other"
],
"activationEvents": [
"onCommand:cryptoMapper.copyTypeHandlerRecommendation",
"onCommand:cryptoMapper.copyFullFileTypeHandlerRecommendation",
"onCommand:cryptoMapper.markCryptoTargetColumns",
"onCommand:cryptoMapper.markCryptoTargetColumnsInCurrentItem",
"onCommand:cryptoMapper.applyTypeHandlerRecommendationInCurrentItem",
"onCommand:cryptoMapper.applyConvertXmlRulesToSelection",
"onCommand:cryptoMapper.markSelectedColumnsHashInfo",
"onCommand:cryptoMapper.convertSelectionToLowerCamel",
"onCommand:cryptoMapper.convertSelectionToUpperSnake"
],
"main": "./dist/extension.js",
"contributes": {
"commands": [
{
"command": "cryptoMapper.copyTypeHandlerRecommendation",
"title": "Crypto Mapper: Copy TypeHandler Recommendation"
},
{
"command": "cryptoMapper.copyFullFileTypeHandlerRecommendation",
"title": "Crypto Mapper: Copy Full XML TypeHandler Recommendation"
},
{
"command": "cryptoMapper.markCryptoTargetColumns",
"title": "Crypto Mapper: Mark Crypto Target Columns"
},
{
"command": "cryptoMapper.markCryptoTargetColumnsInCurrentItem",
"title": "Crypto Mapper: Mark Crypto Target Columns In Current Mapper Item"
},
{
"command": "cryptoMapper.applyTypeHandlerRecommendationInCurrentItem",
"title": "Crypto Mapper: Apply TypeHandler Recommendation In Current Mapper Item"
},
{
"command": "cryptoMapper.applyConvertXmlRulesToSelection",
"title": "Crypto Mapper: Apply convert.xml Rules To Selection"
},
{
"command": "cryptoMapper.markSelectedColumnsHashInfo",
"title": "Crypto Mapper: Mark Selected Columns Hash Info"
},
{
"command": "cryptoMapper.convertSelectionToLowerCamel",
"title": "Crypto Mapper: Convert Selection To Lower Camel"
},
{
"command": "cryptoMapper.convertSelectionToUpperSnake",
"title": "Crypto Mapper: Convert Selection To Upper Snake"
}
],
"keybindings": [
{
"command": "cryptoMapper.copyTypeHandlerRecommendation",
"key": "ctrl+shift+4",
"mac": "cmd+shift+4",
"when": "editorTextFocus"
},
{
"command": "cryptoMapper.copyFullFileTypeHandlerRecommendation",
"key": "ctrl+shift+9",
"mac": "cmd+shift+9",
"when": "editorTextFocus"
},
{
"command": "cryptoMapper.markCryptoTargetColumns",
"key": "ctrl+shift+8",
"mac": "cmd+shift+8",
"when": "editorTextFocus"
},
{
"command": "cryptoMapper.markCryptoTargetColumnsInCurrentItem",
"key": "ctrl+shift+3",
"mac": "cmd+shift+3",
"when": "editorTextFocus"
},
{
"command": "cryptoMapper.applyTypeHandlerRecommendationInCurrentItem",
"key": "ctrl+shift+1",
"mac": "cmd+shift+1",
"when": "editorTextFocus"
},
{
"command": "cryptoMapper.applyConvertXmlRulesToSelection",
"key": "ctrl+shift+5",
"mac": "cmd+shift+5",
"when": "editorTextFocus"
},
{
"command": "cryptoMapper.markSelectedColumnsHashInfo",
"key": "ctrl+shift+6",
"mac": "cmd+shift+6",
"when": "editorTextFocus"
},
{
"command": "cryptoMapper.convertSelectionToLowerCamel",
"key": "ctrl+shift+f1",
"mac": "cmd+shift+f1",
"when": "editorTextFocus"
},
{
"command": "cryptoMapper.convertSelectionToUpperSnake",
"key": "ctrl+shift+f2",
"mac": "cmd+shift+f2",
"when": "editorTextFocus"
}
]
},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"package": "node -r ./scripts/node16-web-streams-polyfill.js ./node_modules/@vscode/vsce/vsce package",
"vscode:prepublish": "npm run compile"
},
"devDependencies": {
"@types/node": "16.18.126",
"@types/vscode": "^1.74.0",
"@vscode/vsce": "2.15.0",
"typescript": "^4.9.5"
},
"overrides": {
"cheerio": "1.0.0-rc.12"
}
}
댓글
댓글 쓰기