From 97cba9d141754063b04085b143abc167da9968e8 Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Sat, 30 May 2026 19:23:48 +0300 Subject: [PATCH 1/8] fix(dev-env): tighten option URL validation Purpose and Context Dev-env SQL validation should warn about siteurl and home values only when those values come from WordPress options rows. Broad string matching can produce misleading search-replace recommendations when similar text appears in unrelated SQL data. Key Changes - Track active options-table INSERT and REPLACE statements while scanning SQL dumps. - Parse option rows by column name or default wp_options order before checking siteurl and home URL values. - Add regression coverage for supported statement variants, multiline rows, comments, and unrelated option-like data. Impact and Considerations This narrows validation warnings without changing import execution. No migrations, configuration changes, or deployment steps are required. The scanner remains purpose-built for supported dump shapes rather than a full SQL parser. Testing and Validation - npx jest --runTestsByPath __tests__/lib/validations/sql.js --runInBand - npx eslint src/lib/validations/sql.ts __tests__/lib/validations/sql.js - npm run check-types - git diff --check -- src/lib/validations/sql.ts __fixtures__/validations/bad-sql-dev-env.sql __tests__/lib/validations/sql.js --- __fixtures__/validations/bad-sql-dev-env.sql | 66 ++- __tests__/lib/validations/sql.js | 86 ++++ src/lib/validations/sql.ts | 496 ++++++++++++++++++- 3 files changed, 644 insertions(+), 4 deletions(-) diff --git a/__fixtures__/validations/bad-sql-dev-env.sql b/__fixtures__/validations/bad-sql-dev-env.sql index a8a248fb0..887d1b524 100644 --- a/__fixtures__/validations/bad-sql-dev-env.sql +++ b/__fixtures__/validations/bad-sql-dev-env.sql @@ -7,4 +7,68 @@ INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'https://super-employees-go.vip.net', 'yes'), ('home', 'https://super-empoyees.com', 'yes'), - ('home', 'home', 'yes'); \ No newline at end of file + ('home', 'home', 'yes'), + ('blogdescription', 'legacy snippet: \'home\', \'https://embedded.example\'', 'yes'); + +INSERT INTO wp_options VALUES + (1, 'siteurl', 'https://full-order.example', 'yes'); + +INSERT INTO wp_options + (option_name, option_value, autoload) +VALUES + ('siteurl', 'https://split-header.example', 'yes'); + +INSERT INTO wp_options ( + option_name, + option_value, + autoload +) +VALUES + ('siteurl', 'https://multi-line-columns.example', 'yes'); + +INSERT INTO wp_options (option_value, option_name, autoload) +VALUES + ('https://reordered.example', 'siteurl', 'yes'), + ('https://unpaired-neighbor.example', 'blogdescription', 'yes'); + +INSERT INTO wp_options (option_name, option_value, autoload) +VALUES + ('siteurl', + 'https://multi-line-tuple.example', + 'yes'); + +INSERT INTO wp_options (option_name, option_value, autoload) +VALUES + -- ('siteurl', 'https://dash-comment.example', 'yes'), + # ('home', 'https://hash-comment.example', 'yes'), + /* ('siteurl', 'https://block-comment.example', 'yes'), */ + ('siteurl', 'https://quoted-dash.example/path--kept', 'yes'), + ('home', 'https://quoted-hash.example/path#kept', 'yes'), + ('siteurl', 'https://quoted-block.example/path/*kept*/', 'yes'), + ('blogdescription', 'https://comment-control.example', 'yes'), -- ('siteurl', 'https://dash-inline-comment.example', 'yes'), + ('blogdescription', 'https://hash-inline-control.example', 'yes'), # ('home', 'https://hash-inline-comment.example', 'yes'), + ('blogdescription', 'https://block-inline-control.example', 'yes') /* ('siteurl', 'https://block-inline-comment.example', 'yes') */; + +INSERT INTO wp_postmeta (meta_key, meta_value) + VALUES + ('home', 'https://unrelated-table.example', 'yes'); + +REPLACE INTO wp_options (option_name, option_value, autoload) + VALUES + ('siteurl', 'https://replace.example', 'yes'); + +INSERT IGNORE INTO wp_options (option_name, option_value, autoload) + VALUES + ('siteurl', 'https://ignore.example', 'yes'); + +INSERT INTO `db`.`wp_options` (option_name, option_value, autoload) + VALUES + ('siteurl', 'https://qualified.example', 'yes'); + +INSERT INTO wp_10_options (option_name, option_value, autoload) + VALUES + ('siteurl', 'https://network.example', 'yes'); + +INSERT INTO wp_abc_options (option_name, option_value, autoload) + VALUES + ('siteurl', 'https://pseudo.example', 'yes'); \ No newline at end of file diff --git a/__tests__/lib/validations/sql.js b/__tests__/lib/validations/sql.js index 59197ba32..50cbb87ca 100644 --- a/__tests__/lib/validations/sql.js +++ b/__tests__/lib/validations/sql.js @@ -190,6 +190,92 @@ describe( 'lib/validations/sql', () => { 'Use \'--search-replace="home,test.domain"\' switch to replace the domain' ); } ); + it( 'should not suggest to replace home strings from non-options tables', () => { + expect( output ).not.toContain( + 'Use \'--search-replace="unrelated-table.example,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should not suggest replacements for unrelated options rows containing quoted home URL text', () => { + expect( output ).not.toContain( + 'Use \'--search-replace="embedded.example,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should suggest replacements for option rows inserted with supported statement variants', () => { + expect( output ).toContain( + 'Use \'--search-replace="full-order.example,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="split-header.example,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="replace.example,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="ignore.example,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="qualified.example,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="network.example,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should suggest replacements for explicit option column lists split across multiple lines', () => { + expect( output ).toContain( + 'Use \'--search-replace="multi-line-columns.example,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should pair option values by explicit column name instead of fixed position', () => { + expect( output ).toContain( + 'Use \'--search-replace="reordered.example,test.domain"\' switch to replace the domain' + ); + expect( output ).not.toContain( + 'Use \'--search-replace="unpaired-neighbor.example,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should suggest replacements for option tuples split across multiple lines', () => { + expect( output ).toContain( + 'Use \'--search-replace="multi-line-tuple.example,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should not suggest replacements from comment-shaped option rows', () => { + expect( output ).not.toContain( + 'Use \'--search-replace="dash-comment.example,test.domain"\' switch to replace the domain' + ); + expect( output ).not.toContain( + 'Use \'--search-replace="hash-comment.example,test.domain"\' switch to replace the domain' + ); + expect( output ).not.toContain( + 'Use \'--search-replace="block-comment.example,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should not suggest replacements from trailing inline comment option rows', () => { + expect( output ).not.toContain( + 'Use \'--search-replace="dash-inline-comment.example,test.domain"\' switch to replace the domain' + ); + expect( output ).not.toContain( + 'Use \'--search-replace="hash-inline-comment.example,test.domain"\' switch to replace the domain' + ); + expect( output ).not.toContain( + 'Use \'--search-replace="block-inline-comment.example,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should preserve comment markers inside quoted option values', () => { + expect( output ).toContain( + 'Use \'--search-replace="quoted-dash.example/path--kept,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="quoted-hash.example/path#kept,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="quoted-block.example/path/*kept*/,test.domain"\' switch to replace the domain' + ); + } ); + it( 'should not suggest replacements for non-numeric pseudo-options tables', () => { + expect( output ).not.toContain( + 'Use \'--search-replace="pseudo.example,test.domain"\' switch to replace the domain' + ); + } ); } ); describe( 'it fails when the import file is compressed', () => { diff --git a/src/lib/validations/sql.ts b/src/lib/validations/sql.ts index 6f86fcc04..d8f162bcc 100644 --- a/src/lib/validations/sql.ts +++ b/src/lib/validations/sql.ts @@ -15,6 +15,12 @@ import { OmitIndexSignature } from '../types'; let problemsFound = 0; let lineNum = 1; const tableNames: string[] = []; +let currentInsertStatementTableName: string | undefined; +let currentInsertStatementColumns: string[] | undefined; +let currentInsertStatementColumnList: string | undefined; +let currentInsertStatementHasValues = false; +let currentInsertStatementRowBuffer: string | undefined; +let currentInsertStatementInBlockComment = false; function formatError( message: string ): string { return `${ chalk.red( 'SQL Error:' ) } ${ message }`; @@ -333,7 +339,7 @@ const checks: Checks = { recommendation: "Disabling 'UNIQUE_CHECKS' is not allowed. These lines should be removed", }, siteHomeUrl: { - matcher: `['"](siteurl|home)['"],\\s?['"](.*?)['"]`, + matcher: `['"](siteurl|home)['"]\\s*,\\s*['"]([^'"]*)['"]`, matchHandler: ( lineNumber, results ) => ( { text: results[ 1 ] + ' ' + results[ 2 ] } ), outputFormatter: infoCheckFormatter, results: [], @@ -342,7 +348,7 @@ const checks: Checks = { recommendation: '', }, siteHomeUrlLando: { - matcher: `['"](siteurl|home)['"],\\s?['"]([^'"]+)['"]`, + matcher: `['"](siteurl|home)['"]\\s*,\\s*['"]([^'"]+)['"]`, matchHandler: ( lineNumber, results, expectedDomain ) => { let foundDomain = results[ 2 ]; if ( ! /^https?:\/\//i.test( foundDomain ) ) { @@ -519,6 +525,447 @@ const checkForTableName = ( line: string ): void => { } }; +const INSERT_STATEMENT_MODIFIERS = new Set( [ + 'IGNORE', + 'LOW_PRIORITY', + 'DELAYED', + 'HIGH_PRIORITY', +] ); + +interface SqlIdentifier { + name: string; + endIndex: number; +} + +interface InsertStatementInfo { + tableName: string; + tableEndIndex: number; +} + +const DEFAULT_OPTIONS_INSERT_COLUMNS = [ 'option_id', 'option_name', 'option_value', 'autoload' ]; + +const skipSqlWhitespace = ( line: string, startIndex: number ): number => { + let index = startIndex; + while ( index < line.length && /\s/.test( line[ index ] ) ) { + index += 1; + } + return index; +}; + +const readSqlIdentifier = ( line: string, startIndex: number ): SqlIdentifier | undefined => { + const index = skipSqlWhitespace( line, startIndex ); + if ( index >= line.length ) { + return undefined; + } + + if ( '`' === line[ index ] ) { + const endIndex = line.indexOf( '`', index + 1 ); + if ( -1 === endIndex ) { + return undefined; + } + return { name: line.slice( index + 1, endIndex ), endIndex: endIndex + 1 }; + } + + const matches = /^[a-z0-9_$]+/i.exec( line.slice( index ) ); + if ( ! matches ) { + return undefined; + } + + return { name: matches[ 0 ], endIndex: index + matches[ 0 ].length }; +}; + +const getInsertStatementInfo = ( line: string ): InsertStatementInfo | undefined => { + const statementMatches = /^\s*(?:INSERT|REPLACE)\b/i.exec( line ); + if ( ! statementMatches ) { + return undefined; + } + + let index = statementMatches[ 0 ].length; + let tableName: SqlIdentifier | undefined; + + while ( true ) { + const identifier = readSqlIdentifier( line, index ); + if ( ! identifier ) { + return undefined; + } + + const keyword = identifier.name.toUpperCase(); + index = identifier.endIndex; + + if ( INSERT_STATEMENT_MODIFIERS.has( keyword ) || 'INTO' === keyword ) { + continue; + } + + tableName = identifier; + break; + } + + const dotIndex = skipSqlWhitespace( line, tableName.endIndex ); + if ( '.' !== line[ dotIndex ] ) { + return { tableName: tableName.name, tableEndIndex: tableName.endIndex }; + } + + const qualifiedTableName = readSqlIdentifier( line, dotIndex + 1 ); + return { + tableName: qualifiedTableName?.name ?? tableName.name, + tableEndIndex: qualifiedTableName?.endIndex ?? tableName.endIndex, + }; +}; + +const isWordPressOptionsTable = ( tableName: string | undefined ): boolean => { + const normalizedTableName = tableName?.toLowerCase() ?? ''; + if ( 'wp_options' === normalizedTableName ) { + return true; + } + + if ( ! normalizedTableName.startsWith( 'wp_' ) || ! normalizedTableName.endsWith( '_options' ) ) { + return false; + } + + const tableId = normalizedTableName.slice( 'wp_'.length, -'_options'.length ); + return tableId.length > 0 && [ ...tableId ].every( char => char >= '0' && char <= '9' ); +}; + +const checkRequiresOptionsInsertContext = ( checkKey: string ): boolean => { + return 'siteHomeUrl' === checkKey || 'siteHomeUrlLando' === checkKey; +}; + +const normalizeSqlIdentifier = ( identifier: string ): string => { + return identifier.replace( /^`|`$/g, '' ).toLowerCase(); +}; + +const findValuesKeywordIndex = ( line: string ): number => { + const valuesMatches = /\bVALUES\b/i.exec( line ); + return valuesMatches?.index ?? -1; +}; + +const parseInsertColumnList = ( line: string, startIndex: number ): string[] | undefined => { + const valuesIndex = findValuesKeywordIndex( line ); + const openingParenthesisIndex = line.indexOf( '(', startIndex ); + if ( + -1 === openingParenthesisIndex || + ( -1 !== valuesIndex && openingParenthesisIndex > valuesIndex ) + ) { + return undefined; + } + + const closingParenthesisIndex = line.indexOf( ')', openingParenthesisIndex + 1 ); + if ( -1 === closingParenthesisIndex ) { + return undefined; + } + + const columns = line + .slice( openingParenthesisIndex + 1, closingParenthesisIndex ) + .split( ',' ) + .map( column => normalizeSqlIdentifier( column.trim() ) ) + .filter( Boolean ); + + return columns.length > 0 ? columns : undefined; +}; + +const parseInsertColumnListSegment = ( columnList: string ): string[] | undefined => { + const columns = columnList + .split( ',' ) + .map( column => normalizeSqlIdentifier( column.trim() ) ) + .filter( Boolean ); + + return columns.length > 0 ? columns : undefined; +}; + +const collectInsertColumnList = ( line: string, startIndex: number ): string[] | undefined => { + if ( undefined !== currentInsertStatementColumnList ) { + const closingParenthesisIndex = line.indexOf( ')' ); + if ( -1 === closingParenthesisIndex ) { + currentInsertStatementColumnList += `\n${ line }`; + return undefined; + } + + const columnList = `${ currentInsertStatementColumnList }\n${ line.slice( + 0, + closingParenthesisIndex + ) }`; + currentInsertStatementColumnList = undefined; + return parseInsertColumnListSegment( columnList ); + } + + const valuesIndex = findValuesKeywordIndex( line ); + const openingParenthesisIndex = line.indexOf( '(', startIndex ); + if ( + -1 === openingParenthesisIndex || + ( -1 !== valuesIndex && openingParenthesisIndex > valuesIndex ) + ) { + return undefined; + } + + const closingParenthesisIndex = line.indexOf( ')', openingParenthesisIndex + 1 ); + if ( -1 !== closingParenthesisIndex ) { + return parseInsertColumnListSegment( + line.slice( openingParenthesisIndex + 1, closingParenthesisIndex ) + ); + } + + currentInsertStatementColumnList = line.slice( openingParenthesisIndex + 1 ); + return undefined; +}; + +const isEscapedByBackslash = ( line: string, index: number ): boolean => { + let backslashCount = 0; + let currentIndex = index - 1; + while ( currentIndex >= 0 && '\\' === line[ currentIndex ] ) { + backslashCount += 1; + currentIndex -= 1; + } + + return 1 === backslashCount % 2; +}; + +interface SqlTupleRowsParseResult { + rows: string[][]; + remainder?: string; +} + +const isSqlCommentOnlyLine = ( line: string ): boolean => { + const trimmedLine = line.trim(); + + if ( currentInsertStatementInBlockComment ) { + if ( trimmedLine.includes( '*/' ) ) { + currentInsertStatementInBlockComment = false; + } + return true; + } + + if ( /^--(?:\s|$)/.test( trimmedLine ) || trimmedLine.startsWith( '#' ) ) { + return true; + } + + if ( ! trimmedLine.startsWith( '/*' ) ) { + return false; + } + + const blockCommentEndIndex = trimmedLine.indexOf( '*/' ); + if ( -1 === blockCommentEndIndex ) { + currentInsertStatementInBlockComment = true; + return true; + } + + return '' === trimmedLine.slice( blockCommentEndIndex + 2 ).trim(); +}; + +const stripSqlCommentsOutsideQuotedStrings = ( line: string ): string => { + let uncommentedLine = ''; + let quote: string | undefined; + + for ( let index = 0; index < line.length; index += 1 ) { + const char = line[ index ]; + + if ( quote ) { + uncommentedLine += char; + + if ( char === quote ) { + if ( line[ index + 1 ] === quote ) { + uncommentedLine += line[ index + 1 ]; + index += 1; + continue; + } + + if ( ! isEscapedByBackslash( line, index ) ) { + quote = undefined; + } + } + + continue; + } + + if ( "'" === char || '"' === char ) { + quote = char; + uncommentedLine += char; + continue; + } + + if ( '-' === char && '-' === line[ index + 1 ] ) { + const nextChar = line[ index + 2 ]; + if ( undefined === nextChar || /\s/.test( nextChar ) ) { + return uncommentedLine.trimEnd(); + } + } + + if ( '#' === char ) { + return uncommentedLine.trimEnd(); + } + + if ( '/' === char && '*' === line[ index + 1 ] ) { + const blockCommentEndIndex = line.indexOf( '*/', index + 2 ); + if ( -1 === blockCommentEndIndex ) { + return uncommentedLine.trimEnd(); + } + + index = blockCommentEndIndex + 1; + continue; + } + + uncommentedLine += char; + } + + return uncommentedLine; +}; + +const parseSqlTupleRows = ( line: string, startIndex: number ): SqlTupleRowsParseResult => { + const rows: string[][] = []; + let currentRow: string[] | undefined; + let currentValue = ''; + let parenthesisDepth = 0; + let quote: string | undefined; + let rowStartIndex: number | undefined; + + for ( let index = startIndex; index < line.length; index += 1 ) { + const char = line[ index ]; + + if ( quote ) { + currentValue += char; + + if ( char === quote ) { + if ( line[ index + 1 ] === quote ) { + currentValue += line[ index + 1 ]; + index += 1; + continue; + } + + if ( ! isEscapedByBackslash( line, index ) ) { + quote = undefined; + } + } + + continue; + } + + if ( "'" === char || '"' === char ) { + quote = char; + currentValue += char; + continue; + } + + if ( '(' === char ) { + if ( 0 === parenthesisDepth ) { + currentRow = []; + currentValue = ''; + rowStartIndex = index; + } else { + currentValue += char; + } + + parenthesisDepth += 1; + continue; + } + + if ( ')' === char && currentRow ) { + parenthesisDepth -= 1; + + if ( 0 === parenthesisDepth ) { + currentRow.push( currentValue.trim() ); + rows.push( currentRow ); + currentRow = undefined; + currentValue = ''; + rowStartIndex = undefined; + continue; + } + } + + if ( ',' === char && 1 === parenthesisDepth && currentRow ) { + currentRow.push( currentValue.trim() ); + currentValue = ''; + continue; + } + + if ( currentRow ) { + currentValue += char; + } + } + + return { + rows, + remainder: undefined === rowStartIndex ? undefined : line.slice( rowStartIndex ), + }; +}; + +const unquoteSqlValue = ( value: string | undefined ): string => { + const trimmedValue = value?.trim() ?? ''; + const quote = trimmedValue[ 0 ]; + if ( ! quote || ( "'" !== quote && '"' !== quote ) || trimmedValue.at( -1 ) !== quote ) { + return trimmedValue; + } + + return trimmedValue + .slice( 1, -1 ) + .replaceAll( quote + quote, quote ) + .replaceAll( '\\' + quote, quote ); +}; + +const getOptionUrlMatchResults = ( row: string[], columns?: string[] ): string[] | undefined => { + const optionColumns = columns ?? DEFAULT_OPTIONS_INSERT_COLUMNS; + const optionNameIndex = optionColumns.indexOf( 'option_name' ); + const optionValueIndex = optionColumns.indexOf( 'option_value' ); + + if ( -1 === optionNameIndex || -1 === optionValueIndex ) { + return undefined; + } + + const optionName = unquoteSqlValue( row[ optionNameIndex ] ).toLowerCase(); + const optionValue = unquoteSqlValue( row[ optionValueIndex ] ); + + if ( 'siteurl' !== optionName && 'home' !== optionName ) { + return undefined; + } + + if ( ! /^https?:\/\//i.test( optionValue ) ) { + return undefined; + } + + return [ '', optionName, optionValue ]; +}; + +const collectOptionUrlMatchesFromRows = ( rows: string[][] ): string[][] => { + return rows + .map( row => getOptionUrlMatchResults( row, currentInsertStatementColumns ) ) + .filter( ( results ): results is string[] => Boolean( results ) ); +}; + +const collectOptionsInsertRows = ( line: string, startIndex: number ): string[][] => { + const lineSegment = line.slice( startIndex ); + const parseInput = currentInsertStatementRowBuffer + ? `${ currentInsertStatementRowBuffer }\n${ lineSegment }` + : lineSegment; + const { rows, remainder } = parseSqlTupleRows( parseInput, 0 ); + currentInsertStatementRowBuffer = remainder; + return collectOptionUrlMatchesFromRows( rows ); +}; + +const collectOptionsInsertMatches = ( line: string, isCommentOnlyLine: boolean ): string[][] => { + if ( ! isWordPressOptionsTable( currentInsertStatementTableName ) ) { + return []; + } + + if ( isCommentOnlyLine ) { + return []; + } + + const uncommentedLine = stripSqlCommentsOutsideQuotedStrings( line ); + + if ( ! currentInsertStatementHasValues ) { + currentInsertStatementColumns = + currentInsertStatementColumns ?? collectInsertColumnList( uncommentedLine, 0 ); + + const valuesIndex = findValuesKeywordIndex( uncommentedLine ); + if ( -1 === valuesIndex ) { + return []; + } + + currentInsertStatementHasValues = true; + return collectOptionsInsertRows( uncommentedLine, valuesIndex + 'VALUES'.length ); + } + + return collectOptionsInsertRows( uncommentedLine, 0 ); +}; + const DEFAULT_VALIDATION_OPTIONS: ValidationOptions = { isImport: true, skipChecks: DEV_ENV_SPECIFIC_CHECKS, @@ -535,18 +982,54 @@ const perLineValidations = ( checkForTableName( line ); + const insertStatementInfo = getInsertStatementInfo( line ); + if ( insertStatementInfo ) { + currentInsertStatementTableName = insertStatementInfo.tableName; + currentInsertStatementColumns = parseInsertColumnList( + line, + insertStatementInfo.tableEndIndex + ); + currentInsertStatementColumnList = undefined; + currentInsertStatementHasValues = false; + currentInsertStatementRowBuffer = undefined; + currentInsertStatementInBlockComment = false; + } + const isCommentOnlyLine = currentInsertStatementTableName ? isSqlCommentOnlyLine( line ) : false; + const optionsInsertMatches = collectOptionsInsertMatches( line, isCommentOnlyLine ); + const checkKeys = Object.keys( checks ).filter( checkItem => ! options.skipChecks.includes( checkItem ) ); for ( const checkKey of checkKeys ) { const check: CheckType = checks[ checkKey ]; - const results = line.match( check.matcher ); // NOSONAR const extraCheckParams = options.extraCheckParams[ checkKey ]; + + if ( checkRequiresOptionsInsertContext( checkKey ) ) { + for ( const results of optionsInsertMatches ) { + check.results.push( check.matchHandler( lineNum, results, extraCheckParams ) ); + } + continue; + } + + const results = line.match( check.matcher ); // NOSONAR if ( results ) { check.results.push( check.matchHandler( lineNum, results, extraCheckParams ) ); } } + if ( + currentInsertStatementTableName && + ! isCommentOnlyLine && + stripSqlCommentsOutsideQuotedStrings( line ).trimEnd().endsWith( ';' ) + ) { + currentInsertStatementTableName = undefined; + currentInsertStatementColumns = undefined; + currentInsertStatementColumnList = undefined; + currentInsertStatementHasValues = false; + currentInsertStatementRowBuffer = undefined; + currentInsertStatementInBlockComment = false; + } + lineNum += 1; }; @@ -571,6 +1054,13 @@ export const validate = async ( filename: string, options: ValidationOptions = DEFAULT_VALIDATION_OPTIONS ): Promise< void > => { + currentInsertStatementTableName = undefined; + currentInsertStatementColumns = undefined; + currentInsertStatementColumnList = undefined; + currentInsertStatementHasValues = false; + currentInsertStatementRowBuffer = undefined; + currentInsertStatementInBlockComment = false; + const fileMeta = await getFileMeta( filename ); if ( fileMeta.isCompressed ) { From e64540bb1e1847c97f07e88d4fbb6abfe4d6f316 Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Sat, 30 May 2026 23:13:08 +0300 Subject: [PATCH 2/8] refactor(sql): extract pure INSERT parser helpers Purpose and Context The dev-env SQL validator added a small INSERT/REPLACE parser inside sql.ts. The pure helpers are large enough to warrant a dedicated module and dedicated unit coverage. Keeping them inline mixes a stateful check-runner with a self-contained parser, which makes the parser harder to understand and harder to test. Key Changes - Move the pure (state-free) helpers, constants, and interfaces to src/lib/validations/sql-insert-parser.ts. - Replace inline definitions in src/lib/validations/sql.ts with a named import from the new module. - Leave the six module-level currentInsertStatement* variables and the five stateful helpers (collectInsertColumnList, isSqlCommentOnlyLine, collectOptionUrlMatchesFromRows, collectOptionsInsertRows, collectOptionsInsertMatches) in sql.ts so this commit is purely a code move. Impact and Considerations No behavior change. The extracted helpers have byte-identical bodies modulo prettier's printWidth: 100 wrap on three signature lines that crossed the limit once `export ` was prepended. The existing integration suite at __tests__/lib/validations/sql.js remains the authoritative behavior-preservation evidence and passes unchanged (26/26). Testing and Validation - npx jest --runTestsByPath __tests__/lib/validations/sql.js --runInBand - npx eslint src/lib/validations/sql.ts src/lib/validations/sql-insert-parser.ts - npm run check-types --- src/lib/validations/sql-insert-parser.ts | 345 ++++++++++++++++++++++ src/lib/validations/sql.ts | 346 +---------------------- 2 files changed, 356 insertions(+), 335 deletions(-) create mode 100644 src/lib/validations/sql-insert-parser.ts diff --git a/src/lib/validations/sql-insert-parser.ts b/src/lib/validations/sql-insert-parser.ts new file mode 100644 index 000000000..a6e6c6d0c --- /dev/null +++ b/src/lib/validations/sql-insert-parser.ts @@ -0,0 +1,345 @@ +export const INSERT_STATEMENT_MODIFIERS = new Set( [ + 'IGNORE', + 'LOW_PRIORITY', + 'DELAYED', + 'HIGH_PRIORITY', +] ); + +export interface SqlIdentifier { + name: string; + endIndex: number; +} + +export interface InsertStatementInfo { + tableName: string; + tableEndIndex: number; +} + +export const DEFAULT_OPTIONS_INSERT_COLUMNS = [ + 'option_id', + 'option_name', + 'option_value', + 'autoload', +]; + +export const skipSqlWhitespace = ( line: string, startIndex: number ): number => { + let index = startIndex; + while ( index < line.length && /\s/.test( line[ index ] ) ) { + index += 1; + } + return index; +}; + +export const readSqlIdentifier = ( + line: string, + startIndex: number +): SqlIdentifier | undefined => { + const index = skipSqlWhitespace( line, startIndex ); + if ( index >= line.length ) { + return undefined; + } + + if ( '`' === line[ index ] ) { + const endIndex = line.indexOf( '`', index + 1 ); + if ( -1 === endIndex ) { + return undefined; + } + return { name: line.slice( index + 1, endIndex ), endIndex: endIndex + 1 }; + } + + const matches = /^[a-z0-9_$]+/i.exec( line.slice( index ) ); + if ( ! matches ) { + return undefined; + } + + return { name: matches[ 0 ], endIndex: index + matches[ 0 ].length }; +}; + +export const getInsertStatementInfo = ( line: string ): InsertStatementInfo | undefined => { + const statementMatches = /^\s*(?:INSERT|REPLACE)\b/i.exec( line ); + if ( ! statementMatches ) { + return undefined; + } + + let index = statementMatches[ 0 ].length; + let tableName: SqlIdentifier | undefined; + + while ( true ) { + const identifier = readSqlIdentifier( line, index ); + if ( ! identifier ) { + return undefined; + } + + const keyword = identifier.name.toUpperCase(); + index = identifier.endIndex; + + if ( INSERT_STATEMENT_MODIFIERS.has( keyword ) || 'INTO' === keyword ) { + continue; + } + + tableName = identifier; + break; + } + + const dotIndex = skipSqlWhitespace( line, tableName.endIndex ); + if ( '.' !== line[ dotIndex ] ) { + return { tableName: tableName.name, tableEndIndex: tableName.endIndex }; + } + + const qualifiedTableName = readSqlIdentifier( line, dotIndex + 1 ); + return { + tableName: qualifiedTableName?.name ?? tableName.name, + tableEndIndex: qualifiedTableName?.endIndex ?? tableName.endIndex, + }; +}; + +export const isWordPressOptionsTable = ( tableName: string | undefined ): boolean => { + const normalizedTableName = tableName?.toLowerCase() ?? ''; + if ( 'wp_options' === normalizedTableName ) { + return true; + } + + if ( ! normalizedTableName.startsWith( 'wp_' ) || ! normalizedTableName.endsWith( '_options' ) ) { + return false; + } + + const tableId = normalizedTableName.slice( 'wp_'.length, -'_options'.length ); + return tableId.length > 0 && [ ...tableId ].every( char => char >= '0' && char <= '9' ); +}; + +export const checkRequiresOptionsInsertContext = ( checkKey: string ): boolean => { + return 'siteHomeUrl' === checkKey || 'siteHomeUrlLando' === checkKey; +}; + +export const normalizeSqlIdentifier = ( identifier: string ): string => { + return identifier.replace( /^`|`$/g, '' ).toLowerCase(); +}; + +export const findValuesKeywordIndex = ( line: string ): number => { + const valuesMatches = /\bVALUES\b/i.exec( line ); + return valuesMatches?.index ?? -1; +}; + +export const parseInsertColumnList = ( line: string, startIndex: number ): string[] | undefined => { + const valuesIndex = findValuesKeywordIndex( line ); + const openingParenthesisIndex = line.indexOf( '(', startIndex ); + if ( + -1 === openingParenthesisIndex || + ( -1 !== valuesIndex && openingParenthesisIndex > valuesIndex ) + ) { + return undefined; + } + + const closingParenthesisIndex = line.indexOf( ')', openingParenthesisIndex + 1 ); + if ( -1 === closingParenthesisIndex ) { + return undefined; + } + + const columns = line + .slice( openingParenthesisIndex + 1, closingParenthesisIndex ) + .split( ',' ) + .map( column => normalizeSqlIdentifier( column.trim() ) ) + .filter( Boolean ); + + return columns.length > 0 ? columns : undefined; +}; + +export const parseInsertColumnListSegment = ( columnList: string ): string[] | undefined => { + const columns = columnList + .split( ',' ) + .map( column => normalizeSqlIdentifier( column.trim() ) ) + .filter( Boolean ); + + return columns.length > 0 ? columns : undefined; +}; + +export const isEscapedByBackslash = ( line: string, index: number ): boolean => { + let backslashCount = 0; + let currentIndex = index - 1; + while ( currentIndex >= 0 && '\\' === line[ currentIndex ] ) { + backslashCount += 1; + currentIndex -= 1; + } + + return 1 === backslashCount % 2; +}; + +export interface SqlTupleRowsParseResult { + rows: string[][]; + remainder?: string; +} + +export const stripSqlCommentsOutsideQuotedStrings = ( line: string ): string => { + let uncommentedLine = ''; + let quote: string | undefined; + + for ( let index = 0; index < line.length; index += 1 ) { + const char = line[ index ]; + + if ( quote ) { + uncommentedLine += char; + + if ( char === quote ) { + if ( line[ index + 1 ] === quote ) { + uncommentedLine += line[ index + 1 ]; + index += 1; + continue; + } + + if ( ! isEscapedByBackslash( line, index ) ) { + quote = undefined; + } + } + + continue; + } + + if ( "'" === char || '"' === char ) { + quote = char; + uncommentedLine += char; + continue; + } + + if ( '-' === char && '-' === line[ index + 1 ] ) { + const nextChar = line[ index + 2 ]; + if ( undefined === nextChar || /\s/.test( nextChar ) ) { + return uncommentedLine.trimEnd(); + } + } + + if ( '#' === char ) { + return uncommentedLine.trimEnd(); + } + + if ( '/' === char && '*' === line[ index + 1 ] ) { + const blockCommentEndIndex = line.indexOf( '*/', index + 2 ); + if ( -1 === blockCommentEndIndex ) { + return uncommentedLine.trimEnd(); + } + + index = blockCommentEndIndex + 1; + continue; + } + + uncommentedLine += char; + } + + return uncommentedLine; +}; + +export const parseSqlTupleRows = ( line: string, startIndex: number ): SqlTupleRowsParseResult => { + const rows: string[][] = []; + let currentRow: string[] | undefined; + let currentValue = ''; + let parenthesisDepth = 0; + let quote: string | undefined; + let rowStartIndex: number | undefined; + + for ( let index = startIndex; index < line.length; index += 1 ) { + const char = line[ index ]; + + if ( quote ) { + currentValue += char; + + if ( char === quote ) { + if ( line[ index + 1 ] === quote ) { + currentValue += line[ index + 1 ]; + index += 1; + continue; + } + + if ( ! isEscapedByBackslash( line, index ) ) { + quote = undefined; + } + } + + continue; + } + + if ( "'" === char || '"' === char ) { + quote = char; + currentValue += char; + continue; + } + + if ( '(' === char ) { + if ( 0 === parenthesisDepth ) { + currentRow = []; + currentValue = ''; + rowStartIndex = index; + } else { + currentValue += char; + } + + parenthesisDepth += 1; + continue; + } + + if ( ')' === char && currentRow ) { + parenthesisDepth -= 1; + + if ( 0 === parenthesisDepth ) { + currentRow.push( currentValue.trim() ); + rows.push( currentRow ); + currentRow = undefined; + currentValue = ''; + rowStartIndex = undefined; + continue; + } + } + + if ( ',' === char && 1 === parenthesisDepth && currentRow ) { + currentRow.push( currentValue.trim() ); + currentValue = ''; + continue; + } + + if ( currentRow ) { + currentValue += char; + } + } + + return { + rows, + remainder: undefined === rowStartIndex ? undefined : line.slice( rowStartIndex ), + }; +}; + +export const unquoteSqlValue = ( value: string | undefined ): string => { + const trimmedValue = value?.trim() ?? ''; + const quote = trimmedValue[ 0 ]; + if ( ! quote || ( "'" !== quote && '"' !== quote ) || trimmedValue.at( -1 ) !== quote ) { + return trimmedValue; + } + + return trimmedValue + .slice( 1, -1 ) + .replaceAll( quote + quote, quote ) + .replaceAll( '\\' + quote, quote ); +}; + +export const getOptionUrlMatchResults = ( + row: string[], + columns?: string[] +): string[] | undefined => { + const optionColumns = columns ?? DEFAULT_OPTIONS_INSERT_COLUMNS; + const optionNameIndex = optionColumns.indexOf( 'option_name' ); + const optionValueIndex = optionColumns.indexOf( 'option_value' ); + + if ( -1 === optionNameIndex || -1 === optionValueIndex ) { + return undefined; + } + + const optionName = unquoteSqlValue( row[ optionNameIndex ] ).toLowerCase(); + const optionValue = unquoteSqlValue( row[ optionValueIndex ] ); + + if ( 'siteurl' !== optionName && 'home' !== optionName ) { + return undefined; + } + + if ( ! /^https?:\/\//i.test( optionValue ) ) { + return undefined; + } + + return [ '', optionName, optionValue ]; +}; diff --git a/src/lib/validations/sql.ts b/src/lib/validations/sql.ts index d8f162bcc..d96488a6e 100644 --- a/src/lib/validations/sql.ts +++ b/src/lib/validations/sql.ts @@ -10,6 +10,17 @@ import { type PostLineExecutionProcessingParams, getReadInterface, } from '../../lib/validations/line-by-line'; +import { + checkRequiresOptionsInsertContext, + findValuesKeywordIndex, + getInsertStatementInfo, + getOptionUrlMatchResults, + isWordPressOptionsTable, + parseInsertColumnList, + parseInsertColumnListSegment, + parseSqlTupleRows, + stripSqlCommentsOutsideQuotedStrings, +} from '../../lib/validations/sql-insert-parser'; import { OmitIndexSignature } from '../types'; let problemsFound = 0; @@ -525,153 +536,6 @@ const checkForTableName = ( line: string ): void => { } }; -const INSERT_STATEMENT_MODIFIERS = new Set( [ - 'IGNORE', - 'LOW_PRIORITY', - 'DELAYED', - 'HIGH_PRIORITY', -] ); - -interface SqlIdentifier { - name: string; - endIndex: number; -} - -interface InsertStatementInfo { - tableName: string; - tableEndIndex: number; -} - -const DEFAULT_OPTIONS_INSERT_COLUMNS = [ 'option_id', 'option_name', 'option_value', 'autoload' ]; - -const skipSqlWhitespace = ( line: string, startIndex: number ): number => { - let index = startIndex; - while ( index < line.length && /\s/.test( line[ index ] ) ) { - index += 1; - } - return index; -}; - -const readSqlIdentifier = ( line: string, startIndex: number ): SqlIdentifier | undefined => { - const index = skipSqlWhitespace( line, startIndex ); - if ( index >= line.length ) { - return undefined; - } - - if ( '`' === line[ index ] ) { - const endIndex = line.indexOf( '`', index + 1 ); - if ( -1 === endIndex ) { - return undefined; - } - return { name: line.slice( index + 1, endIndex ), endIndex: endIndex + 1 }; - } - - const matches = /^[a-z0-9_$]+/i.exec( line.slice( index ) ); - if ( ! matches ) { - return undefined; - } - - return { name: matches[ 0 ], endIndex: index + matches[ 0 ].length }; -}; - -const getInsertStatementInfo = ( line: string ): InsertStatementInfo | undefined => { - const statementMatches = /^\s*(?:INSERT|REPLACE)\b/i.exec( line ); - if ( ! statementMatches ) { - return undefined; - } - - let index = statementMatches[ 0 ].length; - let tableName: SqlIdentifier | undefined; - - while ( true ) { - const identifier = readSqlIdentifier( line, index ); - if ( ! identifier ) { - return undefined; - } - - const keyword = identifier.name.toUpperCase(); - index = identifier.endIndex; - - if ( INSERT_STATEMENT_MODIFIERS.has( keyword ) || 'INTO' === keyword ) { - continue; - } - - tableName = identifier; - break; - } - - const dotIndex = skipSqlWhitespace( line, tableName.endIndex ); - if ( '.' !== line[ dotIndex ] ) { - return { tableName: tableName.name, tableEndIndex: tableName.endIndex }; - } - - const qualifiedTableName = readSqlIdentifier( line, dotIndex + 1 ); - return { - tableName: qualifiedTableName?.name ?? tableName.name, - tableEndIndex: qualifiedTableName?.endIndex ?? tableName.endIndex, - }; -}; - -const isWordPressOptionsTable = ( tableName: string | undefined ): boolean => { - const normalizedTableName = tableName?.toLowerCase() ?? ''; - if ( 'wp_options' === normalizedTableName ) { - return true; - } - - if ( ! normalizedTableName.startsWith( 'wp_' ) || ! normalizedTableName.endsWith( '_options' ) ) { - return false; - } - - const tableId = normalizedTableName.slice( 'wp_'.length, -'_options'.length ); - return tableId.length > 0 && [ ...tableId ].every( char => char >= '0' && char <= '9' ); -}; - -const checkRequiresOptionsInsertContext = ( checkKey: string ): boolean => { - return 'siteHomeUrl' === checkKey || 'siteHomeUrlLando' === checkKey; -}; - -const normalizeSqlIdentifier = ( identifier: string ): string => { - return identifier.replace( /^`|`$/g, '' ).toLowerCase(); -}; - -const findValuesKeywordIndex = ( line: string ): number => { - const valuesMatches = /\bVALUES\b/i.exec( line ); - return valuesMatches?.index ?? -1; -}; - -const parseInsertColumnList = ( line: string, startIndex: number ): string[] | undefined => { - const valuesIndex = findValuesKeywordIndex( line ); - const openingParenthesisIndex = line.indexOf( '(', startIndex ); - if ( - -1 === openingParenthesisIndex || - ( -1 !== valuesIndex && openingParenthesisIndex > valuesIndex ) - ) { - return undefined; - } - - const closingParenthesisIndex = line.indexOf( ')', openingParenthesisIndex + 1 ); - if ( -1 === closingParenthesisIndex ) { - return undefined; - } - - const columns = line - .slice( openingParenthesisIndex + 1, closingParenthesisIndex ) - .split( ',' ) - .map( column => normalizeSqlIdentifier( column.trim() ) ) - .filter( Boolean ); - - return columns.length > 0 ? columns : undefined; -}; - -const parseInsertColumnListSegment = ( columnList: string ): string[] | undefined => { - const columns = columnList - .split( ',' ) - .map( column => normalizeSqlIdentifier( column.trim() ) ) - .filter( Boolean ); - - return columns.length > 0 ? columns : undefined; -}; - const collectInsertColumnList = ( line: string, startIndex: number ): string[] | undefined => { if ( undefined !== currentInsertStatementColumnList ) { const closingParenthesisIndex = line.indexOf( ')' ); @@ -708,22 +572,6 @@ const collectInsertColumnList = ( line: string, startIndex: number ): string[] | return undefined; }; -const isEscapedByBackslash = ( line: string, index: number ): boolean => { - let backslashCount = 0; - let currentIndex = index - 1; - while ( currentIndex >= 0 && '\\' === line[ currentIndex ] ) { - backslashCount += 1; - currentIndex -= 1; - } - - return 1 === backslashCount % 2; -}; - -interface SqlTupleRowsParseResult { - rows: string[][]; - remainder?: string; -} - const isSqlCommentOnlyLine = ( line: string ): boolean => { const trimmedLine = line.trim(); @@ -751,178 +599,6 @@ const isSqlCommentOnlyLine = ( line: string ): boolean => { return '' === trimmedLine.slice( blockCommentEndIndex + 2 ).trim(); }; -const stripSqlCommentsOutsideQuotedStrings = ( line: string ): string => { - let uncommentedLine = ''; - let quote: string | undefined; - - for ( let index = 0; index < line.length; index += 1 ) { - const char = line[ index ]; - - if ( quote ) { - uncommentedLine += char; - - if ( char === quote ) { - if ( line[ index + 1 ] === quote ) { - uncommentedLine += line[ index + 1 ]; - index += 1; - continue; - } - - if ( ! isEscapedByBackslash( line, index ) ) { - quote = undefined; - } - } - - continue; - } - - if ( "'" === char || '"' === char ) { - quote = char; - uncommentedLine += char; - continue; - } - - if ( '-' === char && '-' === line[ index + 1 ] ) { - const nextChar = line[ index + 2 ]; - if ( undefined === nextChar || /\s/.test( nextChar ) ) { - return uncommentedLine.trimEnd(); - } - } - - if ( '#' === char ) { - return uncommentedLine.trimEnd(); - } - - if ( '/' === char && '*' === line[ index + 1 ] ) { - const blockCommentEndIndex = line.indexOf( '*/', index + 2 ); - if ( -1 === blockCommentEndIndex ) { - return uncommentedLine.trimEnd(); - } - - index = blockCommentEndIndex + 1; - continue; - } - - uncommentedLine += char; - } - - return uncommentedLine; -}; - -const parseSqlTupleRows = ( line: string, startIndex: number ): SqlTupleRowsParseResult => { - const rows: string[][] = []; - let currentRow: string[] | undefined; - let currentValue = ''; - let parenthesisDepth = 0; - let quote: string | undefined; - let rowStartIndex: number | undefined; - - for ( let index = startIndex; index < line.length; index += 1 ) { - const char = line[ index ]; - - if ( quote ) { - currentValue += char; - - if ( char === quote ) { - if ( line[ index + 1 ] === quote ) { - currentValue += line[ index + 1 ]; - index += 1; - continue; - } - - if ( ! isEscapedByBackslash( line, index ) ) { - quote = undefined; - } - } - - continue; - } - - if ( "'" === char || '"' === char ) { - quote = char; - currentValue += char; - continue; - } - - if ( '(' === char ) { - if ( 0 === parenthesisDepth ) { - currentRow = []; - currentValue = ''; - rowStartIndex = index; - } else { - currentValue += char; - } - - parenthesisDepth += 1; - continue; - } - - if ( ')' === char && currentRow ) { - parenthesisDepth -= 1; - - if ( 0 === parenthesisDepth ) { - currentRow.push( currentValue.trim() ); - rows.push( currentRow ); - currentRow = undefined; - currentValue = ''; - rowStartIndex = undefined; - continue; - } - } - - if ( ',' === char && 1 === parenthesisDepth && currentRow ) { - currentRow.push( currentValue.trim() ); - currentValue = ''; - continue; - } - - if ( currentRow ) { - currentValue += char; - } - } - - return { - rows, - remainder: undefined === rowStartIndex ? undefined : line.slice( rowStartIndex ), - }; -}; - -const unquoteSqlValue = ( value: string | undefined ): string => { - const trimmedValue = value?.trim() ?? ''; - const quote = trimmedValue[ 0 ]; - if ( ! quote || ( "'" !== quote && '"' !== quote ) || trimmedValue.at( -1 ) !== quote ) { - return trimmedValue; - } - - return trimmedValue - .slice( 1, -1 ) - .replaceAll( quote + quote, quote ) - .replaceAll( '\\' + quote, quote ); -}; - -const getOptionUrlMatchResults = ( row: string[], columns?: string[] ): string[] | undefined => { - const optionColumns = columns ?? DEFAULT_OPTIONS_INSERT_COLUMNS; - const optionNameIndex = optionColumns.indexOf( 'option_name' ); - const optionValueIndex = optionColumns.indexOf( 'option_value' ); - - if ( -1 === optionNameIndex || -1 === optionValueIndex ) { - return undefined; - } - - const optionName = unquoteSqlValue( row[ optionNameIndex ] ).toLowerCase(); - const optionValue = unquoteSqlValue( row[ optionValueIndex ] ); - - if ( 'siteurl' !== optionName && 'home' !== optionName ) { - return undefined; - } - - if ( ! /^https?:\/\//i.test( optionValue ) ) { - return undefined; - } - - return [ '', optionName, optionValue ]; -}; - const collectOptionUrlMatchesFromRows = ( rows: string[][] ): string[][] => { return rows .map( row => getOptionUrlMatchResults( row, currentInsertStatementColumns ) ) From 9a04c74d4b64c7689dd4f088d1b9349bef45b6fe Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Sat, 30 May 2026 23:13:30 +0300 Subject: [PATCH 3/8] test(unit): cover SQL insert-parser helpers Purpose and Context The pure helpers extracted into sql-insert-parser.ts were previously verified only through fixture-driven integration tests. A dedicated unit-test layer localizes regressions to the specific helper rather than reporting them as missing warning text downstream. Key Changes - Add __tests__/lib/validations/sql-insert-parser.js with one describe per helper (14 helpers + constants block). - Cover edge cases for SQL quoting (single, double, doubled, backslash-escaped), comment styles (--, #, block, inline, inside-string), qualified and backtick-quoted table names, INSERT modifiers, multisite numeric options tables, reordered column lists, and multiline tuple buffers. - Lock the parseSqlTupleRows contract that values keep their surrounding quotes (unquoteSqlValue is the separate unwrapper). Impact and Considerations Tests only. No production-code change. Total 98 assertions across 15 describe blocks. Testing and Validation - npx jest --runTestsByPath __tests__/lib/validations/sql-insert-parser.js --runInBand - npx eslint __tests__/lib/validations/sql-insert-parser.js - npm run check-types --- .../lib/validations/sql-insert-parser.js | 593 ++++++++++++++++++ 1 file changed, 593 insertions(+) create mode 100644 __tests__/lib/validations/sql-insert-parser.js diff --git a/__tests__/lib/validations/sql-insert-parser.js b/__tests__/lib/validations/sql-insert-parser.js new file mode 100644 index 000000000..2c2889882 --- /dev/null +++ b/__tests__/lib/validations/sql-insert-parser.js @@ -0,0 +1,593 @@ +/** + * @format + */ + +import { + DEFAULT_OPTIONS_INSERT_COLUMNS, + INSERT_STATEMENT_MODIFIERS, + checkRequiresOptionsInsertContext, + findValuesKeywordIndex, + getInsertStatementInfo, + getOptionUrlMatchResults, + isEscapedByBackslash, + isWordPressOptionsTable, + normalizeSqlIdentifier, + parseInsertColumnList, + parseInsertColumnListSegment, + parseSqlTupleRows, + readSqlIdentifier, + skipSqlWhitespace, + stripSqlCommentsOutsideQuotedStrings, + unquoteSqlValue, +} from '../../../src/lib/validations/sql-insert-parser'; + +describe( 'sql-insert-parser', () => { + describe( 'constants', () => { + it( 'exposes the canonical INSERT statement modifiers', () => { + expect( INSERT_STATEMENT_MODIFIERS ).toBeInstanceOf( Set ); + expect( [ ...INSERT_STATEMENT_MODIFIERS ].sort() ).toEqual( [ + 'DELAYED', + 'HIGH_PRIORITY', + 'IGNORE', + 'LOW_PRIORITY', + ] ); + } ); + + it( 'exposes the default wp_options column ordering', () => { + expect( DEFAULT_OPTIONS_INSERT_COLUMNS ).toEqual( [ + 'option_id', + 'option_name', + 'option_value', + 'autoload', + ] ); + } ); + } ); + + describe( 'skipSqlWhitespace', () => { + it( 'returns startIndex when the current character is non-whitespace', () => { + expect( skipSqlWhitespace( 'abc', 0 ) ).toBe( 0 ); + expect( skipSqlWhitespace( 'abc', 1 ) ).toBe( 1 ); + } ); + + it( 'skips a run of plain spaces', () => { + expect( skipSqlWhitespace( ' abc', 0 ) ).toBe( 3 ); + } ); + + it( 'skips a run of mixed whitespace including tabs and newlines', () => { + expect( skipSqlWhitespace( ' \t\n abc', 0 ) ).toBe( 4 ); + } ); + + it( 'returns line.length when whitespace runs to end of string', () => { + const line = ' \t\n'; + expect( skipSqlWhitespace( line, 0 ) ).toBe( line.length ); + } ); + + it( 'returns startIndex when startIndex is at or past end of line', () => { + expect( skipSqlWhitespace( 'abc', 3 ) ).toBe( 3 ); + expect( skipSqlWhitespace( 'abc', 10 ) ).toBe( 10 ); + } ); + } ); + + describe( 'readSqlIdentifier', () => { + it( 'returns undefined when only whitespace remains', () => { + expect( readSqlIdentifier( ' ', 0 ) ).toBeUndefined(); + } ); + + it( 'reads a bare identifier and returns endIndex past its last char', () => { + expect( readSqlIdentifier( 'wp_options foo', 0 ) ).toEqual( { + name: 'wp_options', + endIndex: 10, + } ); + } ); + + it( 'reads a backtick-quoted identifier and returns endIndex past the closing backtick', () => { + expect( readSqlIdentifier( '`my db`.tbl', 0 ) ).toEqual( { + name: 'my db', + endIndex: 7, + } ); + } ); + + it( 'returns undefined when an opening backtick is not closed', () => { + expect( readSqlIdentifier( '`unterminated', 0 ) ).toBeUndefined(); + } ); + + it( 'returns undefined when the next non-whitespace char is non-identifier punctuation', () => { + expect( readSqlIdentifier( '(foo)', 0 ) ).toBeUndefined(); + } ); + + it( 'skips leading whitespace before reading the identifier', () => { + expect( readSqlIdentifier( ' wp_options', 0 ) ).toEqual( { + name: 'wp_options', + endIndex: 13, + } ); + } ); + } ); + + describe( 'getInsertStatementInfo', () => { + it( 'returns undefined for non-INSERT lines', () => { + expect( getInsertStatementInfo( 'SELECT * FROM wp_options' ) ).toBeUndefined(); + } ); + + it( 'recognizes a plain INSERT INTO statement', () => { + const line = 'INSERT INTO wp_options (option_name) VALUES ("home")'; + expect( getInsertStatementInfo( line ) ).toEqual( { + tableName: 'wp_options', + tableEndIndex: 'INSERT INTO wp_options'.length, + } ); + } ); + + it( 'recognizes REPLACE INTO statements', () => { + const line = 'REPLACE INTO wp_options (option_name) VALUES ("home")'; + expect( getInsertStatementInfo( line ) ).toEqual( { + tableName: 'wp_options', + tableEndIndex: 'REPLACE INTO wp_options'.length, + } ); + } ); + + it( 'skips a single modifier before INTO', () => { + const result = getInsertStatementInfo( + 'INSERT IGNORE INTO wp_options (option_name) VALUES ("home")' + ); + expect( result?.tableName ).toBe( 'wp_options' ); + } ); + + it( 'skips chained modifiers before INTO', () => { + const result = getInsertStatementInfo( + 'INSERT LOW_PRIORITY IGNORE INTO wp_options (option_name) VALUES ("home")' + ); + expect( result?.tableName ).toBe( 'wp_options' ); + } ); + + it( 'recognizes DELAYED and HIGH_PRIORITY modifiers', () => { + expect( + getInsertStatementInfo( 'INSERT DELAYED INTO wp_options VALUES ("home")' )?.tableName + ).toBe( 'wp_options' ); + expect( + getInsertStatementInfo( 'INSERT HIGH_PRIORITY INTO wp_options VALUES ("home")' )?.tableName + ).toBe( 'wp_options' ); + } ); + + it( 'handles missing INTO keyword by treating the next identifier as the table', () => { + const line = 'INSERT wp_options VALUES ("home")'; + expect( getInsertStatementInfo( line ) ).toEqual( { + tableName: 'wp_options', + tableEndIndex: 17, + } ); + } ); + + it( 'resolves a qualified db.table reference to the table identifier', () => { + const result = getInsertStatementInfo( + 'INSERT INTO db.wp_options (option_name) VALUES ("home")' + ); + expect( result?.tableName ).toBe( 'wp_options' ); + } ); + + it( 'resolves a backtick-qualified `db`.`table` reference to the table identifier', () => { + const result = getInsertStatementInfo( + 'INSERT INTO `db`.`wp_options` (option_name) VALUES ("home")' + ); + expect( result?.tableName ).toBe( 'wp_options' ); + } ); + + it( 'returns table info when the line ends right after the table name', () => { + const line = 'INSERT INTO wp_options'; + expect( getInsertStatementInfo( line ) ).toEqual( { + tableName: 'wp_options', + tableEndIndex: line.length, + } ); + } ); + + it( 'is case-insensitive on the INSERT INTO keywords', () => { + expect( + getInsertStatementInfo( 'insert into wp_options (option_name) VALUES ("home")' )?.tableName + ).toBe( 'wp_options' ); + } ); + } ); + + describe( 'isWordPressOptionsTable', () => { + it( 'returns true for the canonical wp_options table', () => { + expect( isWordPressOptionsTable( 'wp_options' ) ).toBe( true ); + } ); + + it( 'is case-insensitive on the table name', () => { + expect( isWordPressOptionsTable( 'WP_Options' ) ).toBe( true ); + } ); + + it( 'returns true for multisite wp__options variants', () => { + expect( isWordPressOptionsTable( 'wp_2_options' ) ).toBe( true ); + expect( isWordPressOptionsTable( 'wp_12_options' ) ).toBe( true ); + } ); + + it( 'returns false when the site id segment is empty', () => { + expect( isWordPressOptionsTable( 'wp__options' ) ).toBe( false ); + } ); + + it( 'returns false when the site id segment is non-numeric', () => { + expect( isWordPressOptionsTable( 'wp_a_options' ) ).toBe( false ); + } ); + + it( 'returns false when the site id segment mixes letters and digits', () => { + expect( isWordPressOptionsTable( 'wp_2a_options' ) ).toBe( false ); + } ); + + it( 'returns false for unrelated wp_ tables', () => { + expect( isWordPressOptionsTable( 'wp_posts' ) ).toBe( false ); + } ); + + it( 'returns false for an unprefixed options table', () => { + expect( isWordPressOptionsTable( 'options' ) ).toBe( false ); + } ); + + it( 'returns false for an undefined table name', () => { + expect( isWordPressOptionsTable( undefined ) ).toBe( false ); + } ); + } ); + + describe( 'checkRequiresOptionsInsertContext', () => { + it( 'returns true for siteHomeUrl', () => { + expect( checkRequiresOptionsInsertContext( 'siteHomeUrl' ) ).toBe( true ); + } ); + + it( 'returns true for siteHomeUrlLando', () => { + expect( checkRequiresOptionsInsertContext( 'siteHomeUrlLando' ) ).toBe( true ); + } ); + + it( 'returns false for unrelated check keys', () => { + expect( checkRequiresOptionsInsertContext( 'binaryLogging' ) ).toBe( false ); + } ); + + it( 'returns false for the empty string', () => { + expect( checkRequiresOptionsInsertContext( '' ) ).toBe( false ); + } ); + } ); + + describe( 'normalizeSqlIdentifier', () => { + it( 'returns a bare lowercase identifier unchanged', () => { + expect( normalizeSqlIdentifier( 'option_name' ) ).toBe( 'option_name' ); + } ); + + it( 'strips surrounding backticks', () => { + expect( normalizeSqlIdentifier( '`option_name`' ) ).toBe( 'option_name' ); + } ); + + it( 'lowercases uppercase identifiers', () => { + expect( normalizeSqlIdentifier( 'OPTION_NAME' ) ).toBe( 'option_name' ); + } ); + + it( 'strips backticks and lowercases together', () => { + expect( normalizeSqlIdentifier( '`Option_Name`' ) ).toBe( 'option_name' ); + } ); + } ); + + describe( 'findValuesKeywordIndex', () => { + it( 'returns the index of the VALUES keyword', () => { + const line = 'INSERT INTO wp_options VALUES (1)'; + expect( findValuesKeywordIndex( line ) ).toBe( line.indexOf( 'VALUES' ) ); + } ); + + it( 'returns -1 when VALUES is absent', () => { + expect( findValuesKeywordIndex( 'INSERT INTO wp_options (option_name)' ) ).toBe( -1 ); + } ); + + it( 'matches lowercase and mixed-case VALUES', () => { + expect( findValuesKeywordIndex( 'insert into wp_options values (1)' ) ).toBeGreaterThan( -1 ); + expect( findValuesKeywordIndex( 'INSERT INTO wp_options Values (1)' ) ).toBeGreaterThan( -1 ); + } ); + + it( 'enforces word boundaries and does not match substrings like MYVALUES', () => { + expect( findValuesKeywordIndex( 'NOVALUES' ) ).toBe( -1 ); + } ); + } ); + + describe( 'parseInsertColumnList', () => { + it( 'returns the lowercased column list following the table name', () => { + const line = 'INSERT INTO wp_options (option_name, option_value, autoload) VALUES (1,2,3)'; + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toEqual( [ + 'option_name', + 'option_value', + 'autoload', + ] ); + } ); + + it( 'returns undefined when the opening parenthesis is after the VALUES keyword', () => { + const line = 'INSERT INTO wp_options VALUES (1, 2, 3)'; + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toBeUndefined(); + } ); + + it( 'returns undefined when no opening parenthesis is between startIndex and VALUES', () => { + const line = 'INSERT INTO wp_options VALUES 1, 2, 3'; + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toBeUndefined(); + } ); + + it( 'returns undefined when the column list opens but does not close on the same line', () => { + const line = 'INSERT INTO wp_options (option_name'; + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toBeUndefined(); + } ); + + it( 'strips backticks from the column list', () => { + const line = 'INSERT INTO wp_options (`option_name`, `option_value`) VALUES (1,2)'; + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toEqual( [ + 'option_name', + 'option_value', + ] ); + } ); + + it( 'filters out empty entries produced by trailing commas', () => { + const line = 'INSERT INTO wp_options (option_name, option_value,) VALUES (1,2)'; + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toEqual( [ + 'option_name', + 'option_value', + ] ); + } ); + } ); + + describe( 'parseInsertColumnListSegment', () => { + it( 'parses a comma-separated segment into normalized columns', () => { + expect( parseInsertColumnListSegment( 'option_name, option_value, autoload' ) ).toEqual( [ + 'option_name', + 'option_value', + 'autoload', + ] ); + } ); + + it( 'handles a multi-line column list with embedded newlines', () => { + expect( parseInsertColumnListSegment( 'option_name,\noption_value,\nautoload' ) ).toEqual( [ + 'option_name', + 'option_value', + 'autoload', + ] ); + } ); + + it( 'returns undefined for empty input', () => { + expect( parseInsertColumnListSegment( '' ) ).toBeUndefined(); + } ); + + it( 'strips backticks and lowercases entries', () => { + expect( parseInsertColumnListSegment( '`Option_Name`, `Option_Value`' ) ).toEqual( [ + 'option_name', + 'option_value', + ] ); + } ); + } ); + + describe( 'isEscapedByBackslash', () => { + it( 'returns true when a single backslash immediately precedes the index', () => { + expect( isEscapedByBackslash( "\\'", 1 ) ).toBe( true ); + } ); + + it( 'returns false when two backslashes precede the index', () => { + expect( isEscapedByBackslash( "\\\\'", 2 ) ).toBe( false ); + } ); + + it( 'returns true when an odd run of backslashes precedes the index', () => { + expect( isEscapedByBackslash( "\\\\\\'", 3 ) ).toBe( true ); + } ); + + it( 'returns false when no backslash precedes the index', () => { + expect( isEscapedByBackslash( "abc'", 3 ) ).toBe( false ); + } ); + + it( 'returns false when index is 0 (no characters before it)', () => { + expect( isEscapedByBackslash( "'abc", 0 ) ).toBe( false ); + } ); + } ); + + describe( 'stripSqlCommentsOutsideQuotedStrings', () => { + it( 'returns a plain line without comments unchanged', () => { + expect( stripSqlCommentsOutsideQuotedStrings( 'foo bar' ) ).toBe( 'foo bar' ); + } ); + + it( 'strips a -- line comment and trims trailing whitespace before it', () => { + expect( stripSqlCommentsOutsideQuotedStrings( 'foo -- comment' ) ).toBe( 'foo' ); + } ); + + it( 'strips a # line comment', () => { + expect( stripSqlCommentsOutsideQuotedStrings( 'foo # comment' ) ).toBe( 'foo' ); + } ); + + it( 'strips a /* ... */ block comment while preserving surrounding text', () => { + const result = stripSqlCommentsOutsideQuotedStrings( 'foo /* block */ bar' ); + expect( result ).not.toContain( 'block' ); + expect( result ).toBe( 'foo bar' ); + } ); + + it( 'treats an unterminated /* as the start of a comment that runs to end of line', () => { + expect( stripSqlCommentsOutsideQuotedStrings( 'foo /* unterminated' ) ).toBe( 'foo' ); + } ); + + it( 'does not treat -- inside single-quoted strings as a comment', () => { + const input = "'foo -- inside' bar"; + expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); + } ); + + it( 'does not treat block-comment markers inside single-quoted strings as comments', () => { + const input = "'before /* inside */ after'"; + expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); + } ); + + it( 'does not treat -- inside double-quoted strings as a comment', () => { + const input = '"foo -- not a comment"'; + expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); + } ); + + it( "handles a doubled '' SQL quote escape inside a string", () => { + const input = "'it''s'"; + expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); + } ); + + it( 'handles a backslash-escaped quote inside a string', () => { + const input = "'it\\'s'"; + expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); + } ); + + it( 'does not treat -- without trailing whitespace or end-of-string as a comment', () => { + expect( stripSqlCommentsOutsideQuotedStrings( 'foo--bar' ) ).toBe( 'foo--bar' ); + } ); + + it( 'returns the empty string unchanged', () => { + expect( stripSqlCommentsOutsideQuotedStrings( '' ) ).toBe( '' ); + } ); + } ); + + describe( 'parseSqlTupleRows', () => { + it( 'parses a single tuple and keeps surrounding quotes on each value', () => { + expect( parseSqlTupleRows( "('a', 'b', 'c')", 0 ) ).toEqual( { + rows: [ [ "'a'", "'b'", "'c'" ] ], + remainder: undefined, + } ); + } ); + + it( 'parses multiple tuples on one line', () => { + expect( parseSqlTupleRows( "('a','b'),('c','d')", 0 ) ).toEqual( { + rows: [ + [ "'a'", "'b'" ], + [ "'c'", "'d'" ], + ], + remainder: undefined, + } ); + } ); + + it( 'returns completed rows plus a remainder slice for an unterminated trailing tuple', () => { + const line = "('a','b'),('c'"; + const result = parseSqlTupleRows( line, 0 ); + expect( result.rows ).toEqual( [ [ "'a'", "'b'" ] ] ); + expect( result.remainder ).toBe( line.slice( line.lastIndexOf( '(' ) ) ); + } ); + + it( 'does not split a value at a quoted comma', () => { + expect( parseSqlTupleRows( "('a,b','c')", 0 ) ).toEqual( { + rows: [ [ "'a,b'", "'c'" ] ], + remainder: undefined, + } ); + } ); + + it( 'does not close a tuple at a quoted parenthesis', () => { + expect( parseSqlTupleRows( "('a)b','c')", 0 ) ).toEqual( { + rows: [ [ "'a)b'", "'c'" ] ], + remainder: undefined, + } ); + } ); + + it( "handles a doubled '' quote escape inside a value", () => { + expect( parseSqlTupleRows( "('it''s','x')", 0 ) ).toEqual( { + rows: [ [ "'it''s'", "'x'" ] ], + remainder: undefined, + } ); + } ); + + it( 'handles a backslash-escaped quote inside a value', () => { + expect( parseSqlTupleRows( "('a\\'b','c')", 0 ) ).toEqual( { + rows: [ [ "'a\\'b'", "'c'" ] ], + remainder: undefined, + } ); + } ); + + it( 'returns empty rows for an empty input', () => { + expect( parseSqlTupleRows( '', 0 ) ).toEqual( { rows: [], remainder: undefined } ); + } ); + + it( 'returns empty rows for whitespace-only input', () => { + expect( parseSqlTupleRows( ' ', 0 ) ).toEqual( { rows: [], remainder: undefined } ); + } ); + + it( 'skips garbage before the first opening parenthesis', () => { + expect( parseSqlTupleRows( "... ('a','b')", 0 ) ).toEqual( { + rows: [ [ "'a'", "'b'" ] ], + remainder: undefined, + } ); + } ); + } ); + + describe( 'unquoteSqlValue', () => { + it( 'unwraps a single-quoted value', () => { + expect( unquoteSqlValue( "'foo'" ) ).toBe( 'foo' ); + } ); + + it( 'unwraps a double-quoted value', () => { + expect( unquoteSqlValue( '"foo"' ) ).toBe( 'foo' ); + } ); + + it( 'passes through an unquoted bare value', () => { + expect( unquoteSqlValue( 'foo' ) ).toBe( 'foo' ); + } ); + + it( 'returns the trimmed value (not unwrapped) when the surrounding quotes do not match', () => { + expect( unquoteSqlValue( '\'foo"' ) ).toBe( '\'foo"' ); + } ); + + it( "collapses doubled '' quote escapes inside a quoted value", () => { + expect( unquoteSqlValue( "'it''s'" ) ).toBe( "it's" ); + } ); + + it( 'collapses backslash-escaped quotes inside a quoted value', () => { + expect( unquoteSqlValue( "'it\\'s'" ) ).toBe( "it's" ); + } ); + + it( 'returns the empty string for undefined input', () => { + expect( unquoteSqlValue( undefined ) ).toBe( '' ); + } ); + + it( 'trims surrounding whitespace before unwrapping', () => { + expect( unquoteSqlValue( " 'foo' " ) ).toBe( 'foo' ); + } ); + } ); + + describe( 'getOptionUrlMatchResults', () => { + it( 'returns a siteurl match using the default columns and a full row', () => { + expect( + getOptionUrlMatchResults( [ '1', "'siteurl'", "'https://example.com'", "'yes'" ] ) + ).toEqual( [ '', 'siteurl', 'https://example.com' ] ); + } ); + + it( 'returns a home match using the default columns', () => { + expect( + getOptionUrlMatchResults( [ '1', "'home'", "'https://example.com'", "'yes'" ] ) + ).toEqual( [ '', 'home', 'https://example.com' ] ); + } ); + + it( 'returns undefined when option_name is unrelated', () => { + expect( + getOptionUrlMatchResults( [ '1', "'blogdescription'", "'https://example.com'", "'yes'" ] ) + ).toBeUndefined(); + } ); + + it( 'returns undefined when option_value is not a URL', () => { + expect( + getOptionUrlMatchResults( [ '1', "'siteurl'", "'plain-text'", "'yes'" ] ) + ).toBeUndefined(); + } ); + + it( 'matches an uppercase HTTPS scheme (case-insensitive)', () => { + expect( + getOptionUrlMatchResults( [ '1', "'siteurl'", "'HTTPS://Example.com'", "'yes'" ] ) + ).toEqual( [ '', 'siteurl', 'HTTPS://Example.com' ] ); + } ); + + it( 'honours an explicit reordered columns argument', () => { + expect( + getOptionUrlMatchResults( + [ "'https://reordered.example'", "'siteurl'", "'yes'" ], + [ 'option_value', 'option_name', 'autoload' ] + ) + ).toEqual( [ '', 'siteurl', 'https://reordered.example' ] ); + } ); + + it( 'returns undefined when the columns array lacks option_name or option_value', () => { + expect( + getOptionUrlMatchResults( [ "'siteurl'", "'https://example.com'" ], [ 'foo', 'bar' ] ) + ).toBeUndefined(); + } ); + + it( "passes a doubled '' quote escape through unquoting before the URL test", () => { + expect( + getOptionUrlMatchResults( [ '1', "'siteurl'", "'https://it''s.example'", "'yes'" ] ) + ).toEqual( [ '', 'siteurl', "https://it's.example" ] ); + } ); + } ); +} ); From 0b308ca8e5661eda4e4721483043d7cf95051bdc Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Sun, 31 May 2026 00:15:40 +0300 Subject: [PATCH 4/8] fix(sql): ignore multiline commented inserts Purpose and Context A review comment identified that multiline block comments could still allow commented options-table rows to be parsed as real siteurl/home rows. The parser stripped an unterminated block comment on the current line, but insert detection did not carry comment state across the SQL stream. Key Changes - Add stream-level SQL comment stripping state for insert detection. - Reuse the stripped physical line for insert detection, option-row collection, and statement reset. - Rewrite the comment scanner as an explicit while loop so it no longer assigns to a for-loop counter. - Add regression coverage for top-level and active-insert multiline block comments containing commented options rows. Impact and Considerations This narrows dev-env SQL validation warnings by ignoring options rows inside multiline block comments. Generic non-insert validation checks continue to read the raw line, preserving the existing validation surface outside options-row URL suggestions. No migrations, configuration changes, or deployment steps are required. Testing and Validation - npx jest --runTestsByPath __tests__/lib/validations/sql-insert-parser.js --runInBand - npx jest --runTestsByPath __tests__/lib/validations/sql.js --runInBand - npx eslint src/lib/validations/sql-insert-parser.ts src/lib/validations/sql.ts __tests__/lib/validations/sql-insert-parser.js __tests__/lib/validations/sql.js - npm run check-types - npm test --- __fixtures__/validations/bad-sql-dev-env.sql | 12 +++ .../lib/validations/sql-insert-parser.js | 28 ++++++ __tests__/lib/validations/sql.js | 20 +++++ src/lib/validations/sql-insert-parser.ts | 88 +++++++++++++++---- src/lib/validations/sql.ts | 53 ++++------- 5 files changed, 145 insertions(+), 56 deletions(-) diff --git a/__fixtures__/validations/bad-sql-dev-env.sql b/__fixtures__/validations/bad-sql-dev-env.sql index 887d1b524..af31ec473 100644 --- a/__fixtures__/validations/bad-sql-dev-env.sql +++ b/__fixtures__/validations/bad-sql-dev-env.sql @@ -3,6 +3,10 @@ CREATE DATABASE automatticians; -- for dev-env you should not switch database USE automatticians; +/* +INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'https://top-level-commented-insert.example', 'yes'); +*/ INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'https://after-top-level-block.example', 'yes'); + INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'https://super-employees-go.vip.net', 'yes'), @@ -49,6 +53,14 @@ VALUES ('blogdescription', 'https://hash-inline-control.example', 'yes'), # ('home', 'https://hash-inline-comment.example', 'yes'), ('blogdescription', 'https://block-inline-control.example', 'yes') /* ('siteurl', 'https://block-inline-comment.example', 'yes') */; +INSERT INTO wp_options (option_name, option_value, autoload) +VALUES + ('blogdescription', 'https://multiline-block-control.example', 'yes') /* + INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'https://commented-insert.example', 'yes'); + ('siteurl', 'https://multiline-block-comment.example', 'yes'), + ('home', 'https://multiline-block-home-comment.example', 'yes') + */ , ('siteurl', 'https://after-multiline-block.example', 'yes'); + INSERT INTO wp_postmeta (meta_key, meta_value) VALUES ('home', 'https://unrelated-table.example', 'yes'); diff --git a/__tests__/lib/validations/sql-insert-parser.js b/__tests__/lib/validations/sql-insert-parser.js index 2c2889882..c84a5fe25 100644 --- a/__tests__/lib/validations/sql-insert-parser.js +++ b/__tests__/lib/validations/sql-insert-parser.js @@ -397,6 +397,24 @@ describe( 'sql-insert-parser', () => { expect( result ).toBe( 'foo bar' ); } ); + it( 'carries block-comment state across lines when a state object is provided', () => { + const state = { inBlockComment: false }; + + expect( stripSqlCommentsOutsideQuotedStrings( 'foo /* start', state ) ).toBe( 'foo' ); + expect( state ).toEqual( { inBlockComment: true } ); + + expect( + stripSqlCommentsOutsideQuotedStrings( + "('siteurl', 'https://commented.example', 'yes'),", + state + ) + ).toBe( '' ); + expect( state ).toEqual( { inBlockComment: true } ); + + expect( stripSqlCommentsOutsideQuotedStrings( '*/ bar', state ) ).toBe( 'bar' ); + expect( state ).toEqual( { inBlockComment: false } ); + } ); + it( 'treats an unterminated /* as the start of a comment that runs to end of line', () => { expect( stripSqlCommentsOutsideQuotedStrings( 'foo /* unterminated' ) ).toBe( 'foo' ); } ); @@ -411,11 +429,21 @@ describe( 'sql-insert-parser', () => { expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); } ); + it( 'does not treat # inside single-quoted strings as a comment', () => { + const input = "'foo # inside' bar"; + expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); + } ); + it( 'does not treat -- inside double-quoted strings as a comment', () => { const input = '"foo -- not a comment"'; expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); } ); + it( 'does not treat block-comment markers inside double-quoted strings as comments', () => { + const input = '"before /* inside */ after"'; + expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); + } ); + it( "handles a doubled '' SQL quote escape inside a string", () => { const input = "'it''s'"; expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( input ); diff --git a/__tests__/lib/validations/sql.js b/__tests__/lib/validations/sql.js index 50cbb87ca..a1772d466 100644 --- a/__tests__/lib/validations/sql.js +++ b/__tests__/lib/validations/sql.js @@ -260,6 +260,26 @@ describe( 'lib/validations/sql', () => { 'Use \'--search-replace="block-inline-comment.example,test.domain"\' switch to replace the domain' ); } ); + it( 'should not suggest replacements from multiline block comment option rows', () => { + expect( output ).not.toContain( + 'Use \'--search-replace="top-level-commented-insert.example,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="after-top-level-block.example,test.domain"\' switch to replace the domain' + ); + expect( output ).not.toContain( + 'Use \'--search-replace="commented-insert.example,test.domain"\' switch to replace the domain' + ); + expect( output ).not.toContain( + 'Use \'--search-replace="multiline-block-comment.example,test.domain"\' switch to replace the domain' + ); + expect( output ).not.toContain( + 'Use \'--search-replace="multiline-block-home-comment.example,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="after-multiline-block.example,test.domain"\' switch to replace the domain' + ); + } ); it( 'should preserve comment markers inside quoted option values', () => { expect( output ).toContain( 'Use \'--search-replace="quoted-dash.example/path--kept,test.domain"\' switch to replace the domain' diff --git a/src/lib/validations/sql-insert-parser.ts b/src/lib/validations/sql-insert-parser.ts index a6e6c6d0c..6d892616d 100644 --- a/src/lib/validations/sql-insert-parser.ts +++ b/src/lib/validations/sql-insert-parser.ts @@ -169,20 +169,71 @@ export interface SqlTupleRowsParseResult { remainder?: string; } -export const stripSqlCommentsOutsideQuotedStrings = ( line: string ): string => { +export interface SqlCommentStripState { + inBlockComment: boolean; +} + +const isSqlQuoteStart = ( char: string ): boolean => "'" === char || '"' === char; + +const isSqlBlockCommentStart = ( line: string, index: number ): boolean => + '/' === line[ index ] && '*' === line[ index + 1 ]; + +const isSqlBlockCommentEnd = ( line: string, index: number ): boolean => + '*' === line[ index ] && '/' === line[ index + 1 ]; + +const isSqlDashCommentStart = ( line: string, index: number ): boolean => { + if ( '-' !== line[ index ] || '-' !== line[ index + 1 ] ) { + return false; + } + + const afterCommentMarker = line[ index + 2 ]; + return undefined === afterCommentMarker || /\s/.test( afterCommentMarker ); +}; + +const setSqlCommentStripState = ( + state: SqlCommentStripState | undefined, + inBlockComment: boolean +): void => { + if ( state ) { + state.inBlockComment = inBlockComment; + } +}; + +export const stripSqlCommentsOutsideQuotedStrings = ( + line: string, + state?: SqlCommentStripState +): string => { let uncommentedLine = ''; let quote: string | undefined; + let inBlockComment = state?.inBlockComment ?? false; - for ( let index = 0; index < line.length; index += 1 ) { + let index = 0; + while ( index < line.length ) { const char = line[ index ]; + const nextChar = line[ index + 1 ]; + + if ( inBlockComment ) { + if ( isSqlBlockCommentEnd( line, index ) ) { + inBlockComment = false; + setSqlCommentStripState( state, false ); + index += 2; + if ( '' === uncommentedLine ) { + index = skipSqlWhitespace( line, index ); + } + continue; + } + + index += 1; + continue; + } if ( quote ) { uncommentedLine += char; if ( char === quote ) { - if ( line[ index + 1 ] === quote ) { - uncommentedLine += line[ index + 1 ]; - index += 1; + if ( nextChar === quote ) { + uncommentedLine += nextChar; + index += 2; continue; } @@ -191,37 +242,38 @@ export const stripSqlCommentsOutsideQuotedStrings = ( line: string ): string => } } + index += 1; continue; } - if ( "'" === char || '"' === char ) { + if ( isSqlQuoteStart( char ) ) { quote = char; uncommentedLine += char; + index += 1; continue; } - if ( '-' === char && '-' === line[ index + 1 ] ) { - const nextChar = line[ index + 2 ]; - if ( undefined === nextChar || /\s/.test( nextChar ) ) { - return uncommentedLine.trimEnd(); - } + if ( isSqlDashCommentStart( line, index ) ) { + return uncommentedLine.trimEnd(); } if ( '#' === char ) { return uncommentedLine.trimEnd(); } - if ( '/' === char && '*' === line[ index + 1 ] ) { - const blockCommentEndIndex = line.indexOf( '*/', index + 2 ); - if ( -1 === blockCommentEndIndex ) { - return uncommentedLine.trimEnd(); - } - - index = blockCommentEndIndex + 1; + if ( isSqlBlockCommentStart( line, index ) ) { + inBlockComment = true; + setSqlCommentStripState( state, true ); + index += 2; continue; } uncommentedLine += char; + index += 1; + } + + if ( inBlockComment ) { + return uncommentedLine.trimEnd(); } return uncommentedLine; diff --git a/src/lib/validations/sql.ts b/src/lib/validations/sql.ts index d96488a6e..8cc8f0fa3 100644 --- a/src/lib/validations/sql.ts +++ b/src/lib/validations/sql.ts @@ -19,6 +19,7 @@ import { parseInsertColumnList, parseInsertColumnListSegment, parseSqlTupleRows, + type SqlCommentStripState, stripSqlCommentsOutsideQuotedStrings, } from '../../lib/validations/sql-insert-parser'; import { OmitIndexSignature } from '../types'; @@ -31,7 +32,7 @@ let currentInsertStatementColumns: string[] | undefined; let currentInsertStatementColumnList: string | undefined; let currentInsertStatementHasValues = false; let currentInsertStatementRowBuffer: string | undefined; -let currentInsertStatementInBlockComment = false; +let currentSqlCommentStripState: SqlCommentStripState = { inBlockComment: false }; function formatError( message: string ): string { return `${ chalk.red( 'SQL Error:' ) } ${ message }`; @@ -573,30 +574,7 @@ const collectInsertColumnList = ( line: string, startIndex: number ): string[] | }; const isSqlCommentOnlyLine = ( line: string ): boolean => { - const trimmedLine = line.trim(); - - if ( currentInsertStatementInBlockComment ) { - if ( trimmedLine.includes( '*/' ) ) { - currentInsertStatementInBlockComment = false; - } - return true; - } - - if ( /^--(?:\s|$)/.test( trimmedLine ) || trimmedLine.startsWith( '#' ) ) { - return true; - } - - if ( ! trimmedLine.startsWith( '/*' ) ) { - return false; - } - - const blockCommentEndIndex = trimmedLine.indexOf( '*/' ); - if ( -1 === blockCommentEndIndex ) { - currentInsertStatementInBlockComment = true; - return true; - } - - return '' === trimmedLine.slice( blockCommentEndIndex + 2 ).trim(); + return '' === line.trim(); }; const collectOptionUrlMatchesFromRows = ( rows: string[][] ): string[][] => { @@ -615,17 +593,15 @@ const collectOptionsInsertRows = ( line: string, startIndex: number ): string[][ return collectOptionUrlMatchesFromRows( rows ); }; -const collectOptionsInsertMatches = ( line: string, isCommentOnlyLine: boolean ): string[][] => { +const collectOptionsInsertMatches = ( uncommentedLine: string ): string[][] => { if ( ! isWordPressOptionsTable( currentInsertStatementTableName ) ) { return []; } - if ( isCommentOnlyLine ) { + if ( isSqlCommentOnlyLine( uncommentedLine ) ) { return []; } - const uncommentedLine = stripSqlCommentsOutsideQuotedStrings( line ); - if ( ! currentInsertStatementHasValues ) { currentInsertStatementColumns = currentInsertStatementColumns ?? collectInsertColumnList( uncommentedLine, 0 ); @@ -658,20 +634,22 @@ const perLineValidations = ( checkForTableName( line ); - const insertStatementInfo = getInsertStatementInfo( line ); + const uncommentedLine = stripSqlCommentsOutsideQuotedStrings( line, currentSqlCommentStripState ); + + const insertStatementInfo = getInsertStatementInfo( uncommentedLine ); if ( insertStatementInfo ) { currentInsertStatementTableName = insertStatementInfo.tableName; currentInsertStatementColumns = parseInsertColumnList( - line, + uncommentedLine, insertStatementInfo.tableEndIndex ); currentInsertStatementColumnList = undefined; currentInsertStatementHasValues = false; currentInsertStatementRowBuffer = undefined; - currentInsertStatementInBlockComment = false; } - const isCommentOnlyLine = currentInsertStatementTableName ? isSqlCommentOnlyLine( line ) : false; - const optionsInsertMatches = collectOptionsInsertMatches( line, isCommentOnlyLine ); + const optionsInsertMatches = currentInsertStatementTableName + ? collectOptionsInsertMatches( uncommentedLine ) + : []; const checkKeys = Object.keys( checks ).filter( checkItem => ! options.skipChecks.includes( checkItem ) @@ -695,15 +673,14 @@ const perLineValidations = ( if ( currentInsertStatementTableName && - ! isCommentOnlyLine && - stripSqlCommentsOutsideQuotedStrings( line ).trimEnd().endsWith( ';' ) + uncommentedLine && + uncommentedLine.trimEnd().endsWith( ';' ) ) { currentInsertStatementTableName = undefined; currentInsertStatementColumns = undefined; currentInsertStatementColumnList = undefined; currentInsertStatementHasValues = false; currentInsertStatementRowBuffer = undefined; - currentInsertStatementInBlockComment = false; } lineNum += 1; @@ -735,7 +712,7 @@ export const validate = async ( currentInsertStatementColumnList = undefined; currentInsertStatementHasValues = false; currentInsertStatementRowBuffer = undefined; - currentInsertStatementInBlockComment = false; + currentSqlCommentStripState = { inBlockComment: false }; const fileMeta = await getFileMeta( filename ); From 355d5d430adfb12e8ded22c9e3672d759de9242e Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Sun, 31 May 2026 00:45:12 +0300 Subject: [PATCH 5/8] fix(sql): support singular VALUE inserts Purpose and Context MySQL accepts the singular VALUE keyword for single-row inserts. The parsed options-row validation path only recognized VALUES, so valid wp_options rows using VALUE were skipped and dev-env validation missed siteurl/home URL warnings that the previous broad matcher would have seen. Key Changes - Add centralized VALUE/VALUES keyword detection with matched bounds. - Use the matched keyword end index when option-row tuple parsing starts, instead of assuming the plural VALUES length. - Keep parser keyword boundaries aligned with the SQL identifier character set, including underscores and dollar signs. - Add unit and fixture-backed coverage for singular VALUE siteurl and home rows, column-list disambiguation, and false-positive tokens. Impact and Considerations This extends the supported options-row dump shapes without changing warning copy or validation policy. Existing plural VALUES behavior is preserved, and larger tokens such as NOVALUES, value_backup, and VALUE$backup are not treated as SQL value-list keywords. Testing and Validation - npx jest --runTestsByPath __tests__/lib/validations/sql-insert-parser.js --runInBand - npx jest --runTestsByPath __tests__/lib/validations/sql.js --runInBand - npx eslint src/lib/validations/sql-insert-parser.ts src/lib/validations/sql.ts __tests__/lib/validations/sql-insert-parser.js __tests__/lib/validations/sql.js - npm run check-types - npm test --- __fixtures__/validations/bad-sql-dev-env.sql | 7 ++ .../lib/validations/sql-insert-parser.js | 82 +++++++++++++++++- __tests__/lib/validations/sql.js | 6 ++ src/lib/validations/sql-insert-parser.ts | 84 ++++++++++++++++++- src/lib/validations/sql.ts | 7 +- 5 files changed, 178 insertions(+), 8 deletions(-) diff --git a/__fixtures__/validations/bad-sql-dev-env.sql b/__fixtures__/validations/bad-sql-dev-env.sql index af31ec473..3c687509f 100644 --- a/__fixtures__/validations/bad-sql-dev-env.sql +++ b/__fixtures__/validations/bad-sql-dev-env.sql @@ -17,11 +17,18 @@ INSERT INTO wp_options (option_name, option_value, autoload) INSERT INTO wp_options VALUES (1, 'siteurl', 'https://full-order.example', 'yes'); +INSERT INTO wp_options VALUE + (2, 'siteurl', 'https://singular-default.example', 'yes'); + INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'https://split-header.example', 'yes'); +INSERT INTO wp_options (option_id, option_name, option_value, autoload) +VALUE + (3, 'home', 'https://singular-explicit.example', 'yes'); + INSERT INTO wp_options ( option_name, option_value, diff --git a/__tests__/lib/validations/sql-insert-parser.js b/__tests__/lib/validations/sql-insert-parser.js index c84a5fe25..4154d58dc 100644 --- a/__tests__/lib/validations/sql-insert-parser.js +++ b/__tests__/lib/validations/sql-insert-parser.js @@ -6,6 +6,7 @@ import { DEFAULT_OPTIONS_INSERT_COLUMNS, INSERT_STATEMENT_MODIFIERS, checkRequiresOptionsInsertContext, + findValuesKeyword, findValuesKeywordIndex, getInsertStatementInfo, getOptionUrlMatchResults, @@ -265,17 +266,76 @@ describe( 'sql-insert-parser', () => { expect( findValuesKeywordIndex( line ) ).toBe( line.indexOf( 'VALUES' ) ); } ); + it( 'returns the index of the VALUE keyword', () => { + const line = 'INSERT INTO wp_options VALUE (1)'; + expect( findValuesKeywordIndex( line ) ).toBe( line.indexOf( 'VALUE' ) ); + } ); + it( 'returns -1 when VALUES is absent', () => { expect( findValuesKeywordIndex( 'INSERT INTO wp_options (option_name)' ) ).toBe( -1 ); } ); - it( 'matches lowercase and mixed-case VALUES', () => { + it( 'matches lowercase and mixed-case VALUE and VALUES', () => { + expect( findValuesKeywordIndex( 'insert into wp_options value (1)' ) ).toBeGreaterThan( -1 ); + expect( findValuesKeywordIndex( 'INSERT INTO wp_options Value (1)' ) ).toBeGreaterThan( -1 ); expect( findValuesKeywordIndex( 'insert into wp_options values (1)' ) ).toBeGreaterThan( -1 ); expect( findValuesKeywordIndex( 'INSERT INTO wp_options Values (1)' ) ).toBeGreaterThan( -1 ); } ); - it( 'enforces word boundaries and does not match substrings like MYVALUES', () => { + it( 'enforces word boundaries and does not match substrings inside larger tokens', () => { expect( findValuesKeywordIndex( 'NOVALUES' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( 'value_backup' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( 'MYVALUE' ) ).toBe( -1 ); + } ); + + it( 'does not match VALUE when adjacent to SQL identifier characters', () => { + expect( findValuesKeywordIndex( 'aVALUE' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( '1VALUE' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( '_VALUE' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( '$VALUE' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( 'VALUEa' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( 'VALUE1' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( 'VALUE_backup' ) ).toBe( -1 ); + expect( findValuesKeywordIndex( 'VALUE$backup' ) ).toBe( -1 ); + } ); + + it( 'matches standalone VALUE and VALUES with punctuation boundaries', () => { + expect( findValuesKeywordIndex( '(VALUE)' ) ).toBe( 1 ); + expect( findValuesKeywordIndex( ',VALUES;' ) ).toBe( 1 ); + } ); + + it( 'returns the real VALUES index after an earlier identifier false positive', () => { + const line = 'value$db.wp_options VALUES (1)'; + expect( findValuesKeywordIndex( line ) ).toBe( line.indexOf( 'VALUES' ) ); + } ); + + it( 'skips backtick identifiers and quoted strings before a real VALUES keyword', () => { + const line = '`VALUES` "VALUE" VALUES (1)'; + expect( findValuesKeywordIndex( line ) ).toBe( line.lastIndexOf( 'VALUES' ) ); + } ); + } ); + + describe( 'findValuesKeyword', () => { + it( 'returns keyword bounds for singular VALUE', () => { + const line = 'INSERT INTO wp_options VALUE (1)'; + const index = line.indexOf( 'VALUE' ); + expect( findValuesKeyword( line ) ).toEqual( { + index, + endIndex: index + 'VALUE'.length, + } ); + } ); + + it( 'returns keyword bounds for plural VALUES', () => { + const line = 'INSERT INTO wp_options VALUES (1)'; + const index = line.indexOf( 'VALUES' ); + expect( findValuesKeyword( line ) ).toEqual( { + index, + endIndex: index + 'VALUES'.length, + } ); + } ); + + it( 'returns undefined when VALUE(S) is absent', () => { + expect( findValuesKeyword( 'INSERT INTO wp_options (option_name)' ) ).toBeUndefined(); } ); } ); @@ -296,6 +356,24 @@ describe( 'sql-insert-parser', () => { expect( parseInsertColumnList( line, startIndex ) ).toBeUndefined(); } ); + it( 'returns the lowercased column list before the VALUE keyword', () => { + const line = + 'INSERT INTO wp_options (option_id, option_name, option_value, autoload) VALUE (1,2,3,4)'; + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toEqual( [ + 'option_id', + 'option_name', + 'option_value', + 'autoload', + ] ); + } ); + + it( 'returns undefined when the opening parenthesis is after the VALUE keyword', () => { + const line = 'INSERT INTO wp_options VALUE (1, 2, 3, 4)'; + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toBeUndefined(); + } ); + it( 'returns undefined when no opening parenthesis is between startIndex and VALUES', () => { const line = 'INSERT INTO wp_options VALUES 1, 2, 3'; const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; diff --git a/__tests__/lib/validations/sql.js b/__tests__/lib/validations/sql.js index a1772d466..e33bbab69 100644 --- a/__tests__/lib/validations/sql.js +++ b/__tests__/lib/validations/sql.js @@ -204,6 +204,12 @@ describe( 'lib/validations/sql', () => { expect( output ).toContain( 'Use \'--search-replace="full-order.example,test.domain"\' switch to replace the domain' ); + expect( output ).toContain( + 'Use \'--search-replace="singular-default.example,test.domain"\' switch to replace the domain' + ); + expect( output ).toContain( + 'Use \'--search-replace="singular-explicit.example,test.domain"\' switch to replace the domain' + ); expect( output ).toContain( 'Use \'--search-replace="split-header.example,test.domain"\' switch to replace the domain' ); diff --git a/src/lib/validations/sql-insert-parser.ts b/src/lib/validations/sql-insert-parser.ts index 6d892616d..96a9a8911 100644 --- a/src/lib/validations/sql-insert-parser.ts +++ b/src/lib/validations/sql-insert-parser.ts @@ -15,6 +15,15 @@ export interface InsertStatementInfo { tableEndIndex: number; } +export interface SqlValuesKeywordMatch { + index: number; + endIndex: number; +} + +const SQL_IDENTIFIER_REGEX = /^[a-z0-9_$]+/i; +const SQL_IDENTIFIER_CHAR_REGEX = /^[a-z0-9_$]$/i; +const VALUES_KEYWORD_REGEX = /^VALUES?/i; + export const DEFAULT_OPTIONS_INSERT_COLUMNS = [ 'option_id', 'option_name', @@ -47,7 +56,7 @@ export const readSqlIdentifier = ( return { name: line.slice( index + 1, endIndex ), endIndex: endIndex + 1 }; } - const matches = /^[a-z0-9_$]+/i.exec( line.slice( index ) ); + const matches = SQL_IDENTIFIER_REGEX.exec( line.slice( index ) ); if ( ! matches ) { return undefined; } @@ -115,9 +124,78 @@ export const normalizeSqlIdentifier = ( identifier: string ): string => { return identifier.replace( /^`|`$/g, '' ).toLowerCase(); }; +const isSqlIdentifierChar = ( char: string | undefined ): boolean => { + return undefined !== char && SQL_IDENTIFIER_CHAR_REGEX.test( char ); +}; + +export const findValuesKeyword = ( line: string ): SqlValuesKeywordMatch | undefined => { + let quote: string | undefined; + let inBacktickIdentifier = false; + + for ( let index = 0; index < line.length; index += 1 ) { + const char = line[ index ]; + const nextChar = line[ index + 1 ]; + + if ( inBacktickIdentifier ) { + if ( '`' === char ) { + if ( '`' === nextChar ) { + index += 1; + continue; + } + + inBacktickIdentifier = false; + } + + continue; + } + + if ( quote ) { + if ( char === quote ) { + if ( nextChar === quote ) { + index += 1; + continue; + } + + if ( ! isEscapedByBackslash( line, index ) ) { + quote = undefined; + } + } + + continue; + } + + if ( '`' === char ) { + inBacktickIdentifier = true; + continue; + } + + if ( "'" === char || '"' === char ) { + quote = char; + continue; + } + + const valuesMatches = VALUES_KEYWORD_REGEX.exec( line.slice( index ) ); + if ( ! valuesMatches ) { + continue; + } + + const endIndex = index + valuesMatches[ 0 ].length; + if ( isSqlIdentifierChar( line[ index - 1 ] ) || isSqlIdentifierChar( line[ endIndex ] ) ) { + index = endIndex - 1; + continue; + } + + return { + index, + endIndex, + }; + } + + return undefined; +}; + export const findValuesKeywordIndex = ( line: string ): number => { - const valuesMatches = /\bVALUES\b/i.exec( line ); - return valuesMatches?.index ?? -1; + return findValuesKeyword( line )?.index ?? -1; }; export const parseInsertColumnList = ( line: string, startIndex: number ): string[] | undefined => { diff --git a/src/lib/validations/sql.ts b/src/lib/validations/sql.ts index 8cc8f0fa3..3958c2a9d 100644 --- a/src/lib/validations/sql.ts +++ b/src/lib/validations/sql.ts @@ -12,6 +12,7 @@ import { } from '../../lib/validations/line-by-line'; import { checkRequiresOptionsInsertContext, + findValuesKeyword, findValuesKeywordIndex, getInsertStatementInfo, getOptionUrlMatchResults, @@ -606,13 +607,13 @@ const collectOptionsInsertMatches = ( uncommentedLine: string ): string[][] => { currentInsertStatementColumns = currentInsertStatementColumns ?? collectInsertColumnList( uncommentedLine, 0 ); - const valuesIndex = findValuesKeywordIndex( uncommentedLine ); - if ( -1 === valuesIndex ) { + const valuesKeyword = findValuesKeyword( uncommentedLine ); + if ( ! valuesKeyword ) { return []; } currentInsertStatementHasValues = true; - return collectOptionsInsertRows( uncommentedLine, valuesIndex + 'VALUES'.length ); + return collectOptionsInsertRows( uncommentedLine, valuesKeyword.endIndex ); } return collectOptionsInsertRows( uncommentedLine, 0 ); From 9aca9564381b47c711e5679fa80e33580d85005e Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Sun, 31 May 2026 01:22:39 +0300 Subject: [PATCH 6/8] fix: make SonarScan happy --- src/lib/validations/sql.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/validations/sql.ts b/src/lib/validations/sql.ts index 3958c2a9d..e899b72ef 100644 --- a/src/lib/validations/sql.ts +++ b/src/lib/validations/sql.ts @@ -674,7 +674,7 @@ const perLineValidations = ( if ( currentInsertStatementTableName && - uncommentedLine && + uncommentedLine.length > 0 && uncommentedLine.trimEnd().endsWith( ';' ) ) { currentInsertStatementTableName = undefined; From 51bfcf491df0f05941e295478950856b3169a68d Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Sun, 31 May 2026 01:32:03 +0300 Subject: [PATCH 7/8] refactor: reduce complexity of `findValuesKeyword()` --- src/lib/validations/sql-insert-parser.ts | 86 ++++++++++++------------ 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/src/lib/validations/sql-insert-parser.ts b/src/lib/validations/sql-insert-parser.ts index 96a9a8911..4210ef0b9 100644 --- a/src/lib/validations/sql-insert-parser.ts +++ b/src/lib/validations/sql-insert-parser.ts @@ -128,67 +128,65 @@ const isSqlIdentifierChar = ( char: string | undefined ): boolean => { return undefined !== char && SQL_IDENTIFIER_CHAR_REGEX.test( char ); }; -export const findValuesKeyword = ( line: string ): SqlValuesKeywordMatch | undefined => { - let quote: string | undefined; - let inBacktickIdentifier = false; +const skipSqlDelimitedSegment = ( line: string, startIndex: number ): number | undefined => { + const delimiter = line[ startIndex ]; + if ( '`' !== delimiter && "'" !== delimiter && '"' !== delimiter ) { + return undefined; + } - for ( let index = 0; index < line.length; index += 1 ) { + for ( let index = startIndex + 1; index < line.length; index += 1 ) { const char = line[ index ]; const nextChar = line[ index + 1 ]; - if ( inBacktickIdentifier ) { - if ( '`' === char ) { - if ( '`' === nextChar ) { - index += 1; - continue; - } - - inBacktickIdentifier = false; - } - + if ( char !== delimiter ) { continue; } - if ( quote ) { - if ( char === quote ) { - if ( nextChar === quote ) { - index += 1; - continue; - } - - if ( ! isEscapedByBackslash( line, index ) ) { - quote = undefined; - } - } - + if ( nextChar === delimiter ) { + index += 1; continue; } - if ( '`' === char ) { - inBacktickIdentifier = true; - continue; + if ( '`' === delimiter || ! isEscapedByBackslash( line, index ) ) { + return index; } + } - if ( "'" === char || '"' === char ) { - quote = char; - continue; - } + return line.length - 1; +}; - const valuesMatches = VALUES_KEYWORD_REGEX.exec( line.slice( index ) ); - if ( ! valuesMatches ) { - continue; - } +const getValuesKeywordMatchAt = ( + line: string, + index: number +): SqlValuesKeywordMatch | undefined => { + const valuesMatches = VALUES_KEYWORD_REGEX.exec( line.slice( index ) ); + if ( ! valuesMatches ) { + return undefined; + } + + const endIndex = index + valuesMatches[ 0 ].length; + if ( isSqlIdentifierChar( line[ index - 1 ] ) || isSqlIdentifierChar( line[ endIndex ] ) ) { + return undefined; + } - const endIndex = index + valuesMatches[ 0 ].length; - if ( isSqlIdentifierChar( line[ index - 1 ] ) || isSqlIdentifierChar( line[ endIndex ] ) ) { - index = endIndex - 1; + return { + index, + endIndex, + }; +}; + +export const findValuesKeyword = ( line: string ): SqlValuesKeywordMatch | undefined => { + for ( let index = 0; index < line.length; index += 1 ) { + const segmentEndIndex = skipSqlDelimitedSegment( line, index ); + if ( undefined !== segmentEndIndex ) { + index = segmentEndIndex; continue; } - return { - index, - endIndex, - }; + const valuesKeyword = getValuesKeywordMatchAt( line, index ); + if ( valuesKeyword ) { + return valuesKeyword; + } } return undefined; From f2fac1c2ce4a26963c75ce8d0d5c8b6e293c9d6a Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Sun, 31 May 2026 01:35:54 +0300 Subject: [PATCH 8/8] refactor: reduce complexity of `stripSqlCommentsOutsideQuotedStrings()` --- src/lib/validations/sql-insert-parser.ts | 114 +++++++++++++---------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/src/lib/validations/sql-insert-parser.ts b/src/lib/validations/sql-insert-parser.ts index 4210ef0b9..a8eff196f 100644 --- a/src/lib/validations/sql-insert-parser.ts +++ b/src/lib/validations/sql-insert-parser.ts @@ -249,6 +249,16 @@ export interface SqlCommentStripState { inBlockComment: boolean; } +interface SqlTextSegment { + text: string; + nextIndex: number; +} + +interface SqlBlockCommentSkipResult { + inBlockComment: boolean; + nextIndex: number; +} + const isSqlQuoteStart = ( char: string ): boolean => "'" === char || '"' === char; const isSqlBlockCommentStart = ( line: string, index: number ): boolean => @@ -266,6 +276,9 @@ const isSqlDashCommentStart = ( line: string, index: number ): boolean => { return undefined === afterCommentMarker || /\s/.test( afterCommentMarker ); }; +const isSqlLineCommentStart = ( line: string, index: number ): boolean => + '#' === line[ index ] || isSqlDashCommentStart( line, index ); + const setSqlCommentStripState = ( state: SqlCommentStripState | undefined, inBlockComment: boolean @@ -275,76 +288,83 @@ const setSqlCommentStripState = ( } }; +const readSqlQuotedSegment = ( line: string, startIndex: number ): SqlTextSegment | undefined => { + if ( ! isSqlQuoteStart( line[ startIndex ] ) ) { + return undefined; + } + + const endIndex = skipSqlDelimitedSegment( line, startIndex ); + if ( undefined === endIndex ) { + return undefined; + } + + return { + text: line.slice( startIndex, endIndex + 1 ), + nextIndex: endIndex + 1, + }; +}; + +const skipSqlBlockComment = ( + line: string, + startIndex: number, + uncommentedLine: string, + state?: SqlCommentStripState +): SqlBlockCommentSkipResult => { + for ( let index = startIndex; index < line.length; index += 1 ) { + if ( ! isSqlBlockCommentEnd( line, index ) ) { + continue; + } + + setSqlCommentStripState( state, false ); + const nextIndex = index + 2; + return { + inBlockComment: false, + nextIndex: '' === uncommentedLine ? skipSqlWhitespace( line, nextIndex ) : nextIndex, + }; + } + + setSqlCommentStripState( state, true ); + return { + inBlockComment: true, + nextIndex: line.length, + }; +}; + export const stripSqlCommentsOutsideQuotedStrings = ( line: string, state?: SqlCommentStripState ): string => { let uncommentedLine = ''; - let quote: string | undefined; let inBlockComment = state?.inBlockComment ?? false; let index = 0; while ( index < line.length ) { - const char = line[ index ]; - const nextChar = line[ index + 1 ]; - if ( inBlockComment ) { - if ( isSqlBlockCommentEnd( line, index ) ) { - inBlockComment = false; - setSqlCommentStripState( state, false ); - index += 2; - if ( '' === uncommentedLine ) { - index = skipSqlWhitespace( line, index ); - } - continue; - } - - index += 1; - continue; - } - - if ( quote ) { - uncommentedLine += char; - - if ( char === quote ) { - if ( nextChar === quote ) { - uncommentedLine += nextChar; - index += 2; - continue; - } - - if ( ! isEscapedByBackslash( line, index ) ) { - quote = undefined; - } - } - - index += 1; + const blockComment = skipSqlBlockComment( line, index, uncommentedLine, state ); + inBlockComment = blockComment.inBlockComment; + index = blockComment.nextIndex; continue; } - if ( isSqlQuoteStart( char ) ) { - quote = char; - uncommentedLine += char; - index += 1; + const quotedSegment = readSqlQuotedSegment( line, index ); + if ( quotedSegment ) { + uncommentedLine += quotedSegment.text; + index = quotedSegment.nextIndex; continue; } - if ( isSqlDashCommentStart( line, index ) ) { - return uncommentedLine.trimEnd(); - } - - if ( '#' === char ) { + if ( isSqlLineCommentStart( line, index ) ) { return uncommentedLine.trimEnd(); } if ( isSqlBlockCommentStart( line, index ) ) { - inBlockComment = true; - setSqlCommentStripState( state, true ); - index += 2; + const blockComment = skipSqlBlockComment( line, index + 2, uncommentedLine, state ); + inBlockComment = blockComment.inBlockComment; + index = blockComment.nextIndex; continue; } - uncommentedLine += char; + uncommentedLine += line[ index ]; index += 1; }