diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayLiteralParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayLiteralParser.java new file mode 100644 index 000000000..fd3697778 --- /dev/null +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayLiteralParser.java @@ -0,0 +1,358 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.spanner.pgadapter.parsers; + +import com.google.api.core.InternalApi; +import com.google.cloud.spanner.pgadapter.error.PGExceptionFactory; +import com.google.cloud.spanner.pgadapter.error.SQLState; +import java.util.ArrayList; +import java.util.List; + +/** + * Dedicated parser for PostgreSQL text-format array literals (e.g. {@code {1, 2, 3}}, {@code + * {"foo", "bar"}}). + * + *

Implements the PostgreSQL array literal grammar as specified in PostgreSQL's {@code + * arrayfuncs.c}. Specifically: + * + *

+ */ +@InternalApi +public class ArrayLiteralParser { + private final String expression; + private final int length; + private int position; + + private ArrayLiteralParser(String expression) { + this.expression = expression; + this.length = expression.length(); + this.position = 0; + } + + /** + * Parses a PostgreSQL text-format array literal into a list of element strings. Elements that + * represent SQL NULL are represented as {@code null} in the returned list. + */ + public static List readArrayLiteral(String expression) { + if (expression == null) { + throw PGExceptionFactory.newPGException( + "Array value cannot be null", SQLState.InvalidParameterValue); + } + return new ArrayLiteralParser(expression).parse(); + } + + private List parse() { + skipWhitespace(); + if (position >= length) { + throw PGExceptionFactory.newPGException( + "Missing '{' at start of array value: " + expression, SQLState.InvalidParameterValue); + } + int expectedElements = parseDimensions(); + if (position >= length || expression.charAt(position) != '{') { + throw PGExceptionFactory.newPGException( + "Missing '{' at start of array value: " + expression, SQLState.InvalidParameterValue); + } + position++; // skip '{' + + skipWhitespace(); + if (position < length && expression.charAt(position) == '}') { + position++; // skip '}' + if (expectedElements >= 0) { + throw PGExceptionFactory.newPGException( + "Specified array dimensions do not match array contents: " + expression, + SQLState.InvalidParameterValue); + } + checkEndOfArray(); + return new ArrayList<>(0); + } + + List result = + expectedElements > 0 ? new ArrayList<>(expectedElements) : new ArrayList<>(); + while (true) { + skipWhitespace(); + if (position >= length) { + throw PGExceptionFactory.newPGException( + "Missing '}' at end of array value: " + expression, SQLState.InvalidParameterValue); + } + char character = expression.charAt(position); + if (character == '"') { + result.add(parseQuotedElement()); + } else if (character == '}' || character == ',') { + throw PGExceptionFactory.newPGException( + "Invalid element in array: " + expression, SQLState.InvalidParameterValue); + } else if (character == '{') { + throw PGExceptionFactory.newPGException( + "Multidimensional arrays are not supported: " + expression, + SQLState.InvalidParameterValue); + } else { + result.add(parseUnquotedElement()); + } + + if (expression.charAt(position) == ',') { + position++; // skip ',' and continue + } else { + position++; // skip '}' and finish + break; + } + } + + if (expectedElements >= 0 && result.size() != expectedElements) { + throw PGExceptionFactory.newPGException( + "Specified array dimensions do not match array contents: " + expression, + SQLState.InvalidParameterValue); + } + checkEndOfArray(); + return result; + } + + private void skipWhitespace() { + while (position < length && isWhitespace(expression.charAt(position))) { + position++; + } + } + + private static boolean isWhitespace(char character) { + return character == ' ' + || character == '\t' + || character == '\n' + || character == '\r' + || character == '\u000B' + || character == '\f'; + } + + private int parseDimensions() { + if (expression.charAt(position) != '[') { + return -1; + } + int closeBracket = expression.indexOf(']', position); + if (closeBracket == -1) { + throw PGExceptionFactory.newPGException( + "Missing ']' in array dimensions: " + expression, SQLState.InvalidParameterValue); + } + int expectedElements = validateDimension(expression.substring(position + 1, closeBracket)); + position = closeBracket + 1; + skipWhitespace(); + if (position < length && expression.charAt(position) == '[') { + throw PGExceptionFactory.newPGException( + "Multidimensional arrays are not supported: " + expression, + SQLState.InvalidParameterValue); + } + if (position >= length || expression.charAt(position) != '=') { + throw PGExceptionFactory.newPGException( + "Missing '=' after array dimensions: " + expression, SQLState.InvalidParameterValue); + } + position++; // skip '=' + skipWhitespace(); + return expectedElements; + } + + private int validateDimension(String dimension) { + int colonIndex = dimension.indexOf(':'); + try { + if (colonIndex == -1) { + long upper = Long.parseLong(dimension.trim()); + if (upper < 1) { + throw PGExceptionFactory.newPGException( + "Upper bound cannot be less than lower bound: " + expression, + SQLState.InvalidParameterValue); + } + if (upper > Integer.MAX_VALUE) { + throw PGExceptionFactory.newPGException( + "Invalid array dimensions: " + expression, SQLState.InvalidParameterValue); + } + return (int) upper; + } else { + long lower = Long.parseLong(dimension.substring(0, colonIndex).trim()); + long upper = Long.parseLong(dimension.substring(colonIndex + 1).trim()); + if (upper < lower) { + throw PGExceptionFactory.newPGException( + "Upper bound cannot be less than lower bound: " + expression, + SQLState.InvalidParameterValue); + } + long count = upper - lower + 1; + if (count < 1 || count > Integer.MAX_VALUE) { + throw PGExceptionFactory.newPGException( + "Invalid array dimensions: " + expression, SQLState.InvalidParameterValue); + } + return (int) count; + } + } catch (NumberFormatException exception) { + throw PGExceptionFactory.newPGException( + "Invalid array dimensions: " + expression, SQLState.InvalidParameterValue); + } + } + + private String parseQuotedElement() { + position++; // skip opening quote '"' + int start = position; + + // Fast path: scan for closing quote or escape character. + while (position < length) { + char character = expression.charAt(position); + if (character == '"') { + String element = expression.substring(start, position); + position++; // skip closing quote + validateAfterQuotedElement(); + return element; + } + if (character == '\\') { + return parseQuotedElementWithEscapes(start); + } + position++; + } + + throw PGExceptionFactory.newPGException( + "Missing end quote character in array value: " + expression, + SQLState.InvalidParameterValue); + } + + private String parseQuotedElementWithEscapes(int start) { + StringBuilder element = new StringBuilder(position - start + 16); + element.append(expression, start, position); + boolean closed = false; + while (position < length) { + char character = expression.charAt(position); + if (character == '\\') { + position++; + if (position >= length) { + throw PGExceptionFactory.newPGException( + "Unexpected end of array value: " + expression, SQLState.InvalidParameterValue); + } + element.append(expression.charAt(position)); + position++; + } else if (character == '"') { + closed = true; + position++; // skip closing quote + break; + } else { + element.append(character); + position++; + } + } + if (!closed) { + throw PGExceptionFactory.newPGException( + "Missing end quote character in array value: " + expression, + SQLState.InvalidParameterValue); + } + validateAfterQuotedElement(); + return element.toString(); + } + + private void validateAfterQuotedElement() { + skipWhitespace(); + if (position >= length) { + throw PGExceptionFactory.newPGException( + "Missing '}' at end of array value: " + expression, SQLState.InvalidParameterValue); + } + char next = expression.charAt(position); + if (next != ',' && next != '}') { + throw PGExceptionFactory.newPGException( + "Incorrectly quoted array element: " + expression, SQLState.InvalidParameterValue); + } + } + + private String parseUnquotedElement() { + int start = position; + int lastNonWhitespace = start; + + // Fast path: scan until delimiter or escape. + while (position < length) { + char character = expression.charAt(position); + if (character == ',' || character == '}') { + int elementLength = lastNonWhitespace - start; + if (elementLength == 4 && expression.regionMatches(true, start, "null", 0, 4)) { + return null; + } + return expression.substring(start, lastNonWhitespace); + } + if (character == '\\') { + return parseUnquotedElementWithEscapes(start); + } + if (character == '"') { + throw PGExceptionFactory.newPGException( + "Incorrectly quoted array element: " + expression, SQLState.InvalidParameterValue); + } + if (character == '{') { + throw PGExceptionFactory.newPGException( + "Unexpected '{' character in array value: " + expression, + SQLState.InvalidParameterValue); + } + if (!isWhitespace(character)) { + lastNonWhitespace = position + 1; + } + position++; + } + + throw PGExceptionFactory.newPGException( + "Missing '}' at end of array value: " + expression, SQLState.InvalidParameterValue); + } + + private String parseUnquotedElementWithEscapes(int start) { + StringBuilder element = new StringBuilder(position - start + 16); + element.append(expression, start, position); + int nonWhitespaceLength = 0; + while (position < length) { + char character = expression.charAt(position); + if (character == '\\') { + position++; + if (position >= length) { + throw PGExceptionFactory.newPGException( + "Unexpected end of array value: " + expression, SQLState.InvalidParameterValue); + } + element.append(expression.charAt(position)); + nonWhitespaceLength = element.length(); + position++; + } else if (character == ',' || character == '}') { + break; + } else if (character == '"') { + throw PGExceptionFactory.newPGException( + "Incorrectly quoted array element: " + expression, SQLState.InvalidParameterValue); + } else if (character == '{') { + throw PGExceptionFactory.newPGException( + "Unexpected '{' character in array value: " + expression, + SQLState.InvalidParameterValue); + } else { + element.append(character); + if (!isWhitespace(character)) { + nonWhitespaceLength = element.length(); + } + position++; + } + } + + if (position >= length) { + throw PGExceptionFactory.newPGException( + "Missing '}' at end of array value: " + expression, SQLState.InvalidParameterValue); + } + element.setLength(nonWhitespaceLength); + return element.toString(); + } + + private void checkEndOfArray() { + skipWhitespace(); + if (position < length) { + throw PGExceptionFactory.newPGException( + "Unexpected characters after array value: " + expression, SQLState.InvalidParameterValue); + } + } +} diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParser.java index daaa29755..da9e00a88 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParser.java @@ -18,6 +18,7 @@ import com.google.cloud.Date; import com.google.cloud.Timestamp; import com.google.cloud.spanner.ErrorCode; +import com.google.cloud.spanner.Interval; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.SpannerExceptionFactory; import com.google.cloud.spanner.Type; @@ -27,7 +28,6 @@ import com.google.cloud.spanner.pgadapter.error.PGExceptionFactory; import com.google.cloud.spanner.pgadapter.error.SQLState; import com.google.cloud.spanner.pgadapter.session.SessionState; -import com.google.cloud.spanner.pgadapter.statements.SimpleParser; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import java.io.ByteArrayInputStream; @@ -146,7 +146,7 @@ public static List stringArrayToList( if (value == null) { return null; } - List values = SimpleParser.readArrayLiteral(value, elementOid == Oid.BYTEA); + List values = ArrayLiteralParser.readArrayLiteral(value); ArrayList result = new ArrayList<>(values.size()); for (String element : values) { if (element == null) { @@ -258,7 +258,8 @@ private boolean stringEquivalence(Code arrayElementType) { || arrayElementType == Code.DATE || arrayElementType == Code.STRING || arrayElementType == Code.TIMESTAMP - || arrayElementType == Code.PG_JSONB; + || arrayElementType == Code.PG_JSONB + || arrayElementType == Code.UUID; } /** @@ -509,6 +510,9 @@ public static void bind( case Oid.DATE: parametersBuilder.put(name, Value.dateArray((List) list)); break; + case Oid.INTERVAL: + parametersBuilder.put(name, Value.intervalArray((List) list)); + break; default: throw PGExceptionFactory.newPGException( "Unsupported array element type: " + elementOid, SQLState.InvalidParameterValue); diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java index 736719280..ac747977a 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java @@ -19,6 +19,7 @@ import com.google.cloud.spanner.Value; import com.google.cloud.spanner.pgadapter.error.PGExceptionFactory; import com.google.cloud.spanner.pgadapter.error.SQLState; +import com.google.cloud.spanner.pgadapter.parsers.ArrayLiteralParser; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.base.Strings; @@ -155,14 +156,10 @@ static class QuotedString { } String getValue() { - return getValue(false); - } - - String getValue(boolean returnRawHexValue) { if (this.value == null) { this.value = this.escaped - ? unescapeQuotedStringValue(this.rawValue, this.quote, returnRawHexValue) + ? unescapeQuotedStringValue(this.rawValue, this.quote) : quotedStringValue(this.rawValue, this.quote); } return this.value; @@ -183,18 +180,13 @@ static String quotedStringValue(String quotedString, char quoteChar) { } static String unescapeQuotedStringValue(String quotedString, char quoteChar) { - return unescapeQuotedStringValue(quotedString, quoteChar, false); - } - - static String unescapeQuotedStringValue( - String quotedString, char quoteChar, boolean returnRawHexValue) { if (quotedString.length() < 2 || quotedString.charAt(0) != quoteChar || quotedString.charAt(quotedString.length() - 1) != quoteChar) { throw PGExceptionFactory.newPGException( quotedString + " is not a valid string", SQLState.SyntaxError); } - if (!returnRawHexValue && quotedString.startsWith(quoteChar + "\\x")) { + if (quotedString.startsWith(quoteChar + "\\x")) { throw PGExceptionFactory.newPGException( "PGAdapter does not support hexadecimal byte values in string literals", SQLState.SyntaxError); @@ -411,43 +403,8 @@ public static boolean isCommand(ImmutableList commands, String query) { return true; } - public static List readArrayLiteral(String expression, boolean returnRawHexValue) { - List result = new ArrayList<>(); - SimpleParser parser = new SimpleParser(expression); - if (!parser.eatToken("{")) { - throw PGExceptionFactory.newPGException( - "Missing '{' at start of array value: " + expression, SQLState.InvalidParameterValue); - } - do { - if (result.isEmpty() && parser.peekToken("}")) { - break; - } else if (parser.eatKeyword("null")) { - result.add(null); - } else if (parser.peekToken("\"")) { - QuotedString quotedString = parser.readQuotedString('"', true); - if (quotedString == null) { - throw PGExceptionFactory.newPGException( - "Invalid string in array: " + expression, SQLState.InvalidParameterValue); - } - result.add(quotedString.getValue(returnRawHexValue)); - } else { - String unquotedString = parser.parseExpressionUntilKeyword(ImmutableList.of("}")); - if (unquotedString == null) { - throw PGExceptionFactory.newPGException( - "Invalid element in array: " + expression, SQLState.InvalidParameterValue); - } - result.add(unquotedString); - } - } while (parser.eatToken(",")); - if (!parser.eatToken("}")) { - throw PGExceptionFactory.newPGException( - "Missing '}' at end of array value: " + expression, SQLState.InvalidParameterValue); - } - if (parser.hasMoreTokens()) { - throw PGExceptionFactory.newPGException( - "Unexpected characters after array value: " + expression, SQLState.InvalidParameterValue); - } - return result; + public static List readArrayLiteral(String expression) { + return ArrayLiteralParser.readArrayLiteral(expression); } /** diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/parsers/ArrayLiteralParserTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/parsers/ArrayLiteralParserTest.java new file mode 100644 index 000000000..48f108536 --- /dev/null +++ b/src/test/java/com/google/cloud/spanner/pgadapter/parsers/ArrayLiteralParserTest.java @@ -0,0 +1,342 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.spanner.pgadapter.parsers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import com.google.cloud.spanner.pgadapter.error.PGException; +import com.google.cloud.spanner.pgadapter.error.SQLState; +import com.google.common.collect.ImmutableList; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ArrayLiteralParserTest { + + @Test + public void testEmptyArray() { + assertEquals(Collections.emptyList(), ArrayLiteralParser.readArrayLiteral("{}")); + assertEquals(Collections.emptyList(), ArrayLiteralParser.readArrayLiteral("{ }")); + assertEquals(Collections.emptyList(), ArrayLiteralParser.readArrayLiteral(" {} ")); + } + + @Test + public void testSingleElement() { + assertEquals(ImmutableList.of("1"), ArrayLiteralParser.readArrayLiteral("{1}")); + assertEquals(ImmutableList.of("foo"), ArrayLiteralParser.readArrayLiteral("{\"foo\"}")); + assertEquals(ImmutableList.of(""), ArrayLiteralParser.readArrayLiteral("{\"\"}")); + assertEquals(ImmutableList.of(" "), ArrayLiteralParser.readArrayLiteral("{\" \"}")); + } + + @Test + public void testMultipleElements() { + assertEquals(ImmutableList.of("1", "2", "3"), ArrayLiteralParser.readArrayLiteral("{1, 2, 3}")); + assertEquals(ImmutableList.of("1", "2", "3"), ArrayLiteralParser.readArrayLiteral("{1,2,3}")); + assertEquals( + ImmutableList.of("foo", "bar", "baz"), + ArrayLiteralParser.readArrayLiteral("{\"foo\", \"bar\", \"baz\"}")); + assertEquals( + ImmutableList.of("foo", "bar", "baz"), + ArrayLiteralParser.readArrayLiteral("{foo, bar, baz}")); + } + + @Test + public void testWhitespaceHandling() { + assertEquals( + ImmutableList.of("1", "2", "3"), ArrayLiteralParser.readArrayLiteral("{ 1 , 2 , 3 }")); + assertEquals( + ImmutableList.of("foo 1", "bar 2"), + ArrayLiteralParser.readArrayLiteral("{ foo 1 , bar 2 }")); + assertEquals( + ImmutableList.of(" foo ", " bar "), + ArrayLiteralParser.readArrayLiteral("{ \" foo \" , \" bar \" }")); + assertEquals( + ImmutableList.of("1", "2"), ArrayLiteralParser.readArrayLiteral("{\t1\r,\u000B2\f}")); + } + + @Test + public void testAdjacentQuotesPreserved() { + // Adjacent quotes inside quoted elements must NOT be collapsed (e.g. empty strings in JSON). + assertEquals( + ImmutableList.of("{\"k\":\"\"}"), + ArrayLiteralParser.readArrayLiteral("{\"{\\\"k\\\":\\\"\\\"}\"}")); + assertEquals( + ImmutableList.of("\"foo\"", "\"\""), + ArrayLiteralParser.readArrayLiteral("{\"\\\"foo\\\"\", \"\\\"\\\"\"}")); + } + + @Test + public void testCommentsPreservedInUnquoted() { + // Unquoted elements must not be tokenized as SQL comments. + assertEquals(ImmutableList.of("--foo"), ArrayLiteralParser.readArrayLiteral("{--foo}")); + assertEquals( + ImmutableList.of("a", "--", "b"), ArrayLiteralParser.readArrayLiteral("{a, --, b}")); + assertEquals( + ImmutableList.of("/*comment*/"), ArrayLiteralParser.readArrayLiteral("{/*comment*/}")); + assertEquals( + ImmutableList.of("a", "/*comment*/", "b"), + ArrayLiteralParser.readArrayLiteral("{a, /*comment*/, b}")); + } + + @Test + public void testEscapesAndWindowsPaths() { + // PostgreSQL array syntax escapes \c to c; backslash does not trigger Java unicode unescaping. + assertEquals( + ImmutableList.of("C:\\user\\test"), + ArrayLiteralParser.readArrayLiteral("{\"C:\\\\user\\\\test\"}")); + assertEquals(ImmutableList.of("C:\\user"), ArrayLiteralParser.readArrayLiteral("{C:\\\\user}")); + assertEquals( + ImmutableList.of("a\\n", "b\\u1234"), + ArrayLiteralParser.readArrayLiteral("{\"a\\\\n\", \"b\\\\u1234\"}")); + } + + @Test + public void testEscapedDelimiters() { + assertEquals(ImmutableList.of(",", "}"), ArrayLiteralParser.readArrayLiteral("{\\,, \\}}")); + assertEquals(ImmutableList.of("{", "}"), ArrayLiteralParser.readArrayLiteral("{\\{, \\}}")); + assertEquals( + ImmutableList.of(",", "}"), ArrayLiteralParser.readArrayLiteral("{\"\\,\", \"\\}\"}")); + } + + @Test + public void testNullHandling() { + assertEquals( + Arrays.asList("foo", "null", null, null, null, "null"), + ArrayLiteralParser.readArrayLiteral("{\"foo\", \\null, null, NULL, Null, \"null\"}")); + assertEquals(Collections.singletonList(null), ArrayLiteralParser.readArrayLiteral("{null}")); + assertEquals(ImmutableList.of("null"), ArrayLiteralParser.readArrayLiteral("{\\null}")); + assertEquals(ImmutableList.of("null"), ArrayLiteralParser.readArrayLiteral("{\"null\"}")); + assertEquals(ImmutableList.of("nullify"), ArrayLiteralParser.readArrayLiteral("{nullify}")); + assertEquals(ImmutableList.of("notnull"), ArrayLiteralParser.readArrayLiteral("{notnull}")); + } + + @Test + public void testDelimitersAndBracesInQuotedElements() { + assertEquals( + ImmutableList.of("foo,bar", "baz"), + ArrayLiteralParser.readArrayLiteral("{\"foo,bar\", \"baz\"}")); + assertEquals( + ImmutableList.of("{foo}", "}bar{"), + ArrayLiteralParser.readArrayLiteral("{\"\\{foo\\}\", \"\\}bar\\{\"}")); + assertEquals(ImmutableList.of("{", "}"), ArrayLiteralParser.readArrayLiteral("{\"{\", \"}\"}")); + } + + @Test + public void testEscapedQuotesInUnquotedElements() { + // In PostgreSQL, \" in unquoted element unescapes to literal " + assertEquals(ImmutableList.of("foo\"bar"), ArrayLiteralParser.readArrayLiteral("{foo\\\"bar}")); + } + + @Test + public void testEscapedSpaces() { + assertEquals(ImmutableList.of(" "), ArrayLiteralParser.readArrayLiteral("{\\ }")); + assertEquals(ImmutableList.of(" "), ArrayLiteralParser.readArrayLiteral("{\\ \\ }")); + assertEquals(ImmutableList.of(" a "), ArrayLiteralParser.readArrayLiteral("{\\ a\\ }")); + } + + @Test + public void testUnquotedByteaHex() { + assertEquals( + ImmutableList.of("\\x1234", "\\x5678"), + ArrayLiteralParser.readArrayLiteral("{\\\\x1234, \\\\x5678}")); + } + + @Test + public void testDimensionDecorations() { + assertEquals(ImmutableList.of("1", "2"), ArrayLiteralParser.readArrayLiteral("[1:2]={1, 2}")); + assertEquals( + ImmutableList.of("1", "2", "3"), ArrayLiteralParser.readArrayLiteral("[3]={1, 2, 3}")); + assertEquals( + ImmutableList.of("1", "2", "3", "4", "5"), + ArrayLiteralParser.readArrayLiteral("[-2:2]={1, 2, 3, 4, 5}")); + assertEquals( + ImmutableList.of("1", "2"), ArrayLiteralParser.readArrayLiteral(" [1:2] = {1, 2} ")); + assertEquals(ImmutableList.of("1", "2"), ArrayLiteralParser.readArrayLiteral("[0:1]={1, 2}")); + assertEquals(ImmutableList.of("1"), ArrayLiteralParser.readArrayLiteral("[0:0]={1}")); + assertEquals(ImmutableList.of("1"), ArrayLiteralParser.readArrayLiteral("[1:1]={1}")); + } + + @Test + public void testDimensionLengthMismatch() { + PGException exception = + assertThrows( + PGException.class, () -> ArrayLiteralParser.readArrayLiteral("[1:2]={1, 2, 3}")); + assertEquals(SQLState.InvalidParameterValue, exception.getSQLState()); + assertEquals( + "Specified array dimensions do not match array contents: [1:2]={1, 2, 3}", + exception.getMessage()); + + exception = + assertThrows(PGException.class, () -> ArrayLiteralParser.readArrayLiteral("[1:2]={}")); + assertEquals(SQLState.InvalidParameterValue, exception.getSQLState()); + assertEquals( + "Specified array dimensions do not match array contents: [1:2]={}", exception.getMessage()); + + exception = + assertThrows(PGException.class, () -> ArrayLiteralParser.readArrayLiteral("[3]={1, 2}")); + assertEquals(SQLState.InvalidParameterValue, exception.getSQLState()); + assertEquals( + "Specified array dimensions do not match array contents: [3]={1, 2}", + exception.getMessage()); + + exception = + assertThrows( + PGException.class, () -> ArrayLiteralParser.readArrayLiteral("[-2:1]={1, 2, 3}")); + assertEquals(SQLState.InvalidParameterValue, exception.getSQLState()); + assertEquals( + "Specified array dimensions do not match array contents: [-2:1]={1, 2, 3}", + exception.getMessage()); + } + + @Test + public void testAdditionalFeatures() { + assertEquals( + Arrays.asList("1 year", null), ArrayLiteralParser.readArrayLiteral("{1 year, NULL}")); + assertEquals( + ImmutableList.of("2026-09-25 18:30:00+02"), + ArrayLiteralParser.readArrayLiteral("{2026-09-25 18:30:00+02}")); + assertEquals( + ImmutableList.of("{\"k\": [1, 2]}"), + ArrayLiteralParser.readArrayLiteral("{\"{\\\"k\\\": [1, 2]}\"}")); + } + + @Test + public void testUnicodeWhitespacePreserved() { + // Unicode spaces (e.g. \u3000 CJK space) are not ASCII whitespace and must be preserved as + // unquoted element content according to PostgreSQL arrayfuncs.c. + assertEquals(ImmutableList.of("foo\u3000"), ArrayLiteralParser.readArrayLiteral("{foo\u3000}")); + assertEquals(ImmutableList.of("\u3000"), ArrayLiteralParser.readArrayLiteral("{\u3000}")); + assertEquals(ImmutableList.of("\u3000foo"), ArrayLiteralParser.readArrayLiteral("{\u3000foo}")); + } + + @Test + public void testUnquotedFourCharacterWords() { + assertEquals( + ImmutableList.of("word", "true", "test", "abcd"), + ArrayLiteralParser.readArrayLiteral("{word, true, test, abcd}")); + } + + @Test + public void testUnquotedSqlTokens() { + // Array unquoted elements are not SQL tokens; apostrophes, parentheses, and dollar signs must + // be + // preserved. + assertEquals( + ImmutableList.of("O'Reilly", "it's"), + ArrayLiteralParser.readArrayLiteral("{O'Reilly, it's}")); + assertEquals( + ImmutableList.of("foo(bar", "baz)qux"), + ArrayLiteralParser.readArrayLiteral("{foo(bar, baz)qux}")); + assertEquals( + ImmutableList.of("$tag$foo", "$$bar"), + ArrayLiteralParser.readArrayLiteral("{$tag$foo, $$bar}")); + assertEquals( + ImmutableList.of("null-1", "null:2"), + ArrayLiteralParser.readArrayLiteral("{null-1, null:2}")); + } + + @Test + public void testQuotedInvalidJavaUnicodeEscapes() { + // In PostgreSQL array grammar, \c unescapes to c; non-Java escape sequences like \\uZZZZ must + // not crash with IllegalArgumentException. + assertEquals( + ImmutableList.of("\\uZZZZ"), ArrayLiteralParser.readArrayLiteral("{\"\\\\uZZZZ\"}")); + assertEquals( + ImmutableList.of("C:\\users\\admin"), + ArrayLiteralParser.readArrayLiteral("{\"C:\\\\users\\\\admin\"}")); + assertEquals(ImmutableList.of("\\u12"), ArrayLiteralParser.readArrayLiteral("{\"\\\\u12\"}")); + } + + @Test + public void testUnquotedWithInternalSpacesAndEscapes() { + assertEquals( + ImmutableList.of("foo bar baz"), ArrayLiteralParser.readArrayLiteral("{foo bar\\ baz }")); + } + + @Test + public void testErrorConditions() { + assertInvalidParameter(null); + assertInvalidParameter(""); + assertInvalidParameter(" "); + assertInvalidParameter("1, 2"); + assertInvalidParameter("{1, 2"); + assertInvalidParameter("1, 2}"); + assertInvalidParameter("{"); + assertInvalidParameter("{ "); + assertInvalidParameter("{1, 2} extra token"); + + // Missing elements or unclosed delimiters + assertInvalidParameter("{1,}"); + assertInvalidParameter("{,1}"); + assertInvalidParameter("{1,,2}"); + assertInvalidParameter("{,}"); + assertInvalidParameter("{1,"); + + // Misquoted elements + assertInvalidParameter("{\"foo\"bar}"); + assertInvalidParameter("{foo\"bar}"); + assertInvalidParameter("{\"foo}"); + assertInvalidParameter("{\"foo\\"); + assertInvalidParameter("{\"foo\\nbar"); + assertInvalidParameter("{\"foo\""); + assertInvalidParameter("{\"foo\\\"bar\"baz}"); + assertInvalidParameter("{\"foo\\\"bar"); + + // Unquoted elements with invalid characters or unclosed states + assertInvalidParameter("{foo{bar}"); + assertInvalidParameter("{foo\\bar\\"); + assertInvalidParameter("{foo\\bar\"baz}"); + assertInvalidParameter("{foo\\bar{baz}"); + assertInvalidParameter("{foo\\bar"); + + // Multidimensional arrays not supported + assertInvalidParameter("{{1, 2}, {3, 4}}"); + + // Malformed dimension headers + assertInvalidParameter("[1:2{1, 2}"); + assertInvalidParameter("[1:2] {1, 2}"); + assertInvalidParameter("[1:2]="); + assertInvalidParameter("[1:2]"); + assertInvalidParameter("[1:2] 123"); + assertInvalidParameter("[1:2][1:2]={{1, 2}, {3, 4}}"); + assertInvalidParameter("[1:2] [1:2]={1, 2}"); + assertInvalidParameter("[]={1}"); + assertInvalidParameter("[abc]={1}"); + assertInvalidParameter("[2:1]={1}"); + assertInvalidParameter("[1:2:3]={1}"); + assertInvalidParameter("[-1]={1}"); + assertInvalidParameter("[0]={1}"); + + // Dimension length does not match parsed elements + assertInvalidParameter("[1:2]={1, 2, 3}"); + assertInvalidParameter("[1:2]={1}"); + assertInvalidParameter("[1:2]={}"); + assertInvalidParameter("[3]={1, 2}"); + assertInvalidParameter("[3]={1, 2, 3, 4}"); + assertInvalidParameter("[3]={}"); + assertInvalidParameter("[-2:1]={1, 2, 3}"); + assertInvalidParameter("[-2:1]={1, 2, 3, 4, 5}"); + } + + private static void assertInvalidParameter(String expression) { + PGException exception = + assertThrows(PGException.class, () -> ArrayLiteralParser.readArrayLiteral(expression)); + assertEquals(SQLState.InvalidParameterValue, exception.getSQLState()); + } +} diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParserTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParserTest.java index e9207ee23..59f0de50e 100644 --- a/src/test/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParserTest.java +++ b/src/test/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParserTest.java @@ -25,6 +25,7 @@ import com.google.cloud.ByteArray; import com.google.cloud.Date; import com.google.cloud.Timestamp; +import com.google.cloud.spanner.Interval; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.ResultSets; import com.google.cloud.spanner.Struct; @@ -41,6 +42,7 @@ import java.time.ZoneId; import java.util.Arrays; import java.util.List; +import java.util.UUID; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -541,10 +543,72 @@ public void testNullTextArray() { ArrayParser.stringArrayToList(null, Oid.UNSPECIFIED, mock(SessionState.class), false)); assertNull( ArrayParser.stringArrayToList(null, Oid.UNSPECIFIED, mock(SessionState.class), true)); - assertNull( - ArrayParser.stringArrayToList(null, Oid.UNSPECIFIED, mock(SessionState.class), false)); - assertNull( - ArrayParser.stringArrayToList(null, Oid.UNSPECIFIED, mock(SessionState.class), true)); + } + + @Test + public void testStringArrayToList_jsonWithAdjacentQuotes() { + List result = + ArrayParser.stringArrayToList( + "{\"{\\\"k\\\":\\\"\\\"}\"}", Oid.JSONB, mock(SessionState.class), false); + assertEquals(ImmutableList.of("{\"k\":\"\"}"), result); + } + + @Test + public void testStringArrayToList_unquotedWithDashes() { + List result = + ArrayParser.stringArrayToList( + "{--foo, a, --, b}", Oid.TEXT, mock(SessionState.class), false); + assertEquals(ImmutableList.of("--foo", "a", "--", "b"), result); + } + + @Test + public void testBindIntervalArray() { + ImmutableMap.Builder builder = ImmutableMap.builder(); + Interval interval = Interval.parseFromString("P1Y2M3DT4H5M6S"); + ArrayParser.bind(builder, "p1", ImmutableList.of(interval), Oid.INTERVAL); + ImmutableMap parameters = builder.build(); + assertEquals(Value.intervalArray(ImmutableList.of(interval)), parameters.get("p1")); + } + + @Test + public void testParserBindIntervalArray() { + ImmutableMap.Builder builder = ImmutableMap.builder(); + Parser.bind( + builder, + "p1", + "{1 year 2 months, 01:00:00}".getBytes(StandardCharsets.UTF_8), + Oid.INTERVAL_ARRAY, + FormatCode.TEXT, + mock(SessionState.class)); + ImmutableMap parameters = builder.build(); + assertEquals( + Value.intervalArray( + ImmutableList.of( + IntervalParser.toInterval("1 year 2 months"), + IntervalParser.toInterval("01:00:00"))), + parameters.get("p1")); + } + + @Test + public void testStringParseUuid() { + UUID uuid = UUID.fromString("1e4d8cd3-099d-4954-a5c1-85272713c3e9"); + ArrayParser parser = + new ArrayParser( + createArrayResultSet(Type.uuid(), Value.uuidArray(ImmutableList.of(uuid))), + 0, + mock(SessionState.class)); + assertEquals("{\"1e4d8cd3-099d-4954-a5c1-85272713c3e9\"}", parser.stringParse()); + } + + @Test + public void testStringParseInterval() { + Interval interval = IntervalParser.toInterval("1 year 2 months"); + ArrayParser parser = + new ArrayParser( + createArrayResultSet(Type.interval(), Value.intervalArray(ImmutableList.of(interval))), + 0, + mock(SessionState.class)); + assertEquals("{14 mons 0 days 00:00:0.000000}", parser.stringParse()); } @Test diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/statements/SimpleParserTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/statements/SimpleParserTest.java index accc8e329..349b8c666 100644 --- a/src/test/java/com/google/cloud/spanner/pgadapter/statements/SimpleParserTest.java +++ b/src/test/java/com/google/cloud/spanner/pgadapter/statements/SimpleParserTest.java @@ -667,28 +667,48 @@ public void testParserTableOrIndexName() { @Test public void testReadArrayLiteral() { - assertEquals(ImmutableList.of(), SimpleParser.readArrayLiteral("{}", true)); - assertEquals(ImmutableList.of("foo"), SimpleParser.readArrayLiteral("{\"foo\"}", true)); + assertEquals(ImmutableList.of(), SimpleParser.readArrayLiteral("{}")); + assertEquals(ImmutableList.of("foo"), SimpleParser.readArrayLiteral("{\"foo\"}")); assertEquals( - ImmutableList.of("foo", "bar"), SimpleParser.readArrayLiteral("{\"foo\", \"bar\"}", true)); + ImmutableList.of("foo", "bar"), SimpleParser.readArrayLiteral("{\"foo\", \"bar\"}")); assertEquals( Arrays.asList("foo", "bar", null), - SimpleParser.readArrayLiteral("{\"foo\", \"bar\", null}", true)); - assertEquals(ImmutableList.of("1", "2"), SimpleParser.readArrayLiteral("{1, 2}", true)); - assertEquals(ImmutableList.of("1", "2"), SimpleParser.readArrayLiteral("{\"1\", \"2\"}", true)); + SimpleParser.readArrayLiteral("{\"foo\", \"bar\", null}")); + assertEquals(ImmutableList.of("1", "2"), SimpleParser.readArrayLiteral("{1, 2}")); + assertEquals(ImmutableList.of("1", "2"), SimpleParser.readArrayLiteral("{\"1\", \"2\"}")); assertEquals( ImmutableList.of("{\"foo\": \"bar\"}"), - SimpleParser.readArrayLiteral("{\"{\\\"foo\\\": \\\"bar\\\"}\"}", true)); + SimpleParser.readArrayLiteral("{\"{\\\"foo\\\": \\\"bar\\\"}\"}")); - assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("1, 2", true)); - assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("{1, 2", true)); - assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("1, 2}", true)); - assertThrows( - PGException.class, () -> SimpleParser.readArrayLiteral("{1, 2} extra token", true)); + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("1, 2")); + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("{1, 2")); + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("1, 2}")); + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("{1, 2} extra token")); - assertEquals(ImmutableList.of("foo", "bar"), SimpleParser.readArrayLiteral("{foo, bar}", true)); + assertEquals(ImmutableList.of("foo", "bar"), SimpleParser.readArrayLiteral("{foo, bar}")); assertEquals( - ImmutableList.of("foo 1", "bar 2"), SimpleParser.readArrayLiteral("{foo 1, bar 2}", true)); + ImmutableList.of("foo 1", "bar 2"), SimpleParser.readArrayLiteral("{foo 1, bar 2}")); + assertEquals( + ImmutableList.of("{\"k\":\"\"}"), + SimpleParser.readArrayLiteral("{\"{\\\"k\\\":\\\"\\\"}\"}")); + assertEquals(ImmutableList.of("--foo"), SimpleParser.readArrayLiteral("{--foo}")); + assertEquals(ImmutableList.of("a", "--", "b"), SimpleParser.readArrayLiteral("{a, --, b}")); + assertEquals(ImmutableList.of("/*comment*/"), SimpleParser.readArrayLiteral("{/*comment*/}")); + assertEquals(ImmutableList.of("C:\\user"), SimpleParser.readArrayLiteral("{\"C:\\\\user\"}")); + assertEquals( + ImmutableList.of("a\\n", "b\\u1234"), + SimpleParser.readArrayLiteral("{\"a\\\\n\", \"b\\\\u1234\"}")); + assertEquals(ImmutableList.of(",", "}"), SimpleParser.readArrayLiteral("{\\,, \\}}")); + assertEquals( + Arrays.asList("foo", "null", null), + SimpleParser.readArrayLiteral("{\"foo\", \\null, null}")); + assertEquals(ImmutableList.of("1", "2"), SimpleParser.readArrayLiteral("[1:2]={1, 2}")); + + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("{1,}")); + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("{,1}")); + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("{1,,2}")); + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("{\"foo\"bar}")); + assertThrows(PGException.class, () -> SimpleParser.readArrayLiteral("{foo\"bar}")); } @Test