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[]
htypeCaptures: Array<{ value: string; column: 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
}
type MapperItemRange = {
start: number
end: number
text: string
}
type RemainingWorkItem = {
file: string
id: string
lines: 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 CRYPTO_PENDING_REVIEW_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.applyFolderRemainingWorkAudit', async (uri?: vscode.Uri) => {
try {
await applyFolderRemainingWorkAudit(uri)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Crypto Mapper remaining work audit 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 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 preMarkResult = await readDbIniFlag(dbIniPath, 'pre3')
? markCryptoTargetColumnsInMapperItem(focusedItem.text, targets, originalXml)
: undefined
const itemText = preMarkResult?.text ?? focusedItem.text
const mapperXmlForLookup = preMarkResult
? `${originalXml.slice(0, focusedItem.start)}${itemText}${originalXml.slice(focusedItem.end)}`
: originalXml
await applyCurrentMapperItemTypeHandlerRecommendation(
editor,
originalXml,
{ ...focusedItem, text: itemText },
parseSelectedMapperItem(itemText),
targets,
date,
markerUserId,
mapperXmlForLookup,
)
}
async function applyCurrentMapperItemTypeHandlerRecommendation(
editor: vscode.TextEditor,
originalXml: string,
focusedItem: MapperItemRange,
mapperItem: MapperItem,
targets: CryptoTarget[],
date: string,
markerUserId: string,
mapperXmlForLookup = originalXml,
) {
const expandedItemText = expandMapperItemIncludesForTargets(focusedItem.text, mapperXmlForLookup, 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, mapperXmlForLookup, matchedTargets, date, markerUserId)
const originalItemText = originalXml.slice(focusedItem.start, focusedItem.end)
if (rewrite.afterSql === originalItemText && 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 preMarkResult = await readDbIniFlag(dbIniPath, 'pre3')
? markCryptoTargetColumnsInMapperItem(selectedText, targets, fullDocumentText)
: undefined
const sourceSelectedText = preMarkResult?.text ?? selectedText
const sourceMapperItem = preMarkResult ? parseSelectedMapperItem(sourceSelectedText) : mapperItem
const mapperXmlForLookup = preMarkResult
? fullDocumentText.replace(selectedText, sourceSelectedText)
: fullDocumentText
const expandedSelectedText = expandMapperItemIncludesForTargets(sourceSelectedText, mapperXmlForLookup, targets, sourceMapperItem.tag).text
const matchedTargets = targets.filter((target) => matchesTarget(expandedSelectedText, target, sourceMapperItem.tag))
if (matchedTargets.length === 0) {
throw new Error('선택 영역에서 crypto-targets.json의 테이블명과 컬럼명이 함께 경계 일치하는 대상을 찾지 못했습니다.')
}
const date = formatLocalDate(new Date())
const recommendation = buildRecommendation(sourceMapperItem, expandedSelectedText, mapperXmlForLookup, 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 preMarkResult = await readDbIniFlag(dbIniPath, 'pre3')
? markCryptoTargetColumns(originalXml, targets)
: undefined
const sourceXml = preMarkResult?.text ?? originalXml
let recommendation: { clipboardText: string; changedItemCount: number; supportSnippets: string[] }
try {
recommendation = buildFullFileRecommendation(sourceXml, targets, date, markerUserId)
} catch (error) {
if (!preMarkResult || preMarkResult.text === originalXml) throw error
recommendation = {
clipboardText: preMarkResult.text,
changedItemCount: 0,
supportSnippets: [],
}
}
const fullRange = new vscode.Range(
editor.document.positionAt(0),
editor.document.positionAt(originalXml.length),
)
const applied = await editor.edit((editBuilder) => {
editBuilder.replace(fullRange, recommendation.clipboardText)
})
if (!applied) throw new Error('Failed to apply full XML recommendation to the editor.')
vscode.window.showInformationMessage(
`Crypto Mapper full XML recommendation applied. 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}`)
}
async function applyFolderRemainingWorkAudit(uri?: vscode.Uri) {
const selectedFolder = await resolveSelectedAuditFolder(uri)
const workspaceRoot = getWorkspaceRootForPath(selectedFolder)
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 xmlFiles = await findXmlFiles(selectedFolder)
const reportItems: RemainingWorkItem[] = []
let changedFileCount = 0
for (const filePath of xmlFiles) {
const originalXml = await fs.readFile(filePath, 'utf8')
const markedXml = markCryptoTargetColumns(originalXml, targets).text
let rewrittenXml = markedXml
try {
rewrittenXml = buildFullFileRecommendation(markedXml, targets, date, markerUserId).clipboardText
} catch {
rewrittenXml = markedXml
}
rewrittenXml = normalizePendingReviewComments(rewrittenXml)
if (rewrittenXml !== originalXml) {
await fs.writeFile(filePath, rewrittenXml, 'utf8')
changedFileCount += 1
}
reportItems.push(...collectRemainingWorkItems(filePath, selectedFolder, rewrittenXml))
}
const reportPath = path.join(selectedFolder, '남은작업대상.txt')
await fs.writeFile(reportPath, remainingWorkReportText(reportItems), 'utf8')
vscode.window.showInformationMessage(
`Crypto Mapper remaining work audit completed. XML files: ${xmlFiles.length}, changed files: ${changedFileCount}, remaining tags: ${reportItems.length}`,
)
}
async function resolveSelectedAuditFolder(uri?: vscode.Uri) {
if (uri?.fsPath) {
const stat = await fs.stat(uri.fsPath)
return stat.isDirectory() ? uri.fsPath : path.dirname(uri.fsPath)
}
const activeEditorPath = vscode.window.activeTextEditor?.document.uri.fsPath
const defaultUri = activeEditorPath ? vscode.Uri.file(path.dirname(activeEditorPath)) : vscode.workspace.workspaceFolders?.[0]?.uri
const selected = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
defaultUri,
openLabel: 'Select XML audit folder',
})
if (!selected?.[0]) throw new Error('Select a folder before running Ctrl+Shift+7.')
return selected[0].fsPath
}
function getWorkspaceRootForPath(filePath: string) {
const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)) ?? vscode.workspace.workspaceFolders?.[0]
if (!folder) return filePath
return folder.uri.fsPath
}
async function findXmlFiles(root: string) {
const files: string[] = []
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
}
for (const entry of entries) {
const entryPath = path.join(current, entry.name)
if (entry.isDirectory()) {
if (!ignored.has(entry.name)) queue.push(entryPath)
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xml')) {
files.push(entryPath)
}
}
}
return files.sort((left, right) => left.localeCompare(right))
}
function collectRemainingWorkItems(filePath: string, root: string, xmlText: string) {
const items: RemainingWorkItem[] = []
const itemPattern = /<(insert|update|select|delete)\b([^>]*)>[\s\S]*?<\/\1>/gi
for (const match of xmlText.matchAll(itemPattern)) {
const itemText = match[0]
if (!itemText.includes(CRYPTO_PENDING_REVIEW_COMMENT)) continue
const start = match.index ?? 0
const id = attrValue(match[2], 'id') || '(id 없음)'
const lines = pendingReviewLines(xmlText, itemText, start)
items.push({
file: path.relative(root, filePath) || path.basename(filePath),
id,
lines,
})
}
return items
}
function pendingReviewLines(fullText: string, itemText: string, itemStart: number) {
const lines: string[] = []
let offset = 0
for (const line of splitLinesWithEndings(itemText)) {
if (line.includes(CRYPTO_PENDING_REVIEW_COMMENT)) {
const lineNumber = lineNumberAtOffset(fullText, itemStart + offset)
lines.push(`L${lineNumber}: ${line.trim()}`)
}
offset += line.length
}
return lines
}
function lineNumberAtOffset(text: string, offset: number) {
return text.slice(0, offset).split(/\r\n|\r|\n/).length
}
function remainingWorkReportText(items: RemainingWorkItem[]) {
if (items.length === 0) return '남은 작업 대상 없음\r\n'
const lines: string[] = []
for (const item of items) {
lines.push(`${item.file} --> ${item.id}`)
for (const line of item.lines) {
lines.push(` --> ${line}`)
}
lines.push('')
}
return lines.join('\r\n')
}
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) {
return await readDbIniValue(dbIniPath, 'userid') ?? ''
}
async function readDbIniFlag(dbIniPath: string, key: string) {
const value = await readDbIniValue(dbIniPath, key)
return /^(?:y|yes|true|1|on)$/i.test(value ?? '')
}
async function readDbIniValue(dbIniPath: string, key: string) {
const text = await fs.readFile(dbIniPath, 'utf8')
const pattern = new RegExp(`^${escapeRegExp(key)}\\s*=\\s*(.+)$`, 'i')
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && !line.startsWith(';'))
.map((line) => line.match(pattern)?.[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
const ambiguity = rule.tableMustExist ? findConvertHtypeTableAmbiguity(text, captureResult, targets) : undefined
if (ambiguity) {
const annotated = markConvertHtypeAmbiguity(match, ambiguity)
if (annotated !== match) changedCount += 1
return annotated
}
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) => isAllConvertHtype(htype) || 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 isAllConvertHtype(htype: string) {
return equalsIgnoreCase(htype, 'ALL')
}
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[] = []
const htypeCaptures: Array<{ value: string; column: string }> = []
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) => (
(isAllConvertHtype(placeholder.htype ?? '') || equalsIgnoreCase(item.cryptoType, placeholder.htype ?? ''))
&& equalsIgnoreCase(item.plainColumn, columnName)
))
if (!target) return undefined
setConvertCapture(captured, placeholder.key, target.plainColumn)
setConvertCapture(captured, `HTYPE:${placeholder.htype}`, target.plainColumn)
if (isAllConvertHtype(placeholder.htype ?? '')) {
setConvertCapture(captured, 'ENC_HANDLER', fqcn(target.encryptTypeHandler))
setConvertCapture(captured, 'HASH_HANDLER', fqcn(target.hashTypeHandler))
}
matchedTargets.push(target)
htypeCaptures.push({ value, column: columnName })
} 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)
setConvertCapture(captured, placeholder.key, camelColumn)
setConvertCapture(captured, `CAMELHTP:${placeholder.camelHtype}`, camelColumn)
matchedTargets.push(target)
} else {
setConvertCapture(captured, placeholder.key, value)
}
}
return { captured, targets: matchedTargets, htypeCaptures }
}
function convertTargetsHaveTableInText(targets: CryptoTarget[], text: string) {
if (targets.length === 0) return false
return targets.every((target) => includesIgnoreCase(text, target.tableName))
}
function findConvertHtypeTableAmbiguity(
text: string,
captureResult: ConvertCaptureResult,
targets: CryptoTarget[],
) {
for (const capture of captureResult.htypeCaptures) {
const candidates = targets.filter((target) => (
equalsIgnoreCase(target.plainColumn, capture.column)
&& hasBoundaryToken(text, target.tableName)
))
const cryptoTypes = uniqueIgnoreCase(candidates.map((target) => target.cryptoType).filter(Boolean))
if (candidates.length > 1 && cryptoTypes.length > 1) {
return { value: capture.value, targets: candidates }
}
}
return undefined
}
function markConvertHtypeAmbiguity(
match: string,
ambiguity: { value: string; targets: CryptoTarget[] },
) {
const comment = convertHtypeAmbiguityComment(ambiguity.targets)
if (match.includes(comment) || convertHtypeAmbiguityCommentPattern().test(match)) return match
const valuePattern = new RegExp(escapeRegExp(ambiguity.value), 'i')
if (valuePattern.test(match)) {
return match.replace(valuePattern, (value) => `${value} ${comment}`)
}
const column = stripAlias(ambiguity.value)
return match.replace(
new RegExp(`(^|[^A-Za-z0-9_])(${escapeRegExp(column)})(?=$|[^A-Za-z0-9_])`, 'i'),
(_match, prefix: string, value: string) => `${prefix}${value} ${comment}`,
)
}
function convertHtypeAmbiguityComment(targets: CryptoTarget[]) {
const infos = targets
.map((target) => `${target.tableName}.${target.cryptoType}`)
.filter((value, index, array) => array.findIndex((item) => equalsIgnoreCase(item, value)) === index)
return `/*★★★${infos.join(' , ')} 암호화타입다름 선택필요★★★*/`
}
function convertHtypeAmbiguityCommentPattern() {
return /\/\*★★★[\s\S]*?암호화타입다름 선택필요★★★\*\//
}
function applyConvertTobe(tobe: string, captured: Map) {
const replaced = tobe.replace(/@@\(([^)]+)\)@@/g, (match: string, rawKey: string) => {
const key = rawKey.trim()
return captured.get(key) ?? captured.get(normalizeConvertCaptureKey(key)) ?? match
})
return applyConvertCaseMacros(replaced)
}
function setConvertCapture(captured: Map, key: string, value: string) {
captured.set(key, value)
captured.set(normalizeConvertCaptureKey(key), value)
}
function normalizeConvertCaptureKey(key: string) {
return key.replace(/\s+/g, '').toUpperCase()
}
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[] = []
const cleanSelectedText = removeCryptoTargetCheckComments(selectedText)
let afterSql = cleanSelectedText
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)
afterSql = matchedTargets.reduce((next, target) => addWhereHashRewrite(next, target, date, markerUserId), afterSql)
} else if (tag === 'SELECT') {
afterSql = addSelectEncryption(cleanSelectedText, matchedTargets, date, markerUserId)
if (shouldApplySelectResultMap(cleanSelectedText, afterSql, matchedTargets)) {
const desiredResultMap = desiredResultMapDefinition(mapperItem, cleanSelectedText, 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 === cleanSelectedText && supportSnippets.length === 0) {
throw new Error('대상은 찾았지만 적용 가능한 치환 지점을 찾지 못했습니다.')
}
const header = [
``,
].filter(Boolean).join('\n')
afterSql = finalizeMapperItemRewrite(selectedText, afterSql, matchedTargets)
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,
}
}
export function rewriteMapperXmlForTypeHandlerRecommendation(
mapperXml: string,
targets: CryptoTarget[],
date: string,
markerUserId: string,
) {
return buildFullFileRecommendation(mapperXml, targets, date, markerUserId)
}
export function markMapperXmlCryptoTargetColumnsForRecommendation(mapperXml: string, targets: CryptoTarget[]) {
return markCryptoTargetColumns(mapperXml, targets)
}
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)
const decryptRanges = selectDecryptExpressionRanges(itemText, target)
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 (isInsideRanges(columnStart, decryptRanges)) return match
if (pendingReviewCommentPatternAtStart().test(afterMatch)) return match
changedCount += 1
return `${prefix}${column} ${CRYPTO_PENDING_REVIEW_COMMENT}`
})
return { text, changedCount }
}
function selectDecryptExpressionRanges(text: string, target: CryptoTarget) {
const ranges: Array<{ start: number; end: number }> = []
const qualifier = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?`
const pattern = 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(?:\s*/\*[\s\S]*?\*/)?`,
'gi',
)
for (const match of text.matchAll(pattern)) {
const start = match.index ?? 0
ranges.push({ start, end: start + match[0].length })
}
return ranges
}
function markHashColumnInfoInText(text: string, targets: CryptoTarget[]) {
const activeTargets = targets.filter((target) => hasBoundaryToken(text, target.tableName))
const targetsByColumn = new Map()
for (const target of activeTargets) {
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) || hasCurrentHashColumnInfoAfter(text, start + column.length)) {
continue
}
next += text.slice(lastIndex, start + column.length)
next += ` ${currentHashColumnInfoComment(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 hasCurrentHashColumnInfoAfter(text: string, offset: number) {
return /^\s*\/\*\s*\([^*]*해시컬럼\s*(?:존재함|존재|없음)[^*]*\)\s*\*\//.test(text.slice(offset))
}
function currentHashColumnInfoComment(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()
const cleanItemText = removeCryptoTargetCheckComments(itemText)
let afterSql = cleanItemText
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)
afterSql = matchedTargets.reduce((next, target) => addWhereHashRewrite(next, target, date, markerUserId), afterSql)
} else if (tag === 'SELECT') {
afterSql = addSelectEncryption(cleanItemText, matchedTargets, date, markerUserId)
if (shouldApplySelectResultMap(cleanItemText, afterSql, matchedTargets)) {
let desiredResultMap = desiredResultMapDefinition(mapperItem, cleanItemText, 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: finalizeMapperItemRewrite(itemText, afterSql, matchedTargets),
supportSnippets,
}
}
function finalizeMapperItemRewrite(originalText: string, rewrittenText: string, matchedTargets: CryptoTarget[]) {
let next = restoreCryptoTargetCheckCommentsOnUnchangedLines(originalText, rewrittenText)
for (const target of matchedTargets) {
next = markCryptoTargetColumnInItem(next, target).text
}
return trimTrailingLineWhitespace(removeSatisfiedCryptoTargetCheckComments(next, matchedTargets))
}
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 (isMergeIntoText(xmlText)) {
return removeSatisfiedCryptoTargetCheckComments(addMergeEncryption(xmlText, target, date, markerUserId), [target])
}
const valuesAt = xmlText.search(/\bVALUES\b/i)
if (valuesAt < 0) return addInsertSelectEncryption(xmlText, target, date, markerUserId)
const indexed = addInsertValuesEncryptionByIndex(xmlText, target, date, markerUserId)
if (indexed !== xmlText) return removeSatisfiedCryptoTargetCheckComments(indexed, [target])
if (hasBoundaryToken(xmlText, target.encColumn) && (!target.hashColumn || hasBoundaryToken(xmlText, target.hashColumn))) {
return removeSatisfiedCryptoTargetCheckComments(xmlText, [target])
}
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 removeSatisfiedCryptoTargetCheckComments(nextHead + nextTail, [target])
}
const propertyExpression = findBindPropertyExpression(tail, target.javaProperty) || target.javaProperty
const binds = insertEncryptionBinds(target, propertyExpression, date, markerUserId)
return removeSatisfiedCryptoTargetCheckComments(
replaceFirstBoundary(head, target.plainColumn, columns.join(',\n '))
+ replaceFirstPropertyBind(tail, target.javaProperty, binds.join(',\n ')),
[target],
)
}
function isMergeIntoText(xmlText: string) {
return /\bMERGE\s+INTO\b/i.test(xmlText)
}
function addMergeEncryption(xmlText: string, target: CryptoTarget, date: string, markerUserId: string) {
let next = addMergeUsingSelectEncryption(xmlText, target, date, markerUserId)
next = addMergeUpdateSetEncryption(next, target)
next = addInsertValuesEncryptionByIndex(next, target, date, markerUserId)
return next
}
function addMergeUsingSelectEncryption(xmlText: string, target: CryptoTarget, date: string, markerUserId: string) {
const ranges = mergeUsingSelectRanges(xmlText)
if (!ranges) return xmlText
const selectItems = splitTopLevelCsvItems(xmlText.slice(ranges.selectStart, ranges.selectEnd))
const targetIndex = selectItems.findIndex((item) => equalsIgnoreCase(stripAlias(item.text), target.plainColumn))
if (targetIndex < 0) return xmlText
const existingAliases = new Set(selectItems.map((item) => stripAlias(item.text).toUpperCase()))
const propertyExpression = bindPropertyExpressionFromValue(selectItems[targetIndex].text)
if (!propertyExpression) return xmlText
const additions = [
existingAliases.has(target.encColumn.toUpperCase())
? ''
: `${typeHandlerBind(propertyExpression, target.encryptTypeHandler)} AS ${target.encColumn} ${kmsTypeHandlerComment(date, markerUserId)}`,
target.hashColumn && !existingAliases.has(target.hashColumn.toUpperCase())
? `${typeHandlerBind(propertyExpression, target.hashTypeHandler)} AS ${target.hashColumn} ${kmsTypeHandlerComment(date, markerUserId)}`
: '',
].filter(Boolean)
return insertItemsAfterCsvIndex(xmlText, ranges.selectStart, ranges.selectEnd, targetIndex, additions)
}
function addMergeUpdateSetEncryption(xmlText: string, target: CryptoTarget) {
const ranges = mergeUpdateSetRange(xmlText)
if (!ranges) return xmlText
const setItems = splitTopLevelCsvItems(xmlText.slice(ranges.start, ranges.end))
const targetIndex = setItems.findIndex((item) => mergeUpdateAssignmentParts(item.text, target.plainColumn))
if (targetIndex < 0) return xmlText
const existingAssignments = new Set(
setItems
.map((item) => item.text.match(/^\s*((?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?[A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1] ?? '')
.map((column) => stripAlias(column).toUpperCase())
.filter(Boolean),
)
const parts = mergeUpdateAssignmentParts(setItems[targetIndex].text, target.plainColumn)
if (!parts) return xmlText
const additions = [
existingAssignments.has(target.encColumn.toUpperCase()) ? '' : `${parts.leftQualifier}${target.encColumn} = ${parts.rightQualifier}${target.encColumn}`,
target.hashColumn && !existingAssignments.has(target.hashColumn.toUpperCase())
? `${parts.leftQualifier}${target.hashColumn} = ${parts.rightQualifier}${target.hashColumn}`
: '',
].filter(Boolean)
return insertItemsAfterCsvIndex(xmlText, ranges.start, ranges.end, targetIndex, additions)
}
function mergeUpdateAssignmentParts(text: string, plainColumn: string) {
const identifier = String.raw`([A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?${escapeRegExp(plainColumn)}`
const match = text.match(new RegExp(String.raw`^\s*${identifier}\s*=\s*${identifier}\s*$`, 'i'))
if (!match) return undefined
return {
leftQualifier: normalizeSqlQualifier(match[1] ?? ''),
rightQualifier: normalizeSqlQualifier(match[2] ?? ''),
}
}
function normalizeSqlQualifier(qualifier: string) {
return qualifier ? `${qualifier.replace(/\s+/g, '')}` : ''
}
function mergeUsingSelectRanges(xmlText: string) {
const usingMatch = /\bUSING\s*\(/i.exec(xmlText)
if (!usingMatch || usingMatch.index === undefined) return undefined
const usingOpen = xmlText.indexOf('(', usingMatch.index)
if (usingOpen < 0) return undefined
const usingClose = findMatchingParen(xmlText, usingOpen)
if (usingClose < 0) return undefined
const selectAt = findSqlKeywordAtParenDepth(xmlText, 'select', usingOpen + 1, usingClose, 1)
if (selectAt < 0) return undefined
const fromAt = findSqlKeywordAtParenDepth(xmlText, 'from', selectAt + 'select'.length, usingClose, 1)
if (fromAt < 0) return undefined
return {
selectStart: selectAt + 'select'.length,
selectEnd: fromAt,
}
}
function mergeUpdateSetRange(xmlText: string) {
const updateSetMatch = /\bWHEN\s+MATCHED\s+THEN\s+UPDATE\s+SET\b/i.exec(xmlText)
if (!updateSetMatch || updateSetMatch.index === undefined) return undefined
const start = updateSetMatch.index + updateSetMatch[0].length
const nextWhen = findSqlKeywordAtParenDepth(xmlText, 'when', start, xmlText.length, 0)
return {
start,
end: nextWhen < 0 ? xmlText.length : nextWhen,
}
}
function addInsertValuesEncryptionByIndex(xmlText: string, target: CryptoTarget, date: string, markerUserId: string) {
const ranges = insertValuesRanges(xmlText)
if (!ranges) return xmlText
const columnItems = splitTopLevelCsvItems(xmlText.slice(ranges.columnsStart, ranges.columnsEnd))
const valueItems = splitTopLevelCsvItems(xmlText.slice(ranges.valuesStart, ranges.valuesEnd))
const targetIndex = columnItems.findIndex((item) => equalsIgnoreCase(stripAlias(item.text), target.plainColumn))
if (targetIndex < 0 || targetIndex >= valueItems.length) return xmlText
const existingColumns = new Set(columnItems.map((item) => stripAlias(item.text).toUpperCase()))
const valueExpression = valueItems[targetIndex].text
const propertyExpression = bindPropertyExpressionFromValue(valueExpression)
if (!propertyExpression) return xmlText
const allBinds = insertEncryptionBinds(target, propertyExpression, date, markerUserId)
const desired = [
{ column: target.encColumn, bind: allBinds[1] },
{ column: target.hashColumn, bind: allBinds[2] },
].filter((item) => item.column && item.bind)
const missingColumns = desired.filter((item) => !existingColumns.has(item.column.toUpperCase()))
const missingValues = desired.filter((item) => {
const columnIndex = columnItems.findIndex((columnItem) => equalsIgnoreCase(stripAlias(columnItem.text), item.column))
if (columnIndex < 0) return true
const value = valueItems[columnIndex]?.text ?? ''
return !insertCryptoValueMatchesHandler(value, item.bind)
})
if (missingColumns.length === 0 && missingValues.length === 0) return xmlText
const withValues = insertItemsAfterCsvIndex(xmlText, ranges.valuesStart, ranges.valuesEnd, targetIndex, missingValues.map((item) => item.bind))
return insertItemsAfterCsvIndex(withValues, ranges.columnsStart, ranges.columnsEnd, targetIndex, missingColumns.map((item) => item.column))
}
function insertCryptoValueMatchesHandler(value: string, bind: string) {
const expectedHandler = bind.match(/\btypeHandler\s*=\s*([^}\s,]+)/i)?.[1]
if (!expectedHandler) return Boolean(value.trim())
return new RegExp(`\\btypeHandler\\s*=\\s*${escapeRegExp(expectedHandler)}\\b`, 'i').test(value)
}
function addInsertSelectEncryption(xmlText: string, target: CryptoTarget, date: string, markerUserId: string) {
const ranges = insertSelectRanges(xmlText)
if (!ranges) return xmlText
const columnItems = splitTopLevelCsvItems(xmlText.slice(ranges.columnsStart, ranges.columnsEnd))
const selectItems = splitTopLevelCsvItems(xmlText.slice(ranges.selectStart, ranges.selectEnd))
const targetIndex = columnItems.findIndex((item) => equalsIgnoreCase(stripAlias(item.text), target.plainColumn))
if (targetIndex < 0 || targetIndex >= selectItems.length) return xmlText
if (!insertSelectHasSourceTable(xmlText, target.tableName)) return xmlText
const sourceAlias = findTableAlias(xmlText, target.tableName)
const qualifier = sourceAlias ? `${sourceAlias}.` : ''
const existingColumns = new Set(columnItems.map((item) => stripAlias(item.text).toUpperCase()))
const existingSelectAliases = new Set(selectItems.map((item) => stripAlias(item.text).toUpperCase()))
const columns = [target.encColumn, target.hashColumn]
.filter((column) => column && !existingColumns.has(column.toUpperCase()) && !existingSelectAliases.has(column.toUpperCase()))
const selectColumns = columns.map((column) => `${qualifier}${column} ${kmsTypeHandlerComment(date, markerUserId)}`)
if (columns.length === 0) return xmlText
const withSelect = insertItemsAfterCsvIndex(xmlText, ranges.selectStart, ranges.selectEnd, targetIndex, selectColumns)
return insertItemsAfterCsvIndex(withSelect, ranges.columnsStart, ranges.columnsEnd, targetIndex, columns)
}
function insertValuesRanges(xmlText: string) {
const valuesAt = xmlText.search(/\bVALUES\b/i)
if (valuesAt < 0) return undefined
const columnsOpen = findPreviousOpenParen(xmlText, valuesAt)
const valuesOpen = findNextOpenParen(xmlText, valuesAt)
if (columnsOpen < 0 || valuesOpen < 0) return undefined
const columnsClose = findMatchingParen(xmlText, columnsOpen)
const valuesClose = findMatchingParen(xmlText, valuesOpen)
if (columnsClose < 0 || valuesClose < 0) return undefined
return {
columnsStart: columnsOpen + 1,
columnsEnd: columnsClose,
valuesStart: valuesOpen + 1,
valuesEnd: valuesClose,
}
}
function insertSelectRanges(xmlText: string) {
const selectAt = findTopLevelSqlKeyword(xmlText, 'select', 0)
if (selectAt < 0) return undefined
const fromAt = findTopLevelSqlKeyword(xmlText, 'from', selectAt + 'select'.length)
if (fromAt < 0) return undefined
const columnsOpen = findPreviousOpenParen(xmlText, selectAt)
if (columnsOpen < 0) return undefined
const columnsClose = findMatchingParen(xmlText, columnsOpen)
if (columnsClose < 0 || columnsClose > selectAt) return undefined
return {
columnsStart: columnsOpen + 1,
columnsEnd: columnsClose,
selectStart: selectAt + 'select'.length,
selectEnd: fromAt,
}
}
function insertItemsAfterCsvIndex(text: string, start: number, end: number, index: number, additions: string[]) {
if (additions.length === 0) return text
const content = text.slice(start, end)
const items = splitTopLevelCsvItems(content)
if (index < 0 || index >= items.length) return text
const target = items[index]
const isLastItem = index === items.length - 1
const trailingLineIndent = target.text.match(/(\r?\n[ \t]*)$/)?.[1] ?? ''
const delimiterIndent = csvDelimiterIndent(text, start, items, index)
const insertionOffset = start + target.end - (isLastItem ? trailingLineIndent.length : 0)
const firstPrefix = !isLastItem && trailingLineIndent ? '' : delimiterIndent
const insertedItems = additions
.map((item, additionIndex) => `${additionIndex === 0 ? firstPrefix : delimiterIndent}, ${item.trim()}`)
.join('')
const trailingPrefix = !isLastItem && trailingLineIndent ? delimiterIndent : ''
return `${text.slice(0, insertionOffset)}${insertedItems}${trailingPrefix}${text.slice(insertionOffset)}`
}
function csvDelimiterIndent(text: string, start: number, items: Array<{ text: string; start: number; end: number }>, index: number) {
const targetTrailingIndent = items[index].text.match(/(\r?\n[ \t]+)$/)?.[1]
if (targetTrailingIndent && index < items.length - 1) return targetTrailingIndent
const previousTrailingIndent = index > 0 ? items[index - 1].text.match(/(\r?\n[ \t]+)$/)?.[1] : undefined
if (previousTrailingIndent) return previousTrailingIndent
const nextTrailingIndent = index + 1 < items.length ? items[index + 1].text.match(/(\r?\n[ \t]+)$/)?.[1] : undefined
if (nextTrailingIndent) return nextTrailingIndent
return `\n${lineLeadingWhitespace(text, start)} `
}
function bindPropertyExpressionFromValue(value: string) {
return value.match(/#\{\s*([^,}]+)(?:,[^}]*)?\}/)?.[1]?.trim().replace(/\s*\.\s*/g, '.')
}
function insertSelectHasSourceTable(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|<\/insert>|$)/i)
return fromMatch ? hasBoundaryToken(fromMatch[1], tableName) : false
}
function findPreviousOpenParen(text: string, before: number) {
for (let index = before - 1; index >= 0; index -= 1) {
if (text[index] === '(') return index
}
return -1
}
function findNextOpenParen(text: string, after: number) {
for (let index = after; index < text.length; index += 1) {
if (text[index] === '(') return index
}
return -1
}
function findMatchingParen(text: string, openIndex: number) {
let quote = ''
let depth = 0
for (let index = openIndex; 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
if (char === ')') {
depth -= 1
if (depth === 0) return index
}
}
return -1
}
function addUpdateEncryption(xmlText: string, target: CryptoTarget, date: string, markerUserId: string) {
if (hasBoundaryToken(xmlText, target.encColumn) && (!target.hashColumn || hasBoundaryToken(xmlText, target.hashColumn))) {
return removeSatisfiedCryptoTargetCheckComments(xmlText, [target])
}
const plainAssign = new RegExp(
`((?:^\\s*|\\bSET\\s+|,\\s*)(?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?${escapeRegExp(target.plainColumn)}${cryptoReviewCommentPatternText()}\\s*=\\s*#\\{\\s*(${bindPropertyPatternText(target.javaProperty)})\\s*\\}[ \\t]*,?)(?:[ \\t]*(?:|/\\*[\\s\\S]*?\\*/))?`,
'mi',
)
const rewritten = xmlText.replace(plainAssign, (match, plainAssignment: string, propertyExpression: string, offset: number, source: 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 trimmedPlainAssignment = plainAssignment.trimEnd()
const plainAssignmentWithComma = /,$/.test(trimmedPlainAssignment) ? trimmedPlainAssignment : `${trimmedPlainAssignment},`
const additionLines = additions.map((addition, index) => {
return index < additions.length - 1 ? `${addition},` : addition
})
const trailingNewline = /^[ \t]*\bWHERE\b/i.test(source.slice(offset + match.length)) ? '\n' : ''
return `${[plainAssignmentWithComma, ...additionLines].join('\n')}${trailingNewline}`
})
return removeSatisfiedCryptoTargetCheckComments(rewritten, [target])
}
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)
}
next = addWhereColumnHashRewrite(next, targets, 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) {
return isSimpleTargetSelectColumnItem(itemText, target.plainColumn)
|| isSimpleTargetSelectColumnItem(itemText, target.encColumn)
}
function isSimpleTargetSelectColumnItem(itemText: string, columnName: string) {
if (!columnName) return false
const clean = removeCryptoTargetCheckComments(removeDecryptTargetComments(itemText)).trim()
const qualifier = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?`
const alias = String.raw`(?:\s+(?:AS\s+)?[A-Za-z_][A-Za-z0-9_]*)?`
return new RegExp(String.raw`^${qualifier}${escapeRegExp(columnName)}${alias}\s*$`, 'i').test(clean)
}
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 originalWhereBody = xmlText.slice(whereMatch.index + whereMatch[0].length)
let whereBody = originalWhereBody
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 anyParameter = String.raw`#\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*)(?:\s*,[^}]*)?\s*\}`
const likeExpression = String.raw`(?:'%'\s*\|\|\s*)?${anyParameter}(?:\s*\|\|\s*'%')?`
const checkComment = cryptoReviewCommentPatternText()
const inPattern = new RegExp(String.raw`${qualifier}\b${column}\b${checkComment}\s+(NOT\s+IN|IN)\b`, 'gi')
const likePattern = new RegExp(String.raw`${qualifier}\b${column}\b${checkComment}\s+LIKE\s*${likeExpression}`, 'gi')
const equalsPattern = new RegExp(String.raw`${qualifier}\b${column}\b${checkComment}\s*=\s*${anyParameter}`, 'gi')
whereBody = whereBody.replace(
inPattern,
(_match: string, alias: string | undefined, operator: string) => {
const conditionAlias = alias || tableAlias
return `${hashColumnReference(target, conditionAlias)} ${operator.replace(/\s+/g, ' ').toUpperCase()}`
},
)
whereBody = whereBody.replace(
likePattern,
(_match: string, alias: string | undefined, propertyExpression: string) => {
const conditionAlias = alias || tableAlias
return hashWhereCondition(target, conditionAlias, normalizeBindPropertyExpression(propertyExpression), date, markerUserId)
},
)
whereBody = whereBody.replace(
equalsPattern,
(_match: string, alias: string | undefined, propertyExpression: string) => {
const conditionAlias = alias || tableAlias
return hashWhereCondition(target, conditionAlias, normalizeBindPropertyExpression(propertyExpression), date, markerUserId)
},
)
return whereBody === originalWhereBody ? xmlText : `${beforeWhereBody}${whereBody}`
}
function addWhereColumnHashRewrite(xmlText: string, targets: CryptoTarget[], date: string, markerUserId = '') {
const hashTargets = targets.filter((target) => target.hashColumn && target.hashTypeHandler)
if (hashTargets.length < 2) 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 originalWhereBody = xmlText.slice(whereMatch.index + whereMatch[0].length)
let whereBody = originalWhereBody
const qualifier = String.raw`(?:(\w+)\s*\.\s*)?`
const checkComment = cryptoReviewCommentPatternText()
for (const leftTarget of hashTargets) {
const leftColumn = escapeRegExp(leftTarget.plainColumn)
const leftFallbackAlias = findTableAlias(xmlText, leftTarget.tableName)
for (const rightTarget of hashTargets) {
if (leftTarget === rightTarget) continue
const rightColumn = escapeRegExp(rightTarget.plainColumn)
const rightFallbackAlias = findTableAlias(xmlText, rightTarget.tableName)
const equalsPattern = new RegExp(
String.raw`${qualifier}\b${leftColumn}\b${checkComment}\s*=\s*${qualifier}\b${rightColumn}\b${checkComment}`,
'gi',
)
whereBody = whereBody.replace(
equalsPattern,
(_match: string, leftAlias: string | undefined, rightAlias: string | undefined) => {
const leftHash = hashColumnReference(leftTarget, leftAlias || leftFallbackAlias)
const rightHash = hashColumnReference(rightTarget, rightAlias || rightFallbackAlias)
return `${leftHash} = ${rightHash} ${kmsTypeHandlerComment(date, markerUserId)}`
},
)
}
}
return whereBody === originalWhereBody ? xmlText : `${beforeWhereBody}${whereBody}`
}
function hashWhereCondition(target: CryptoTarget, alias: string, propertyExpression: string, date: string, markerUserId: string) {
return `${hashColumnReference(target, alias)} = ${typeHandlerBindWithKmsComment(propertyExpression, target.hashTypeHandler, date, markerUserId)}`
}
function hashColumnReference(target: CryptoTarget, alias: string) {
const qualifier = alias ? `${alias}.` : ''
return `${qualifier}${target.hashColumn}`
}
function cryptoReviewCommentPatternText() {
return String.raw`(?:\s*${blockCommentPatternText()})*`
}
function blockCommentPatternText() {
return String.raw`\/\*(?:[^*]|\*(?!\/))*\*\/`
}
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('')
.split(CRYPTO_PENDING_REVIEW_COMMENT)
.join('')
.replace(/\/\*\s*@@@[\s\S]*?@@@\s*\*\//g, '')
.replace(/\/\*\s*암호화\s*(?:작업\s*)?확인\s*필요\s*\*\//g, '')
}
function normalizePendingReviewComments(text: string) {
return text
.split(CRYPTO_TARGET_CHECK_COMMENT)
.join(CRYPTO_PENDING_REVIEW_COMMENT)
.replace(/\/\*\s*@@@[\s\S]*?@@@\s*\*\//g, CRYPTO_PENDING_REVIEW_COMMENT)
.replace(/\/\*\s*암호화\s*(?:작업\s*)?확인\s*필요\s*\*\//g, CRYPTO_PENDING_REVIEW_COMMENT)
}
function pendingReviewCommentPatternAtStart() {
return new RegExp(
`^\\s*(?:${escapeRegExp(CRYPTO_TARGET_CHECK_COMMENT)}|${escapeRegExp(CRYPTO_PENDING_REVIEW_COMMENT)}|\\/\\*\\s*@@@[\\s\\S]*?@@@\\s*\\*\\/|\\/\\*\\s*암호화\\s*(?:작업\\s*)?확인\\s*필요\\s*\\*\\/)`,
'i',
)
}
function removeSatisfiedCryptoTargetCheckComments(text: string, targets: CryptoTarget[]) {
let next = text
for (const target of targets) {
if (!hasBoundaryToken(next, target.encColumn) || (target.hashColumn && !hasBoundaryToken(next, target.hashColumn))) {
continue
}
const pattern = new RegExp(
`(^|[^A-Za-z0-9_\\.'])((?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?${escapeRegExp(target.plainColumn)})\\s*(?:\\/\\*\\s*@@@[\\s\\S]*?@@@\\s*\\*\\/|${escapeRegExp(CRYPTO_PENDING_REVIEW_COMMENT)})[ \\t]*(?=([,=])|\\r?\\n|$)`,
'gi',
)
next = next.replace(pattern, (_match, prefix: string, column: string, delimiter: string | undefined) => {
return `${prefix}${column}${delimiter === '=' ? ' ' : ''}`
})
const plainColumnPattern = `(^|[^A-Za-z0-9_\\.'])((?:[A-Za-z_][A-Za-z0-9_]*\\s*\\.\\s*)?${escapeRegExp(target.plainColumn)})`
next = next
.replace(new RegExp(`${plainColumnPattern}[ \\t]+(?=,)`, 'gi'), (_match, prefix: string, column: string) => `${prefix}${column}`)
.replace(new RegExp(`${plainColumnPattern}[ \\t]+(?==)`, 'gi'), (_match, prefix: string, column: string) => `${prefix}${column} `)
}
return next
}
function trimTrailingLineWhitespace(text: string) {
return text.replace(/[ \t]+(\r?\n)/g, '$1')
}
function restoreCryptoTargetCheckCommentsOnUnchangedLines(originalText: string, rewrittenText: string) {
if (!hasPendingReviewComment(originalText)) return rewrittenText
const originalLines = splitLinesWithEndings(originalText)
const cleanOriginalLines = originalLines.map((line) => removeCryptoTargetCheckComments(line))
const rewrittenLines = splitLinesWithEndings(rewrittenText)
const matches = lineMatchesByLcs(cleanOriginalLines, rewrittenLines)
if (matches.size === 0) return rewrittenText
return rewrittenLines
.map((line, index) => {
const originalIndex = matches.get(index)
if (originalIndex === undefined) return line
const originalLine = originalLines[originalIndex]
return hasPendingReviewComment(originalLine) ? originalLine : line
})
.join('')
}
function hasPendingReviewComment(text: string) {
return text.includes(CRYPTO_TARGET_CHECK_COMMENT)
|| text.includes(CRYPTO_PENDING_REVIEW_COMMENT)
|| /\/\*\s*@@@[\s\S]*?@@@\s*\*\//.test(text)
|| /\/\*\s*암호화\s*(?:작업\s*)?확인\s*필요\s*\*\//.test(text)
}
function splitLinesWithEndings(text: string) {
const lines = text.match(/[^\r\n]*(?:\r\n|\r|\n|$)/g) ?? ['']
return lines.length > 1 && lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
}
function lineMatchesByLcs(left: string[], right: string[]) {
const lengths: number[][] = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0))
for (let leftIndex = left.length - 1; leftIndex >= 0; leftIndex -= 1) {
for (let rightIndex = right.length - 1; rightIndex >= 0; rightIndex -= 1) {
lengths[leftIndex][rightIndex] = left[leftIndex] === right[rightIndex]
? lengths[leftIndex + 1][rightIndex + 1] + 1
: Math.max(lengths[leftIndex + 1][rightIndex], lengths[leftIndex][rightIndex + 1])
}
}
const matches = new Map()
let leftIndex = 0
let rightIndex = 0
while (leftIndex < left.length && rightIndex < right.length) {
if (left[leftIndex] === right[rightIndex]) {
matches.set(rightIndex, leftIndex)
leftIndex += 1
rightIndex += 1
} else if (lengths[leftIndex + 1][rightIndex] >= lengths[leftIndex][rightIndex + 1]) {
leftIndex += 1
} else {
rightIndex += 1
}
}
return matches
}
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 findSqlKeywordAtParenDepth(text: string, keyword: string, start: number, end: number, targetDepth: number) {
let quote = ''
let depth = 0
for (let index = 0; index < end; 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 (index >= start && depth === targetDepth && matchesSqlKeywordAt(text, keyword, index)) return index
if (char === '(') {
depth += 1
continue
}
if (char === ')') {
depth = Math.max(0, depth - 1)
continue
}
}
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 bindDepth = 0
let itemStart = 0
for (let index = 0; index < text.length; index += 1) {
const char = text[index]
const next = text[index + 1]
if (!quote && bindDepth > 0) {
current += char
if (char === '{') bindDepth += 1
if (char === '}') bindDepth = Math.max(0, bindDepth - 1)
continue
}
if (char === "'") {
current += char
if (quote && next === "'") {
current += next
index += 1
} else {
quote = !quote
}
continue
}
if (!quote && (char === '#' || char === '$') && next === '{') {
current += char
current += next
bindDepth = 1
index += 1
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 anyParameter = String.raw`#\{\s*[A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*(?:\s*,[^}]*)?\s*\}`
const likeExpression = String.raw`(?:'%'\s*\|\|\s*)?${anyParameter}(?:\s*\|\|\s*'%')?`
const checkComment = cryptoReviewCommentPatternText()
const equalsPattern = new RegExp(String.raw`${qualifier}\b${column}\b${checkComment}\s*=\s*${anyParameter}`, 'i')
const likePattern = new RegExp(
String.raw`${qualifier}\b${column}\b${checkComment}\s+LIKE\s*${likeExpression}`,
'i',
)
const inPattern = new RegExp(String.raw`${qualifier}\b${column}\b${checkComment}\s+(?:NOT\s+IN|IN)\b`, 'i')
return equalsPattern.test(whereBody) || likePattern.test(whereBody) || inPattern.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}`
}
댓글
댓글 쓰기