xml 분석기
const fs = require('fs/promises');
const path = require('path');
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',
]);
async function main() {
const appDir = __dirname;
const inputPath = path.join(appDir, 'input.ini');
const targetsPath = path.join(appDir, 'crypto-targets.json');
const outputPath = path.join(appDir, 'output.txt');
const targetMapperPath = await readIniValue(inputPath, 'targetmapperpath');
if (!targetMapperPath) {
throw new Error(`input.ini has no targetmapperpath value: ${inputPath}`);
}
const mapperRoot = path.isAbsolute(targetMapperPath)
? targetMapperPath
: path.resolve(appDir, targetMapperPath);
const targets = await readCryptoTargets(targetsPath);
const xmlFiles = await findXmlFiles(mapperRoot);
const lines = [];
for (const xmlFile of xmlFiles) {
const xml = await fs.readFile(xmlFile, 'utf8');
const summary = summarizeTargetMapperItems(xml, targets);
if (summary.total === 0) continue;
lines.push([
xmlFile,
path.basename(xmlFile),
`${summary.total},${summary.select},${summary.insert},${summary.update}`,
].join('|'));
}
await fs.writeFile(outputPath, `${lines.join('\n')}${lines.length ? '\n' : ''}`, 'utf8');
console.log(`Done. XML files: ${xmlFiles.length}, output: ${outputPath}`);
}
async function readIniValue(filePath, key) {
const text = await fs.readFile(filePath, 'utf8');
const pattern = new RegExp(`^${escapeRegExp(key)}\\s*=\\s*(.+)$`, 'i');
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
const match = line.match(pattern);
if (!match) continue;
return match[1].trim().replace(/^['"]|['"]$/g, '');
}
return '';
}
async function readCryptoTargets(filePath) {
const text = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) {
throw new Error(`crypto-targets.json must be an array: ${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);
}
async function findXmlFiles(rootDir) {
const result = [];
const queue = [rootDir];
while (queue.length > 0) {
const current = queue.shift();
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
queue.push(fullPath);
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xml')) {
result.push(fullPath);
}
}
}
return result.sort((left, right) => left.localeCompare(right));
}
function summarizeTargetMapperItems(mapperXml, targets) {
const summary = { total: 0, select: 0, insert: 0, update: 0 };
const itemPattern = /<(insert|update|select|delete)\b([^>]*)>[\s\S]*?<\/\1>/gi;
for (const match of mapperXml.matchAll(itemPattern)) {
const itemText = match[0];
const mapperItem = parseMapperItem(itemText);
const expandedItemText = expandMapperItemIncludesForTargets(itemText, mapperXml, targets, mapperItem.tag).text;
const isTarget = targets.some((target) => (
hasBoundaryToken(expandedItemText, target.tableName)
&& hasBoundaryToken(expandedItemText, target.plainColumn)
));
if (!isTarget) continue;
summary.total += 1;
if (mapperItem.tag === 'select') summary.select += 1;
if (mapperItem.tag === 'insert') summary.insert += 1;
if (mapperItem.tag === 'update') summary.update += 1;
}
return summary;
}
function parseMapperItem(text) {
const match = text.trim().match(/^<(insert|update|select|delete)\b([^>]*)>[\s\S]*<\/\1>$/i);
if (!match) {
throw new Error('Invalid mapper item text.');
}
return {
tag: match[1].toLowerCase(),
attrs: match[2],
id: attrValue(match[2], 'id') || 'anonymousSql',
text,
};
}
function expandMapperItemIncludesForTargets(itemText, mapperXml, targets, tag) {
const tableTargets = targets.filter((target) => hasBoundaryToken(itemText, target.tableName));
if (tableTargets.length === 0 || !/]*(?:\/>|>[\s\S]*?<\/include\s*>)/gi, (includeText) => {
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) {
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) {
return body.replace(/^\s*\r?\n/, '').replace(/\r?\n\s*$/, '');
}
function findSqlFragment(fragments, refid) {
if (!refid) return '';
return fragments.get(refid) ?? fragments.get(refid.replace(/^.*\./, '')) ?? '';
}
function applyIncludeProperties(fragment, includeText) {
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, name) => (
properties.has(name) ? properties.get(name) : match
));
}
function includeFragmentHasCryptoTargetColumn(fragment, tableTargets, 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 hasSelectRewritePoint(text, target) {
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 selectedColumns(selectedText) {
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 selectColumnRanges(xmlText) {
const ranges = [];
const openTag = xmlText.match(/
댓글
댓글 쓰기