diff --git a/__fixtures__/validations/bad-sql-dev-env.sql b/__fixtures__/validations/bad-sql-dev-env.sql index a8a248fb0..3c687509f 100644 --- a/__fixtures__/validations/bad-sql-dev-env.sql +++ b/__fixtures__/validations/bad-sql-dev-env.sql @@ -3,8 +3,91 @@ 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'), ('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 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, + 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_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'); + +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-insert-parser.js b/__tests__/lib/validations/sql-insert-parser.js new file mode 100644 index 000000000..4154d58dc --- /dev/null +++ b/__tests__/lib/validations/sql-insert-parser.js @@ -0,0 +1,699 @@ +/** + * @format + */ + +import { + DEFAULT_OPTIONS_INSERT_COLUMNS, + INSERT_STATEMENT_MODIFIERS, + checkRequiresOptionsInsertContext, + findValuesKeyword, + 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 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 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 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(); + } ); + } ); + + 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 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; + 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( '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' ); + } ); + + 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 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 ); + } ); + + 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" ] ); + } ); + } ); +} ); diff --git a/__tests__/lib/validations/sql.js b/__tests__/lib/validations/sql.js index 59197ba32..e33bbab69 100644 --- a/__tests__/lib/validations/sql.js +++ b/__tests__/lib/validations/sql.js @@ -190,6 +190,118 @@ 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="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' + ); + 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 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' + ); + 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-insert-parser.ts b/src/lib/validations/sql-insert-parser.ts new file mode 100644 index 000000000..a8eff196f --- /dev/null +++ b/src/lib/validations/sql-insert-parser.ts @@ -0,0 +1,493 @@ +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 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', + '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 = SQL_IDENTIFIER_REGEX.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(); +}; + +const isSqlIdentifierChar = ( char: string | undefined ): boolean => { + return undefined !== char && SQL_IDENTIFIER_CHAR_REGEX.test( char ); +}; + +const skipSqlDelimitedSegment = ( line: string, startIndex: number ): number | undefined => { + const delimiter = line[ startIndex ]; + if ( '`' !== delimiter && "'" !== delimiter && '"' !== delimiter ) { + return undefined; + } + + for ( let index = startIndex + 1; index < line.length; index += 1 ) { + const char = line[ index ]; + const nextChar = line[ index + 1 ]; + + if ( char !== delimiter ) { + continue; + } + + if ( nextChar === delimiter ) { + index += 1; + continue; + } + + if ( '`' === delimiter || ! isEscapedByBackslash( line, index ) ) { + return index; + } + } + + return line.length - 1; +}; + +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; + } + + 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; + } + + const valuesKeyword = getValuesKeywordMatchAt( line, index ); + if ( valuesKeyword ) { + return valuesKeyword; + } + } + + return undefined; +}; + +export const findValuesKeywordIndex = ( line: string ): number => { + return findValuesKeyword( line )?.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 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 => + '/' === 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 isSqlLineCommentStart = ( line: string, index: number ): boolean => + '#' === line[ index ] || isSqlDashCommentStart( line, index ); + +const setSqlCommentStripState = ( + state: SqlCommentStripState | undefined, + inBlockComment: boolean +): void => { + if ( state ) { + state.inBlockComment = inBlockComment; + } +}; + +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 inBlockComment = state?.inBlockComment ?? false; + + let index = 0; + while ( index < line.length ) { + if ( inBlockComment ) { + const blockComment = skipSqlBlockComment( line, index, uncommentedLine, state ); + inBlockComment = blockComment.inBlockComment; + index = blockComment.nextIndex; + continue; + } + + const quotedSegment = readSqlQuotedSegment( line, index ); + if ( quotedSegment ) { + uncommentedLine += quotedSegment.text; + index = quotedSegment.nextIndex; + continue; + } + + if ( isSqlLineCommentStart( line, index ) ) { + return uncommentedLine.trimEnd(); + } + + if ( isSqlBlockCommentStart( line, index ) ) { + const blockComment = skipSqlBlockComment( line, index + 2, uncommentedLine, state ); + inBlockComment = blockComment.inBlockComment; + index = blockComment.nextIndex; + continue; + } + + uncommentedLine += line[ index ]; + index += 1; + } + + if ( inBlockComment ) { + return uncommentedLine.trimEnd(); + } + + 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 6f86fcc04..e899b72ef 100644 --- a/src/lib/validations/sql.ts +++ b/src/lib/validations/sql.ts @@ -10,11 +10,30 @@ import { type PostLineExecutionProcessingParams, getReadInterface, } from '../../lib/validations/line-by-line'; +import { + checkRequiresOptionsInsertContext, + findValuesKeyword, + findValuesKeywordIndex, + getInsertStatementInfo, + getOptionUrlMatchResults, + isWordPressOptionsTable, + parseInsertColumnList, + parseInsertColumnListSegment, + parseSqlTupleRows, + type SqlCommentStripState, + stripSqlCommentsOutsideQuotedStrings, +} from '../../lib/validations/sql-insert-parser'; 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 currentSqlCommentStripState: SqlCommentStripState = { inBlockComment: false }; function formatError( message: string ): string { return `${ chalk.red( 'SQL Error:' ) } ${ message }`; @@ -333,7 +352,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 +361,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 +538,87 @@ const checkForTableName = ( line: string ): void => { } }; +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 isSqlCommentOnlyLine = ( line: string ): boolean => { + return '' === line.trim(); +}; + +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 = ( uncommentedLine: string ): string[][] => { + if ( ! isWordPressOptionsTable( currentInsertStatementTableName ) ) { + return []; + } + + if ( isSqlCommentOnlyLine( uncommentedLine ) ) { + return []; + } + + if ( ! currentInsertStatementHasValues ) { + currentInsertStatementColumns = + currentInsertStatementColumns ?? collectInsertColumnList( uncommentedLine, 0 ); + + const valuesKeyword = findValuesKeyword( uncommentedLine ); + if ( ! valuesKeyword ) { + return []; + } + + currentInsertStatementHasValues = true; + return collectOptionsInsertRows( uncommentedLine, valuesKeyword.endIndex ); + } + + return collectOptionsInsertRows( uncommentedLine, 0 ); +}; + const DEFAULT_VALIDATION_OPTIONS: ValidationOptions = { isImport: true, skipChecks: DEV_ENV_SPECIFIC_CHECKS, @@ -535,18 +635,55 @@ const perLineValidations = ( checkForTableName( line ); + const uncommentedLine = stripSqlCommentsOutsideQuotedStrings( line, currentSqlCommentStripState ); + + const insertStatementInfo = getInsertStatementInfo( uncommentedLine ); + if ( insertStatementInfo ) { + currentInsertStatementTableName = insertStatementInfo.tableName; + currentInsertStatementColumns = parseInsertColumnList( + uncommentedLine, + insertStatementInfo.tableEndIndex + ); + currentInsertStatementColumnList = undefined; + currentInsertStatementHasValues = false; + currentInsertStatementRowBuffer = undefined; + } + const optionsInsertMatches = currentInsertStatementTableName + ? collectOptionsInsertMatches( uncommentedLine ) + : []; + 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 && + uncommentedLine.length > 0 && + uncommentedLine.trimEnd().endsWith( ';' ) + ) { + currentInsertStatementTableName = undefined; + currentInsertStatementColumns = undefined; + currentInsertStatementColumnList = undefined; + currentInsertStatementHasValues = false; + currentInsertStatementRowBuffer = undefined; + } + lineNum += 1; }; @@ -571,6 +708,13 @@ export const validate = async ( filename: string, options: ValidationOptions = DEFAULT_VALIDATION_OPTIONS ): Promise< void > => { + currentInsertStatementTableName = undefined; + currentInsertStatementColumns = undefined; + currentInsertStatementColumnList = undefined; + currentInsertStatementHasValues = false; + currentInsertStatementRowBuffer = undefined; + currentSqlCommentStripState = { inBlockComment: false }; + const fileMeta = await getFileMeta( filename ); if ( fileMeta.isCompressed ) {