import * as fs from 'fs/promises'
import * as path from 'path'
import * as vscode from 'vscode'
type CryptoTarget = {
tableName: string
plainColumn: string
encColumn: string
hashColumn: string
javaProperty: string
encryptTypeHandler: string
hashTypeHandler: string
}
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
}
const DECRYPT_TARGET_COMMENT = '/* 복호화 대상 */'
const CRYPTO_TARGET_CHECK_COMMENT = '/* @@@ 암호화 대상 확인 필요 @@@ */'
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand('cryptoMapper.copyTypeHandlerRecommendation', async () => {
try {
await copyTypeHandlerRecommendation()
} 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}`)
}
}),
)
}
export function deactivate() {
// no-op
}
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 matchedTargets = targets.filter((target) => matchesTarget(selectedText, target, mapperItem.tag))
if (matchedTargets.length === 0) {
throw new Error('선택 영역에서 crypto-targets.json의 테이블명과 컬럼명이 함께 경계 일치하는 대상을 찾지 못했습니다.')
}
const date = formatLocalDate(new Date())
const fullDocumentText = editor.document.getText()
const recommendation = buildRecommendation(mapperItem, selectedText, 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}`)
}
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),
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()
}
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 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), afterSql)
} else if (tag === 'UPDATE') {
afterSql = matchedTargets.reduce((next, target) => addUpdateEncryption(next, target, date), afterSql)
} else if (tag === 'SELECT') {
afterSql = addSelectEncryption(selectedText, matchedTargets, date, markerUserId)
if (hasDecryptTargetComment(afterSql)) {
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 matchedTargets = targets.filter((target) => matchesTarget(itemText, target, mapperItem.tag))
if (matchedTargets.length === 0) {
chunks.push(itemText)
lastIndex = index + itemText.length
continue
}
const rewrite = rewriteMapperItemForFullFile(mapperItem, itemText, 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))
let nextItemText = itemText
const matchedTargets = targets.filter((target) => hasBoundaryToken(itemText, target.tableName) && hasBoundaryToken(itemText, target.plainColumn))
for (const target of matchedTargets) {
const result = markCryptoTargetColumnInItem(nextItemText, target)
nextItemText = result.text
changedCount += result.changedCount
}
chunks.push(nextItemText)
lastIndex = index + itemText.length
}
chunks.push(mapperXml.slice(lastIndex))
return { text: chunks.join(''), changedCount }
}
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 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), afterSql)
} else if (tag === 'UPDATE') {
afterSql = matchedTargets.reduce((next, target) => addUpdateEncryption(next, target, date), afterSql)
} else if (tag === 'SELECT') {
afterSql = addSelectEncryption(itemText, matchedTargets, date, markerUserId)
if (hasDecryptTargetComment(afterSql)) {
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) {
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*${escapeRegExp(target.javaProperty)}\\s*\\}(\\s*,?)\\s*$`, 'mi')
const columns = [target.plainColumn, target.encColumn, target.hashColumn].filter(Boolean)
const binds = [
`#{${target.javaProperty}}`,
withComment(typeHandlerBind(target.javaProperty, target.encryptTypeHandler), date),
target.hashColumn ? withComment(typeHandlerBind(target.javaProperty, target.hashTypeHandler), date) : '',
].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, comma: string) => {
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
return replaceFirstBoundary(head, target.plainColumn, columns.join(',\n '))
+ replaceFirstBoundary(tail, `#{${target.javaProperty}}`, binds.join(',\n '))
}
function addUpdateEncryption(xmlText: string, target: CryptoTarget, date: 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*${escapeRegExp(target.javaProperty)}\\s*\\}\\s*,?)(?:\\s*)?`,
'mi',
)
return xmlText.replace(plainAssign, (match, plainAssignment: string) => {
const indent = plainAssignment.match(/^\s*/)?.[0] ?? ''
const additions = [
hasBoundaryToken(xmlText, target.encColumn) ? '' : `${indent}${target.encColumn} = ${withComment(typeHandlerBind(target.javaProperty, target.encryptTypeHandler), date)},`,
target.hashColumn && !hasBoundaryToken(xmlText, target.hashColumn)
? `${indent}${target.hashColumn} = ${withComment(typeHandlerBind(target.javaProperty, target.hashTypeHandler), date)},`
: '',
].filter(Boolean)
if (additions.length === 0) return match
const plainAssignmentWithComma = /,\s*$/.test(plainAssignment) ? plainAssignment : `${plainAssignment},`
return [plainAssignmentWithComma, ...additions].join('\n')
})
}
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*/\\*\\s*복호화 대상\\s*\\*/)?`,
'i',
)
if (!aliasPattern.test(next)) {
next = replaceFirstSelectColumn(next, target, selectDecryptAlias(target, tableAlias))
} else {
next = next.replace(aliasPattern, (match) => {
return /\/\*\s*복호화 대상\s*\*\//.test(match) ? match : `${match} ${DECRYPT_TARGET_COMMENT}`
})
}
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 xmlText.includes(DECRYPT_TARGET_COMMENT)
}
function selectDecryptAlias(target: CryptoTarget, tableAlias: string) {
const qualifier = tableAlias ? `${tableAlias}.` : ''
return `NVL(${qualifier}${target.encColumn}, ${qualifier}${target.plainColumn}) AS ${target.plainColumn} ${DECRYPT_TARGET_COMMENT}`
}
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+${escapeRegExp(target.plainColumn)}\b`,
'i',
)
}
function replaceFirstSelectColumn(xmlText: string, target: CryptoTarget, replacement: string) {
if (selectDecryptAliasPattern(target).test(xmlText)) return xmlText
const selectBody = xmlText.match(/\bselect\b([\s\S]*?)\bfrom\b/i)
if (!selectBody || selectBody.index === undefined) return xmlText
const selectKeyword = selectBody[0].match(/\bselect\b/i)
const fromKeyword = selectBody[0].match(/\bfrom\b/i)
if (!selectKeyword || !fromKeyword || fromKeyword.index === undefined) {
return xmlText
}
const columnsStart = selectBody.index + (selectKeyword.index ?? 0) + selectKeyword[0].length
const columnsEnd = selectBody.index + fromKeyword.index
const before = xmlText.slice(0, columnsStart)
const columns = xmlText.slice(columnsStart, columnsEnd)
const after = xmlText.slice(columnsEnd)
const columnPattern = new RegExp(
`(^|[^A-Za-z0-9_\\.])((?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?${escapeRegExp(target.plainColumn)})(?=$|[^A-Za-z0-9_])`,
'i',
)
if (!columnPattern.test(columns)) return xmlText
return `${before}${columns.replace(columnPattern, (_match, prefix: string) => `${prefix}${replacement}`)}${after}`
}
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) => {
const conditionAlias = alias || tableAlias
return hashWhereFallbackCondition(target, conditionAlias, date, markerUserId)
}
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) {
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)})`,
` OR (${hashColumn} IS NULL AND UPPER(${plainColumn}) = UPPER(#{${target.javaProperty}})) ${marker}`,
].join('\n')
}
function cipherMarkerComment(date: string, markerUserId: string) {
const userIdPart = markerUserId ? ` ${markerUserId}` : ''
return `/* DS_PI_META_NET CIPHER_${date.replace(/-/g, '')}${userIdPart} */`
}
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()
for (const column of selectedColumns(selectedText)) {
const target = targets.find((item) => equalsIgnoreCase(item.plainColumn, column) || equalsIgnoreCase(item.encColumn, column))
const property = target?.javaProperty || toCamel(stripAlias(column))
const normalizedColumn = target?.plainColumn || stripAlias(column)
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: target ? fqcn(target.encryptTypeHandler) : undefined,
})
}
for (const target of targets) {
const key = `${target.plainColumn.toUpperCase()}|${target.javaProperty}`
if (!seen.has(key)) {
entries.push({
kind: 'result',
column: target.plainColumn,
property: target.javaProperty,
typeHandler: fqcn(target.encryptTypeHandler),
})
}
}
return entries
}
function selectedColumns(selectedText: string) {
const body = selectedText
.replace(/^]*>/i, '')
.replace(/<\/select>\s*$/i, '')
const match = body.match(/\bselect\b([\s\S]*?)\bfrom\b/i)
if (!match) return []
return splitTopLevelCsv(match[1])
.map((item) => stripAlias(item))
.filter((item) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(item))
}
function splitTopLevelCsv(text: string) {
const items: string[] = []
let current = ''
let quote = false
let depth = 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(current.trim())
current = ''
continue
}
current += char
}
if (current.trim()) items.push(current.trim())
return items
}
function stripAlias(value: string) {
return value
.replace(//g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim()
.replace(/\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i, '$1')
.replace(/^.*\.([A-Za-z_][A-Za-z0-9_]*)$/, '$1')
.replace(/^([A-Za-z_][A-Za-z0-9_]*)\s+[A-Za-z_][A-Za-z0-9_]*$/i, '$1')
.trim()
}
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))
|| 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 typeHandlerBind(property: string, handler?: string) {
const typeHandler = fqcn(handler)
return typeHandler ? `#{${property}, typeHandler=${typeHandler}}` : `#{${property}}`
}
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 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 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}`
}
댓글
댓글 쓰기