rawsql-ts
Version:
[beta]High-performance SQL parser and AST analyzer written in TypeScript. Provides fast parsing and advanced transformation capabilities.
497 lines • 20.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SqlIdentifierRenamer = void 0;
/**
* Handles safe renaming of SQL identifiers within SQL strings
* Uses character-by-character parsing instead of regex for better performance and maintainability
*/
class SqlIdentifierRenamer {
/**
* Safely renames identifiers in SQL string while preserving context
* @param sql SQL string to modify
* @param renames Map of original identifiers to new identifiers
* @returns Modified SQL string with renamed identifiers
*/
renameIdentifiers(sql, renames) {
if (renames.size === 0) {
return sql;
}
let result = sql;
// Apply all renames
for (const [originalValue, newValue] of renames) {
result = this.replaceIdentifierSafely(result, originalValue, newValue);
}
return result;
}
/**
* Renames a single identifier in SQL string
* @param sql SQL string to modify
* @param oldIdentifier Original identifier to replace
* @param newIdentifier New identifier to replace with
* @returns Modified SQL string
*/
renameIdentifier(sql, oldIdentifier, newIdentifier) {
return this.replaceIdentifierSafely(sql, oldIdentifier, newIdentifier);
}
/**
* Renames a single identifier within a specified scope range
* @param sql SQL string to modify
* @param oldIdentifier Original identifier to replace
* @param newIdentifier New identifier to replace with
* @param scopeRange Optional scope range to limit replacement
* @returns Modified SQL string
*/
renameIdentifierInScope(sql, oldIdentifier, newIdentifier, scopeRange) {
if (!scopeRange) {
// Fallback to full SQL replacement
return this.replaceIdentifierSafely(sql, oldIdentifier, newIdentifier);
}
// Extract the portion of SQL within scope range
const beforeScope = sql.slice(0, scopeRange.start);
const scopeContent = sql.slice(scopeRange.start, scopeRange.end);
const afterScope = sql.slice(scopeRange.end);
// Replace identifiers only within the scope
const modifiedScopeContent = this.replaceIdentifierSafely(scopeContent, oldIdentifier, newIdentifier);
// Reconstruct the full SQL
return beforeScope + modifiedScopeContent + afterScope;
}
/**
* Checks if an identifier at the given position can be renamed
* @param sql SQL string
* @param position Position in the SQL text
* @returns Renameability result
*/
checkRenameability(sql, position) {
// Convert line/column to character position
const charPosition = this.positionToCharIndex(sql, position);
// TODO: Implement proper identifier detection and scope analysis
// For now, minimal implementation to make tests pass
// Simple detection - if we're in a string literal, not renameable
if (this.isInsideStringLiteral(sql, charPosition)) {
return {
canRename: false,
reason: 'Cannot rename identifiers inside string literal'
};
}
// Detect if we're on an identifier (simplified)
const identifier = this.getIdentifierAtPosition(sql, charPosition);
if (!identifier) {
return {
canRename: false,
reason: 'No identifier found at position'
};
}
// Determine type and scope (simplified logic)
const type = this.determineIdentifierType(sql, charPosition, identifier);
const scopeRange = this.calculateScopeRange(sql, charPosition, type);
return {
canRename: true,
currentName: identifier,
type: type,
scopeRange: scopeRange
};
}
/**
* Renames identifier at the specified position
* @param sql SQL string
* @param position Position in the SQL text
* @param newName New identifier name
* @returns Modified SQL string
*/
renameAtPosition(sql, position, newName) {
const renameability = this.checkRenameability(sql, position);
if (!renameability.canRename || !renameability.currentName) {
throw new Error(renameability.reason || 'Cannot rename at this position');
}
return this.renameIdentifierInScope(sql, renameability.currentName, newName, renameability.scopeRange);
}
/**
* Convert line/column position to character index
*/
positionToCharIndex(sql, position) {
const lines = sql.split('\n');
let charIndex = 0;
for (let i = 0; i < position.line - 1 && i < lines.length; i++) {
charIndex += lines[i].length + 1; // +1 for newline
}
charIndex += position.column - 1;
return Math.min(charIndex, sql.length - 1);
}
/**
* Check if position is inside a string literal
*/
isInsideStringLiteral(sql, charPosition) {
// Simple check - count single quotes before position
let inString = false;
for (let i = 0; i < charPosition && i < sql.length; i++) {
if (sql[i] === "'") {
inString = !inString;
}
}
return inString;
}
/**
* Get identifier at the specified character position
*/
getIdentifierAtPosition(sql, charPosition) {
if (charPosition >= sql.length)
return null;
// Find start of identifier
let start = charPosition;
while (start > 0 && this.isIdentifierChar(sql.charCodeAt(start - 1))) {
start--;
}
// Find end of identifier
let end = charPosition;
while (end < sql.length && this.isIdentifierChar(sql.charCodeAt(end))) {
end++;
}
if (start === end)
return null;
return sql.slice(start, end);
}
/**
* Determine the type of identifier (improved logic)
*/
determineIdentifierType(sql, charPosition, identifier) {
const beforePosition = sql.slice(0, charPosition);
const afterPosition = sql.slice(charPosition);
// Check if this is a CTE name (appears between WITH and AS)
const beforeUpper = beforePosition.toUpperCase();
const afterUpper = afterPosition.toUpperCase();
// Find last WITH before our position
const lastWithIndex = beforeUpper.lastIndexOf('WITH');
if (lastWithIndex !== -1) {
// Get the identifier bounds
let start = charPosition;
while (start > 0 && this.isIdentifierChar(sql.charCodeAt(start - 1))) {
start--;
}
let end = charPosition;
while (end < sql.length && this.isIdentifierChar(sql.charCodeAt(end))) {
end++;
}
// Check what comes after the complete identifier
const afterIdentifier = sql.slice(end).toUpperCase();
// CTE pattern: WITH identifier AS (
if (afterIdentifier.trim().startsWith('AS (')) {
return 'cte';
}
}
// Check if this appears after FROM or JOIN (table alias)
const beforeLines = beforePosition.split('\n');
const currentLine = beforeLines[beforeLines.length - 1].toUpperCase();
if (currentLine.includes('FROM ') || currentLine.includes('JOIN ')) {
return 'table_alias';
}
// Check context around the identifier for table alias patterns
const contextBefore = beforePosition.slice(Math.max(0, charPosition - 50));
const contextAfter = afterPosition.slice(0, 50);
const fullContext = (contextBefore + identifier + contextAfter).toUpperCase();
// Table alias patterns: "FROM table AS alias" or "JOIN table alias"
if (fullContext.includes(' AS ' + identifier.toUpperCase()) ||
fullContext.includes(' ' + identifier.toUpperCase() + ' ON') ||
fullContext.includes(' ' + identifier.toUpperCase() + '\n')) {
return 'table_alias';
}
// Default to table alias if uncertain
return 'table_alias';
}
/**
* Calculate scope range for the identifier
*/
calculateScopeRange(sql, charPosition, type) {
if (type === 'cte') {
// CTE has global scope
return { start: 0, end: sql.length };
}
// Table alias - find the containing SELECT statement (simplified)
// This is a very basic implementation
const beforePosition = sql.slice(0, charPosition);
const afterPosition = sql.slice(charPosition);
// Find the start of current SELECT
const lastSelect = beforePosition.toUpperCase().lastIndexOf('SELECT');
const start = lastSelect !== -1 ? lastSelect : 0;
// Find the end (next major clause or end of SQL)
const nextMajorClause = afterPosition.search(/\b(SELECT|WITH|UNION)\b/i);
const end = nextMajorClause !== -1 ? charPosition + nextMajorClause : sql.length;
return { start, end };
}
/**
* Safely replaces SQL identifiers while preserving word boundaries and context
* Uses character-by-character parsing instead of regex for better maintainability
* @param sql SQL string to modify
* @param oldIdentifier Original identifier to replace
* @param newIdentifier New identifier to replace with
* @returns Modified SQL string
*/
replaceIdentifierSafely(sql, oldIdentifier, newIdentifier) {
if (oldIdentifier === newIdentifier || oldIdentifier.length === 0) {
return sql;
}
const result = [];
let position = 0;
const sqlLength = sql.length;
const oldIdLength = oldIdentifier.length;
while (position < sqlLength) {
const char = sql[position];
const charCode = char.charCodeAt(0);
// Handle quoted identifiers - check for identifier matches within quotes
if (charCode === 34 || charCode === 96 || charCode === 91) { // " ` [
const { content, nextPosition } = this.extractAndReplaceQuotedIdentifier(sql, position, char, oldIdentifier, newIdentifier);
result.push(content);
position = nextPosition;
continue;
}
// Skip string literals (only single quotes are actual string literals)
if (charCode === 39) { // '
const { content, nextPosition } = this.extractQuotedString(sql, position, char);
result.push(content);
position = nextPosition;
continue;
}
// Skip line comments --
if (charCode === 45 && position + 1 < sqlLength && sql.charCodeAt(position + 1) === 45) {
const { content, nextPosition } = this.extractLineComment(sql, position);
result.push(content);
position = nextPosition;
continue;
}
// Skip block comments
if (charCode === 47 && position + 1 < sqlLength && sql.charCodeAt(position + 1) === 42) {
const { content, nextPosition } = this.extractBlockComment(sql, position);
result.push(content);
position = nextPosition;
continue;
}
// Check for potential identifier match
if (this.isIdentifierStartChar(charCode) && this.matchesIdentifierAt(sql, position, oldIdentifier)) {
const beforePosition = position - 1;
const afterPosition = position + oldIdLength;
// Validate word boundaries
const beforeChar = beforePosition >= 0 ? sql[beforePosition] : null;
const afterChar = afterPosition < sqlLength ? sql[afterPosition] : null;
if (this.hasValidWordBoundaries(beforeChar, afterChar)) {
result.push(newIdentifier);
position += oldIdLength;
continue;
}
}
// Default: add current character
result.push(char);
position++;
}
return result.join('');
}
/**
* Validates that the rename operation was successful
* @param originalSql Original SQL string
* @param modifiedSql Modified SQL string after rename
* @param oldIdentifier Old identifier that was replaced
* @param newIdentifier New identifier that was added
* @returns True if rename appears successful
*/
validateRename(originalSql, modifiedSql, oldIdentifier, newIdentifier) {
// Basic validation: modified SQL should be different from original
if (originalSql === modifiedSql) {
return false;
}
// The new identifier should appear in the result
if (!modifiedSql.includes(newIdentifier)) {
return false;
}
// The modified SQL should have fewer occurrences of the old identifier than the original
const originalOccurrences = this.countWordOccurrences(originalSql, oldIdentifier);
const modifiedOccurrences = this.countWordOccurrences(modifiedSql, oldIdentifier);
return modifiedOccurrences < originalOccurrences;
}
/**
* Extract and potentially replace quoted identifiers
*/
extractAndReplaceQuotedIdentifier(sql, startPosition, quoteChar, oldIdentifier, newIdentifier) {
if (quoteChar === '[') {
return this.extractAndReplaceBracketedIdentifier(sql, startPosition, oldIdentifier, newIdentifier);
}
const result = [quoteChar];
let position = startPosition + 1;
const identifierStart = position;
while (position < sql.length) {
const char = sql[position];
if (char === quoteChar) {
// Check for escaped quotes (double quotes)
if (position + 1 < sql.length && sql[position + 1] === quoteChar) {
result.push(char);
result.push(sql[position + 1]);
position += 2;
continue;
}
// Extract the content within quotes and check for identifier match
const quotedContent = sql.slice(identifierStart, position);
if (quotedContent.toLowerCase() === oldIdentifier.toLowerCase()) {
result.push(newIdentifier);
}
else {
result.push(quotedContent);
}
result.push(char); // closing quote
break;
}
position++;
}
return { content: result.join(''), nextPosition: position + 1 };
}
/**
* Extract and potentially replace bracketed identifiers [identifier]
*/
extractAndReplaceBracketedIdentifier(sql, startPosition, oldIdentifier, newIdentifier) {
const result = ['['];
let position = startPosition + 1;
const identifierStart = position;
while (position < sql.length) {
const char = sql[position];
if (char === ']') {
// Extract the content within brackets and check for identifier match
const bracketedContent = sql.slice(identifierStart, position);
if (bracketedContent.toLowerCase() === oldIdentifier.toLowerCase()) {
result.push(newIdentifier);
}
else {
result.push(bracketedContent);
}
result.push(char); // closing bracket
break;
}
position++;
}
return { content: result.join(''), nextPosition: position + 1 };
}
/**
* Extract quoted string (handles quotes)
*/
extractQuotedString(sql, startPosition, quoteChar) {
const result = [quoteChar];
let position = startPosition + 1;
while (position < sql.length) {
const char = sql[position];
result.push(char);
if (char === quoteChar) {
// Check for escaped quotes (double quotes)
if (position + 1 < sql.length && sql[position + 1] === quoteChar) {
result.push(sql[position + 1]);
position += 2;
continue;
}
break;
}
position++;
}
return { content: result.join(''), nextPosition: position + 1 };
}
/**
* Extract line comment
*/
extractLineComment(sql, startPosition) {
const result = [];
let position = startPosition;
while (position < sql.length && sql.charCodeAt(position) !== 10 && sql.charCodeAt(position) !== 13) {
result.push(sql[position]);
position++;
}
// Include the newline if present
if (position < sql.length && (sql.charCodeAt(position) === 10 || sql.charCodeAt(position) === 13)) {
result.push(sql[position]);
position++;
}
return { content: result.join(''), nextPosition: position };
}
/**
* Extract block comment
*/
extractBlockComment(sql, startPosition) {
const result = ['/', '*'];
let position = startPosition + 2;
while (position < sql.length - 1) {
const char = sql[position];
result.push(char);
if (char === '*' && sql[position + 1] === '/') {
result.push('/');
position += 2;
break;
}
position++;
}
return { content: result.join(''), nextPosition: position };
}
/**
* Check if character code can start an identifier
*/
isIdentifierStartChar(charCode) {
return (charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
(charCode === 95); // _
}
/**
* Check if character code can be part of an identifier
*/
isIdentifierChar(charCode) {
return (charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
(charCode >= 48 && charCode <= 57) || // 0-9
(charCode === 95); // _
}
/**
* Check if the identifier matches at the given position (case-insensitive)
*/
matchesIdentifierAt(sql, position, identifier) {
if (position + identifier.length > sql.length) {
return false;
}
// Case-insensitive comparison
for (let i = 0; i < identifier.length; i++) {
const sqlChar = sql.charCodeAt(position + i);
const idChar = identifier.charCodeAt(i);
// Convert both to lowercase for comparison
const sqlLower = sqlChar >= 65 && sqlChar <= 90 ? sqlChar + 32 : sqlChar;
const idLower = idChar >= 65 && idChar <= 90 ? idChar + 32 : idChar;
if (sqlLower !== idLower) {
return false;
}
}
return true;
}
/**
* Validate word boundaries
*/
hasValidWordBoundaries(beforeChar, afterChar) {
const isValidBefore = beforeChar === null || !this.isIdentifierChar(beforeChar.charCodeAt(0));
const isValidAfter = afterChar === null || !this.isIdentifierChar(afterChar.charCodeAt(0));
return isValidBefore && isValidAfter;
}
/**
* Counts word boundary occurrences of an identifier in SQL
* @param sql SQL string to search
* @param identifier Identifier to count
* @returns Number of occurrences
*/
countWordOccurrences(sql, identifier) {
let count = 0;
let position = 0;
const sqlLength = sql.length;
const idLength = identifier.length;
while (position <= sqlLength - idLength) {
if (this.matchesIdentifierAt(sql, position, identifier)) {
const beforePosition = position - 1;
const afterPosition = position + idLength;
const beforeChar = beforePosition >= 0 ? sql[beforePosition] : null;
const afterChar = afterPosition < sqlLength ? sql[afterPosition] : null;
if (this.hasValidWordBoundaries(beforeChar, afterChar)) {
count++;
}
}
position++;
}
return count;
}
}
exports.SqlIdentifierRenamer = SqlIdentifierRenamer;
//# sourceMappingURL=SqlIdentifierRenamer.js.map