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..f2b99c849 --- /dev/null +++ b/__tests__/lib/validations/sql-insert-parser.js @@ -0,0 +1,600 @@ +/** + * @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.each( [ + [ 'returns startIndex when the current character is non-whitespace', 'abc', 0, 0 ], + [ 'returns startIndex when the current character is non-whitespace', 'abc', 1, 1 ], + [ 'skips a run of plain spaces', ' abc', 0, 3 ], + [ 'skips a run of mixed whitespace including tabs and newlines', ' \t\n abc', 0, 4 ], + [ + 'returns line.length when whitespace runs to end of string', + ' \t\n', + 0, + ' \t\n'.length, + ], + [ 'returns startIndex when startIndex is at or past end of line', 'abc', 3, 3 ], + [ 'returns startIndex when startIndex is at or past end of line', 'abc', 10, 10 ], + ] )( '%s', ( _name, line, startIndex, expected ) => { + expect( skipSqlWhitespace( line, startIndex ) ).toBe( expected ); + } ); + } ); + + describe( 'readSqlIdentifier', () => { + it.each( [ + [ 'returns undefined when only whitespace remains', ' ', 0, undefined ], + [ + 'reads a bare identifier and returns endIndex past its last char', + 'wp_options foo', + 0, + { + name: 'wp_options', + endIndex: 10, + }, + ], + [ + 'reads a backtick-quoted identifier and returns endIndex past the closing backtick', + '`my db`.tbl', + 0, + { + name: 'my db', + endIndex: 7, + }, + ], + [ 'returns undefined when an opening backtick is not closed', '`unterminated', 0, undefined ], + [ + 'returns undefined when the next non-whitespace char is non-identifier punctuation', + '(foo)', + 0, + undefined, + ], + [ + 'skips leading whitespace before reading the identifier', + ' wp_options', + 0, + { + name: 'wp_options', + endIndex: 13, + }, + ], + ] )( '%s', ( _name, line, startIndex, expected ) => { + expect( readSqlIdentifier( line, startIndex ) ).toEqual( expected ); + } ); + } ); + + describe( 'getInsertStatementInfo', () => { + it( 'returns undefined for non-INSERT lines', () => { + expect( getInsertStatementInfo( 'SELECT * FROM wp_options' ) ).toBeUndefined(); + } ); + + it.each( [ + [ + 'INSERT INTO wp_options (option_name) VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'INSERT INTO wp_options'.length, + }, + ], + [ + 'REPLACE INTO wp_options (option_name) VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'REPLACE INTO wp_options'.length, + }, + ], + [ + 'INSERT IGNORE INTO wp_options (option_name) VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'INSERT IGNORE INTO wp_options'.length, + }, + ], + [ + 'INSERT LOW_PRIORITY IGNORE INTO wp_options (option_name) VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'INSERT LOW_PRIORITY IGNORE INTO wp_options'.length, + }, + ], + [ + 'INSERT DELAYED INTO wp_options VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'INSERT DELAYED INTO wp_options'.length, + }, + ], + [ + 'INSERT HIGH_PRIORITY INTO wp_options VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'INSERT HIGH_PRIORITY INTO wp_options'.length, + }, + ], + [ + 'INSERT INTO db.wp_options (option_name) VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'INSERT INTO db.wp_options'.length, + }, + ], + [ + 'INSERT INTO `db`.`wp_options` (option_name) VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'INSERT INTO `db`.`wp_options`'.length, + }, + ], + [ + 'insert into wp_options (option_name) VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 'insert into wp_options'.length, + }, + ], + [ + 'INSERT wp_options VALUES ("home")', + { + tableName: 'wp_options', + tableEndIndex: 17, + }, + ], + [ + 'INSERT INTO wp_options', + { + tableName: 'wp_options', + tableEndIndex: 'INSERT INTO wp_options'.length, + }, + ], + ] )( 'resolves table info in %s', ( line, expected ) => { + expect( getInsertStatementInfo( line ) ).toEqual( expected ); + } ); + } ); + + describe( 'isWordPressOptionsTable', () => { + it.each( [ + [ true, 'wp_options' ], + [ true, 'WP_Options' ], + [ true, 'wp_2_options' ], + [ true, 'wp_12_options' ], + [ false, 'wp__options' ], + [ false, 'wp_a_options' ], + [ false, 'wp_2a_options' ], + [ false, 'wp_posts' ], + [ false, 'options' ], + [ false, undefined ], + ] )( 'returns %s for table %s', ( expected, tableName ) => { + expect( isWordPressOptionsTable( tableName ) ).toBe( expected ); + } ); + } ); + + describe( 'checkRequiresOptionsInsertContext', () => { + it.each( [ + [ 'returns true for siteHomeUrl', 'siteHomeUrl', true ], + [ 'returns true for siteHomeUrlLando', 'siteHomeUrlLando', true ], + [ 'returns false for unrelated check keys', 'binaryLogging', false ], + [ 'returns false for the empty string', '', false ], + ] )( '%s', ( _name, checkKey, expected ) => { + expect( checkRequiresOptionsInsertContext( checkKey ) ).toBe( expected ); + } ); + } ); + + describe( 'normalizeSqlIdentifier', () => { + it.each( [ + [ 'returns a bare lowercase identifier unchanged', 'option_name', 'option_name' ], + [ 'strips surrounding backticks', '`option_name`', 'option_name' ], + [ 'lowercases uppercase identifiers', 'OPTION_NAME', 'option_name' ], + [ 'strips backticks and lowercases together', '`Option_Name`', 'option_name' ], + ] )( '%s', ( _name, identifier, expected ) => { + expect( normalizeSqlIdentifier( identifier ) ).toBe( expected ); + } ); + } ); + + 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.each( [ + [ 'returns keyword bounds for singular VALUE', 'INSERT INTO wp_options VALUE (1)', 'VALUE' ], + [ 'returns keyword bounds for plural VALUES', 'INSERT INTO wp_options VALUES (1)', 'VALUES' ], + [ + 'returns undefined when VALUE(S) is absent', + 'INSERT INTO wp_options (option_name)', + undefined, + ], + ] )( '%s', ( _name, line, keyword ) => { + const result = findValuesKeyword( line ); + const expected = + undefined === keyword + ? undefined + : { + index: line.indexOf( keyword ), + endIndex: line.indexOf( keyword ) + keyword.length, + }; + expect( result ).toEqual( expected ); + } ); + } ); + + describe( 'parseInsertColumnList', () => { + it.each( [ + [ + 'returns the lowercased column list following the table name', + 'INSERT INTO wp_options (option_name, option_value, autoload) VALUES (1,2,3)', + [ 'option_name', 'option_value', 'autoload' ], + ], + [ + 'returns undefined when the opening parenthesis is after the VALUES keyword', + 'INSERT INTO wp_options VALUES (1, 2, 3)', + undefined, + ], + [ + 'returns the lowercased column list before the VALUE keyword', + 'INSERT INTO wp_options (option_id, option_name, option_value, autoload) VALUE (1,2,3,4)', + [ 'option_id', 'option_name', 'option_value', 'autoload' ], + ], + [ + 'returns undefined when the opening parenthesis is after the VALUE keyword', + 'INSERT INTO wp_options VALUE (1, 2, 3, 4)', + undefined, + ], + [ + 'returns undefined when no opening parenthesis is between startIndex and VALUES', + 'INSERT INTO wp_options VALUES 1, 2, 3', + undefined, + ], + [ + 'returns undefined when the column list opens but does not close on the same line', + 'INSERT INTO wp_options (option_name', + undefined, + ], + [ + 'strips backticks from the column list', + 'INSERT INTO wp_options (`option_name`, `option_value`) VALUES (1,2)', + [ 'option_name', 'option_value' ], + ], + [ + 'filters out empty entries produced by trailing commas', + 'INSERT INTO wp_options (option_name, option_value,) VALUES (1,2)', + [ 'option_name', 'option_value' ], + ], + ] )( '%s', ( _name, line, expected ) => { + const startIndex = line.indexOf( 'wp_options' ) + 'wp_options'.length; + expect( parseInsertColumnList( line, startIndex ) ).toEqual( expected ); + } ); + } ); + + describe( 'parseInsertColumnListSegment', () => { + it.each( [ + [ + 'parses a comma-separated segment into normalized columns', + 'option_name, option_value, autoload', + [ 'option_name', 'option_value', 'autoload' ], + ], + [ + 'handles a multi-line column list with embedded newlines', + 'option_name,\noption_value,\nautoload', + [ 'option_name', 'option_value', 'autoload' ], + ], + [ 'returns undefined for empty input', '', undefined ], + [ + 'strips backticks and lowercases entries', + '`Option_Name`, `Option_Value`', + [ 'option_name', 'option_value' ], + ], + ] )( '%s', ( _name, input, expected ) => { + expect( parseInsertColumnListSegment( input ) ).toEqual( expected ); + } ); + } ); + + describe( 'isEscapedByBackslash', () => { + it.each( [ + [ 'returns true when a single backslash immediately precedes the index', "\\'", 1, true ], + [ 'returns false when two backslashes precede the index', "\\\\'", 2, false ], + [ 'returns true when an odd run of backslashes precedes the index', "\\\\\\'", 3, true ], + [ 'returns false when no backslash precedes the index', "abc'", 3, false ], + [ 'returns false when index is 0 (no characters before it)', "'abc", 0, false ], + ] )( '%s', ( _name, input, index, expected ) => { + expect( isEscapedByBackslash( input, index ) ).toBe( expected ); + } ); + } ); + + describe( 'stripSqlCommentsOutsideQuotedStrings', () => { + it.each( [ + [ 'returns a plain line without comments unchanged', 'foo bar', 'foo bar' ], + [ + 'strips a -- line comment and trims trailing whitespace before it', + 'foo -- comment', + 'foo', + ], + [ 'strips a # line comment', 'foo # comment', 'foo' ], + [ + 'strips a /* ... */ block comment while preserving surrounding text', + 'foo /* block */ bar', + 'foo bar', + ], + [ + 'treats an unterminated /* as the start of a comment that runs to end of line', + 'foo /* unterminated', + 'foo', + ], + [ + 'does not treat -- inside single-quoted strings as a comment', + "'foo -- inside' bar", + "'foo -- inside' bar", + ], + [ + 'does not treat block-comment markers inside single-quoted strings as comments', + "'before /* inside */ after'", + "'before /* inside */ after'", + ], + [ + 'does not treat # inside single-quoted strings as a comment', + "'foo # inside' bar", + "'foo # inside' bar", + ], + [ + 'does not treat -- inside double-quoted strings as a comment', + '"foo -- not a comment"', + '"foo -- not a comment"', + ], + [ + 'does not treat block-comment markers inside double-quoted strings as comments', + '"before /* inside */ after"', + '"before /* inside */ after"', + ], + [ "handles a doubled '' SQL quote escape inside a string", "'it''s'", "'it''s'" ], + [ 'handles a backslash-escaped quote inside a string', "'it\\'s'", "'it\\'s'" ], + [ + 'does not treat -- without trailing whitespace or end-of-string as a comment', + 'foo--bar', + 'foo--bar', + ], + [ 'returns the empty string unchanged', '', '' ], + ] )( '%s', ( _name, input, expected ) => { + expect( stripSqlCommentsOutsideQuotedStrings( input ) ).toBe( expected ); + } ); + + 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 } ); + } ); + } ); + + describe( 'parseSqlTupleRows', () => { + it.each( [ + [ + 'parses a single tuple and keeps surrounding quotes on each value', + "('a', 'b', 'c')", + { rows: [ [ "'a'", "'b'", "'c'" ] ], remainder: undefined }, + ], + [ + 'parses multiple tuples on one line', + "('a','b'),('c','d')", + { + rows: [ + [ "'a'", "'b'" ], + [ "'c'", "'d'" ], + ], + remainder: undefined, + }, + ], + [ + 'does not split a value at a quoted comma', + "('a,b','c')", + { rows: [ [ "'a,b'", "'c'" ] ], remainder: undefined }, + ], + [ + 'does not close a tuple at a quoted parenthesis', + "('a)b','c')", + { rows: [ [ "'a)b'", "'c'" ] ], remainder: undefined }, + ], + [ + "handles a doubled '' quote escape inside a value", + "('it''s','x')", + { rows: [ [ "'it''s'", "'x'" ] ], remainder: undefined }, + ], + [ + 'handles a backslash-escaped quote inside a value', + "('a\\'b','c')", + { rows: [ [ "'a\\'b'", "'c'" ] ], remainder: undefined }, + ], + [ 'returns empty rows for an empty input', '', { rows: [], remainder: undefined } ], + [ 'returns empty rows for whitespace-only input', ' ', { rows: [], remainder: undefined } ], + [ + 'skips garbage before the first opening parenthesis', + "... ('a','b')", + { rows: [ [ "'a'", "'b'" ] ], remainder: undefined }, + ], + ] )( '%s', ( _name, input, expected ) => { + expect( parseSqlTupleRows( input, 0 ) ).toEqual( expected ); + } ); + + 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( '(' ) ) ); + } ); + } ); + + describe( 'unquoteSqlValue', () => { + it.each( [ + [ 'unwraps a single-quoted value', "'foo'", 'foo' ], + [ 'unwraps a double-quoted value', '"foo"', 'foo' ], + [ 'passes through an unquoted bare value', 'foo', 'foo' ], + [ + 'returns the trimmed value (not unwrapped) when the surrounding quotes do not match', + '\'foo"', + '\'foo"', + ], + [ "collapses doubled '' quote escapes inside a quoted value", "'it''s'", "it's" ], + [ 'collapses backslash-escaped quotes inside a quoted value', "'it\\'s'", "it's" ], + [ 'returns the empty string for undefined input', undefined, '' ], + [ 'trims surrounding whitespace before unwrapping', " 'foo' ", 'foo' ], + ] )( '%s', ( _name, input, expected ) => { + expect( unquoteSqlValue( input ) ).toBe( expected ); + } ); + } ); + + describe( 'getOptionUrlMatchResults', () => { + it.each( [ + [ + 'returns a siteurl match using the default columns and a full row', + [ '1', "'siteurl'", "'https://example.com'", "'yes'" ], + undefined, + [ '', 'siteurl', 'https://example.com' ], + ], + [ + 'returns a home match using the default columns', + [ '1', "'home'", "'https://example.com'", "'yes'" ], + undefined, + [ '', 'home', 'https://example.com' ], + ], + [ + 'returns undefined when option_name is unrelated', + [ '1', "'blogdescription'", "'https://example.com'", "'yes'" ], + undefined, + undefined, + ], + [ + 'returns undefined when option_value is not a URL', + [ '1', "'siteurl'", "'plain-text'", "'yes'" ], + undefined, + undefined, + ], + [ + 'matches an uppercase HTTPS scheme (case-insensitive)', + [ '1', "'siteurl'", "'HTTPS://Example.com'", "'yes'" ], + undefined, + [ '', 'siteurl', 'HTTPS://Example.com' ], + ], + [ + 'honours an explicit reordered columns argument', + [ "'https://reordered.example'", "'siteurl'", "'yes'" ], + [ 'option_value', 'option_name', 'autoload' ], + [ '', 'siteurl', 'https://reordered.example' ], + ], + [ + 'returns undefined when the columns array lacks option_name or option_value', + [ "'siteurl'", "'https://example.com'" ], + [ 'foo', 'bar' ], + undefined, + ], + [ + "passes a doubled '' quote escape through unquoting before the URL test", + [ '1', "'siteurl'", "'https://it''s.example'", "'yes'" ], + undefined, + [ '', 'siteurl', "https://it's.example" ], + ], + ] )( '%s', ( _name, rows, columns, expected ) => { + expect( getOptionUrlMatchResults( rows, columns ) ).toEqual( expected ); + } ); + } ); +} ); diff --git a/__tests__/lib/validations/sql.js b/__tests__/lib/validations/sql.js index 647a43327..3540abdb6 100644 --- a/__tests__/lib/validations/sql.js +++ b/__tests__/lib/validations/sql.js @@ -186,6 +186,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..40282e9d3 --- /dev/null +++ b/src/lib/validations/sql-insert-parser.ts @@ -0,0 +1,484 @@ +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 => + tableName + ? /^wp_\d+_options$/i.test( tableName ) || 'wp_options' === tableName.toLowerCase() + : false; + +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; // NOSONAR + 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..a9f6dd805 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: String.raw`['"](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: String.raw`['"](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 ) {