@@ -38,8 +38,8 @@ var psqlTerminatorRE = regexp.MustCompile(`(?i)\\(g|gx|gset|gdesc|gexec|crosstab
3838// copyFromStdinRE detects COPY ... FROM STDIN statements.
3939var copyFromStdinRE = regexp .MustCompile (`(?i)\bCOPY\b[^;]*\bFROM\b\s+STDIN\b` )
4040
41- // preprocessLines handles psql metacommands and COPY FROM stdin data blocks.
42- // It returns processed lines with metacommands removed and COPY data skipped.
41+ // preprocessLines handles psql metacommands (line-start only) and COPY FROM stdin data blocks.
42+ // It returns processed lines with start-of-line metacommands removed and COPY data skipped.
4343func preprocessLines (content []byte ) []processedLine {
4444 rawLines := bytes .Split (content , []byte ("\n " ))
4545 result := make ([]processedLine , 0 , len (rawLines ))
@@ -48,11 +48,11 @@ func preprocessLines(content []byte) []processedLine {
4848 for i , raw := range rawLines {
4949 lineNum := i + 1
5050 line := string (raw )
51+ line = strings .TrimRight (line , "\r " )
5152
5253 // In COPY data mode, skip until \. terminator
5354 if inCopyData {
54- trimmed := strings .TrimRight (line , "\r " )
55- if trimmed == "\\ ." {
55+ if line == "\\ ." {
5656 inCopyData = false
5757 }
5858 // Skip this line either way (data or terminator)
@@ -73,33 +73,15 @@ func preprocessLines(content []byte) []processedLine {
7373 continue
7474 }
7575
76- // Check for mid-line psql metacommand (e.g., "SELECT 1 \gset")
77- // We need to find \ that starts a metacommand but is NOT inside a string.
78- sqlPart , terminated := splitMidLineMeta (line )
79- if terminated {
80- result = append (result , processedLine {
81- text : sqlPart ,
82- lineNum : lineNum ,
83- terminates : true ,
84- })
85- continue
86- }
87-
88- // Normal SQL line
76+ // Normal SQL line (mid-line metacommands are handled in splitStatements)
8977 result = append (result , processedLine {
9078 text : line ,
9179 lineNum : lineNum ,
9280 })
9381
9482 // Check if this line completes a COPY FROM STDIN statement.
95- // We check if the line ends with a semicolon (possibly after whitespace)
96- // and the accumulated context looks like COPY FROM STDIN.
97- // This is a heuristic: we'll do the real check in splitStatements
98- // after we see the full statement. But for the line-level preprocessing,
99- // we set inCopyData after the statement-ending semicolon on a COPY FROM STDIN.
100- // Actually, the real detection must happen at the statement level.
101- // We'll handle this by scanning for the pattern across the recent lines.
102- // For simplicity, check if this line contains "stdin" + ";" pattern.
83+ // Note: The original heuristic might be flaky for multi-line COPY statements,
84+ // but typically COPY FROM STDIN is one line or ends on a line.
10385 if looksLikeCopyStdinEnd (line ) {
10486 inCopyData = true
10587 }
@@ -338,6 +320,31 @@ func splitStatements(filename string, lines []processedLine) []ExtractedStmt {
338320 }
339321
340322 switch {
323+ case ch == '\\' :
324+ // Check for mid-line psql metacommand (e.g. \gset) which acts as terminator.
325+ // It must be at word start (or preceded by space/semicolon, but here we just check if it's outside strings).
326+ // Scan ahead to match psqlTerminatorRE
327+ rest := text [i :]
328+ // psqlTerminatorRE expects start of string, but we are in middle.
329+ // We need to check if 'rest' starts with one of the terminators.
330+ // The RE is `(?i)\\(g|gx|gset|gdesc|gexec|crosstabview)\b`
331+ // We can just match against the pattern manually or use FindStringIndex.
332+ if loc := psqlTerminatorRE .FindStringIndex (rest ); loc != nil && loc [0 ] == 0 {
333+ // Found terminator at current position
334+ emit ()
335+ // Skip the rest of the line as psql would consume it
336+ i = n
337+ } else {
338+ // Just a backslash, or unknown metacommand?
339+ // Treat as backslash (or if followed by ;, it is psql separator)
340+ if i + 1 < n && text [i + 1 ] == ';' {
341+ emit ()
342+ i ++
343+ } else {
344+ buf .WriteByte (ch )
345+ }
346+ }
347+
341348 case ch == ';' :
342349 // Only split on semicolons outside parens and outside BEGIN ATOMIC blocks
343350 if parenDepth == 0 && atomicDepth == 0 {
@@ -402,10 +409,25 @@ func splitStatements(filename string, lines []processedLine) []ExtractedStmt {
402409 buf .WriteByte (ch )
403410 }
404411
405- case ch == '\\' && i + 1 < n && text [i + 1 ] == ';' :
406- // psql \; separator: emit current statement and skip the \;
407- emit ()
408- i ++ // skip the ; as well
412+ case ch == '\\' :
413+ // Check for mid-line psql metacommand (e.g. \gset) which acts as terminator.
414+ // It must be at word start (or preceded by space/semicolon, but here we just check if it's outside strings).
415+ // Scan ahead to match psqlTerminatorRE
416+ rest := text [i :]
417+ if loc := psqlTerminatorRE .FindStringIndex (rest ); loc != nil && loc [0 ] == 0 {
418+ // Found terminator at current position
419+ emit ()
420+ // Skip the rest of the line as psql would consume it
421+ i = n
422+ } else {
423+ // Check for psql \; separator
424+ if i + 1 < n && text [i + 1 ] == ';' {
425+ emit ()
426+ i ++
427+ } else {
428+ buf .WriteByte (ch )
429+ }
430+ }
409431
410432 default :
411433 if ! isSpaceByte (ch ) {
@@ -741,6 +763,25 @@ func ReplacePsqlVariables(sql string) (string, bool) {
741763 }
742764 }
743765
766+ if isVar {
767+ // Check if the variable name is a SQL keyword that shouldn't be replaced
768+ // (e.g. :NULL in array slice [1:NULL])
769+ varName := ""
770+ if replType == 0 {
771+ varName = sql [i + 1 : varEnd ]
772+ } else {
773+ // quoted, skip quotes
774+ varName = sql [i + 2 : varEnd - 1 ]
775+ }
776+
777+ if strings .ToUpper (varName ) == "NULL" {
778+ isVar = false
779+ // Rewind varEnd to i+1 so we just consume the colon in default case?
780+ // No, we need to process the colon and following chars normally.
781+ // Just fall through to default processing.
782+ }
783+ }
784+
744785 if isVar {
745786 replaced = true
746787 switch replType {
0 commit comments