diff --git a/client/src/com/aerospike/client/Operation.java b/client/src/com/aerospike/client/Operation.java index 56400cb7e..dc9493b57 100644 --- a/client/src/com/aerospike/client/Operation.java +++ b/client/src/com/aerospike/client/Operation.java @@ -50,14 +50,24 @@ public static Operation put(Bin bin) { /** * Create string append database operation. + * + * @deprecated Use {@link com.aerospike.client.operation.StringOperation#append(com.aerospike.client.operation.StringPolicy, String, String, com.aerospike.client.cdt.CTX...)} + * instead. This legacy operation performs a raw byte concatenation that is not Unicode/DBCS-aware; + * the string-package equivalent provides consistent Unicode handling and policy/CTX support. */ + @Deprecated public static Operation append(Bin bin) { return new Operation(Type.APPEND, bin.name, bin.value); } /** * Create string prepend database operation. + * + * @deprecated Use {@link com.aerospike.client.operation.StringOperation#prepend(com.aerospike.client.operation.StringPolicy, String, String, com.aerospike.client.cdt.CTX...)} + * instead. This legacy operation performs a raw byte concatenation that is not Unicode/DBCS-aware; + * the string-package equivalent provides consistent Unicode handling and policy/CTX support. */ + @Deprecated public static Operation prepend(Bin bin) { return new Operation(Type.PREPEND, bin.name, bin.value); } @@ -111,7 +121,10 @@ public static enum Type { BIT_MODIFY(13, true), DELETE(14, true), HLL_READ(15, false), - HLL_MODIFY(16, true); + HLL_MODIFY(16, true), + STRING_READ(17, false), + STRING_MODIFY(18, true), + TO_STRING(19, false); public final int protocolType; public final boolean isWrite; diff --git a/client/src/com/aerospike/client/ResultCode.java b/client/src/com/aerospike/client/ResultCode.java index ba90d0ee5..cf15da05d 100644 --- a/client/src/com/aerospike/client/ResultCode.java +++ b/client/src/com/aerospike/client/ResultCode.java @@ -249,6 +249,13 @@ public final class ResultCode { */ public static final int LOST_CONFLICT = 28; + /** + * String bin or string argument contains invalid UTF-8. + * Returned by server 8.1.3+ string operations when the bin value or a + * string argument fails the UTF-8 well-formedness gate. + */ + public static final int INVALID_ENCODING = 29; + /** * Write can't complete until XDR finishes shipping. */ @@ -652,6 +659,9 @@ public static String getResultString(int resultCode) { case LOST_CONFLICT: return "Command failed due to conflict with XDR"; + case INVALID_ENCODING: + return "Invalid UTF-8 encoding"; + case XDR_KEY_BUSY: return "Write can't complete until XDR finishes shipping"; diff --git a/client/src/com/aerospike/client/command/OperateArgs.java b/client/src/com/aerospike/client/command/OperateArgs.java index 3a15a5150..da95bb37a 100644 --- a/client/src/com/aerospike/client/command/OperateArgs.java +++ b/client/src/com/aerospike/client/command/OperateArgs.java @@ -49,7 +49,9 @@ public OperateArgs( case EXP_READ: case HLL_READ: case MAP_READ: - // Map operations require respondAllOps to be true. + case STRING_READ: + case TO_STRING: + // These operations require respondAllOps to be true. respondAllOps = true; // Fall through to read. case CDT_READ: @@ -72,7 +74,8 @@ public OperateArgs( case EXP_MODIFY: case HLL_MODIFY: case MAP_MODIFY: - // Map operations require respondAllOps to be true. + case STRING_MODIFY: + // These operations require respondAllOps to be true. respondAllOps = true; // Fall through to write. default: diff --git a/client/src/com/aerospike/client/exp/Exp.java b/client/src/com/aerospike/client/exp/Exp.java index 16ae4b4ef..f3d700569 100644 --- a/client/src/com/aerospike/client/exp/Exp.java +++ b/client/src/com/aerospike/client/exp/Exp.java @@ -459,7 +459,11 @@ public static Exp digestModulo(int mod) { * @param regex regular expression string * @param flags regular expression bit flags. See {@link com.aerospike.client.query.RegexFlag} * @param bin string bin or string value expression + * @deprecated Use {@link com.aerospike.client.exp.StringExp#regexCompare(Exp, int, Exp)} instead. + * This legacy comparison uses POSIX regex and is not Unicode/DBCS-aware; the string-package + * equivalent uses ICU regex and provides consistent Unicode handling across the string ops. */ + @Deprecated public static Exp regexCompare(String regex, int flags, Exp bin) { return new Regex(bin, regex, flags); } diff --git a/client/src/com/aerospike/client/exp/StringExp.java b/client/src/com/aerospike/client/exp/StringExp.java new file mode 100644 index 000000000..7a9f3e883 --- /dev/null +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -0,0 +1,1011 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * 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.aerospike.client.exp; + +import com.aerospike.client.operation.StringPolicy; +import com.aerospike.client.operation.StringRegexFlags; +import com.aerospike.client.util.Pack; +import com.aerospike.client.util.Packer; + +/** + * String expression generator. Produces {@link Exp} nodes that read or transform + * string values inside an Aerospike {@link Expression}. Mirrors the operations + * exposed by {@link com.aerospike.client.operation.StringOperation}, but composes + * inside expressions instead of being sent as standalone operate ops. + *
+ * Each builder takes an {@code Exp src} that produces the string to operate on. + * Common sources: + *
+ * Modify-style expressions (e.g. {@link #upper}, {@link #replace}) return the + * modified string value; they do not mutate the underlying bin. + * To persist a change, write the returned value back via + * {@link com.aerospike.client.exp.Exp.Build} or use + * {@link com.aerospike.client.operation.StringOperation} for direct ops. + *
+ * Index orientation is left-to-right with codepoint addressing. Negative indexes + * count from the end of the string ({@code -1} = last codepoint). Out-of-bounds + * indexes are clamped to the valid range; no error is returned. + *
+ * Unlike {@link com.aerospike.client.operation.StringOperation}, these builders + * do not accept a {@link com.aerospike.client.cdt.CTX}. To apply + * a string expression to a value nested inside a list or map, compose with + * {@link com.aerospike.client.exp.ListExp#getByIndex} or + * {@link com.aerospike.client.exp.MapExp#getByKey} (which do take CTX) to extract + * the leaf, then pass the resulting {@code Exp} as {@code src}. + *
+ * String expressions require server version 8.1.3 or later. + * + *
{@code
+ * // Filter records whose "name" bin starts with "hello".
+ * Expression filter = Exp.build(
+ * Exp.eq(
+ * StringExp.startsWith(Exp.val("hello"), Exp.stringBin("name")),
+ * Exp.val(1)));
+ * }
+ */
+public final class StringExp {
+ private static final int MODULE = 3; // CALL_STRING
+ private static final int MODULE_REPR = 4; // CALL_REPR
+
+ // Read ops
+ private static final int STRLEN = 0;
+ private static final int SUBSTR = 1;
+ private static final int CHAR_AT = 2;
+ private static final int FIND = 3;
+ private static final int CONTAINS = 4;
+ private static final int STARTS_WITH = 5;
+ private static final int ENDS_WITH = 6;
+ private static final int TO_INTEGER = 7;
+ private static final int TO_DOUBLE = 8;
+ private static final int BYTE_LENGTH = 9;
+ private static final int IS_NUMERIC = 10;
+ private static final int IS_UPPER = 11;
+ private static final int IS_LOWER = 12;
+ private static final int TO_BLOB = 13;
+ private static final int SPLIT = 14;
+ private static final int B64_DECODE = 15;
+ private static final int REGEX_COMPARE = 16;
+
+ // Modify ops
+ private static final int INSERT = 50;
+ private static final int OVERWRITE = 51;
+ private static final int CONCAT = 52;
+ private static final int SNIP = 53;
+ private static final int REPLACE = 54;
+ private static final int REPLACE_ALL = 55;
+ private static final int UPPER = 56;
+ private static final int LOWER = 57;
+ private static final int CASE_FOLD = 58;
+ private static final int NORMALIZE_NFC = 59;
+ private static final int TRIM_START = 60;
+ private static final int TRIM_END = 61;
+ private static final int TRIM = 62;
+ private static final int PAD_START = 63;
+ private static final int PAD_END = 64;
+ private static final int REPEAT = 65;
+ private static final int REGEX_REPLACE = 66;
+ private static final int APPEND = 67;
+ private static final int PREPEND = 68;
+
+ //-----------------------------------------------------------------
+ // Read expressions
+ //-----------------------------------------------------------------
+
+ /**
+ * Create expression that returns the number of Unicode codepoints in {@code src}
+ * as an int64. Equivalent to {@link String#codePointCount(int, int)} on the source.
+ * + * The returned value is the codepoint count — not the count of + * user-perceived characters (grapheme clusters). They agree for ASCII / simple + * Latin text but diverge for combining marks, emoji modifiers, and ZWJ sequences + * (see {@link com.aerospike.client.operation.StringOperation#strlen} for examples). + * For UTF-8 byte length, use {@link #byteLength(Exp)}. + * + *
{@code
+ * // "hello world" -> 11
+ * Exp len = StringExp.strlen(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return integer-typed expression yielding the codepoint count
+ */
+ public static Exp strlen(Exp src) {
+ byte[] bytes = Pack.pack(STRLEN);
+ return addRead(src, bytes, Exp.Type.INT);
+ }
+
+ /**
+ * Create expression that returns the substring of {@code src} from codepoint
+ * {@code start} to the end. Negative {@code start} counts from the end of the
+ * string.
+ *
+ * {@code
+ * // "hello world" from 6 -> "world"
+ * Exp tail = StringExp.substr(Exp.val(6), Exp.stringBin("text"));
+ * }
+ *
+ * @param start starting codepoint index (negative counts from end)
+ * @param src source string expression
+ * @return string-typed expression yielding the substring
+ */
+ public static Exp substr(Exp start, Exp src) {
+ byte[] bytes = Pack.pack(SUBSTR, start);
+ return addRead(src, bytes, Exp.Type.STRING);
+ }
+
+ /**
+ * Create expression that returns the codepoints of {@code src} from {@code start}
+ * (inclusive) to {@code end} (exclusive). Negative indexes count from the end.
+ * If, after negative-index normalization, {@code start >= end}, the result is the
+ * empty string.
+ *
+ * {@code
+ * // "hello world" [0, 5) -> "hello"
+ * Exp head = StringExp.substr(Exp.val(0), Exp.val(5), Exp.stringBin("text"));
+ * }
+ *
+ * @param start starting codepoint index, inclusive (negative counts from end)
+ * @param end end codepoint index, exclusive (negative counts from end)
+ * @param src source string expression
+ * @return string-typed expression yielding the substring
+ */
+ public static Exp substr(Exp start, Exp end, Exp src) {
+ byte[] bytes = Pack.pack(SUBSTR, start, end);
+ return addRead(src, bytes, Exp.Type.STRING);
+ }
+
+ /**
+ * Create expression that returns the codepoint at {@code index} of {@code src}
+ * as a one-codepoint string. Negative indexes count from the end.
+ *
+ * {@code
+ * // "Hello123World" at 5 -> "1"
+ * Exp c = StringExp.charAt(Exp.val(5), Exp.stringBin("text"));
+ * }
+ *
+ * @param index codepoint index (negative counts from end)
+ * @param src source string expression
+ * @return string-typed expression yielding a single-codepoint string
+ */
+ public static Exp charAt(Exp index, Exp src) {
+ byte[] bytes = Pack.pack(CHAR_AT, index);
+ return addRead(src, bytes, Exp.Type.STRING);
+ }
+
+ /**
+ * Create expression that returns the codepoint index of the first occurrence of
+ * {@code needle} in {@code src}, or {@code -1} if not found.
+ *
+ * {@code
+ * // "hello world" find "world" -> 6
+ * Exp idx = StringExp.find(Exp.val("world"), Exp.stringBin("text"));
+ * }
+ *
+ * @param needle substring to search for (any expression yielding a string)
+ * @param src source string expression
+ * @return integer-typed expression: codepoint index, or -1 if absent
+ */
+ public static Exp find(Exp needle, Exp src) {
+ byte[] bytes = Pack.pack(FIND, needle);
+ return addRead(src, bytes, Exp.Type.INT);
+ }
+
+ /**
+ * Create expression that returns the codepoint index of the {@code occurrence}-th
+ * match of {@code needle} ({@code 1} = first, {@code -1} = last), or {@code -1}
+ * if not found.
+ *
+ * {@code
+ * // "ababab" 2nd occurrence of "ab" -> 2
+ * Exp idx = StringExp.find(Exp.val("ab"), Exp.val(2), Exp.stringBin("text"));
+ * }
+ *
+ * @param needle substring to search for
+ * @param occurrence 1-based occurrence to return (negative counts from the last)
+ * @param src source string expression
+ * @return integer-typed expression: codepoint index, or -1 if absent
+ */
+ public static Exp find(Exp needle, Exp occurrence, Exp src) {
+ byte[] bytes = Pack.pack(FIND, needle, occurrence);
+ return addRead(src, bytes, Exp.Type.INT);
+ }
+
+ /**
+ * Create expression that tests whether {@code src} contains {@code needle} as a
+ * substring. Returns an integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * Expression filter = Exp.build(Exp.eq(
+ * StringExp.contains(Exp.val("hello"), Exp.stringBin("text")),
+ * Exp.val(1)));
+ * }
+ *
+ * @param needle substring to test for
+ * @param src source string expression
+ * @return integer-typed expression: 1 on match, 0 otherwise
+ */
+ public static Exp contains(Exp needle, Exp src) {
+ byte[] bytes = Pack.pack(CONTAINS, needle);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ /**
+ * Create expression that tests whether {@code src} begins with {@code prefix}.
+ * Returns an integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * Exp matched = StringExp.startsWith(Exp.val("Hello"), Exp.stringBin("text"));
+ * }
+ *
+ * @param prefix prefix to test for
+ * @param src source string expression
+ * @return integer-typed expression: 1 on match, 0 otherwise
+ */
+ public static Exp startsWith(Exp prefix, Exp src) {
+ byte[] bytes = Pack.pack(STARTS_WITH, prefix);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ /**
+ * Create expression that tests whether {@code src} ends with {@code suffix}.
+ * Returns an integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * Exp matched = StringExp.endsWith(Exp.val("World"), Exp.stringBin("text"));
+ * }
+ *
+ * @param suffix suffix to test for
+ * @param src source string expression
+ * @return integer-typed expression: 1 on match, 0 otherwise
+ */
+ public static Exp endsWith(Exp suffix, Exp src) {
+ byte[] bytes = Pack.pack(ENDS_WITH, suffix);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ /**
+ * Create expression that parses {@code src} as an int64. The expression returns
+ * an error if the source cannot be parsed as an integer.
+ *
+ * {@code
+ * // "12345" -> 12345
+ * Exp n = StringExp.toInteger(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return integer-typed expression yielding the parsed int64
+ */
+ public static Exp toInteger(Exp src) {
+ byte[] bytes = Pack.pack(TO_INTEGER);
+ return addRead(src, bytes, Exp.Type.INT);
+ }
+
+ /**
+ * Create expression that parses {@code src} as a 64-bit float. The expression
+ * returns an error if the source cannot be parsed as a double.
+ *
+ * {@code
+ * // "3.14" -> 3.14
+ * Exp v = StringExp.toDouble(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return float-typed expression yielding the parsed double
+ */
+ public static Exp toDouble(Exp src) {
+ byte[] bytes = Pack.pack(TO_DOUBLE);
+ return addRead(src, bytes, Exp.Type.FLOAT);
+ }
+
+ /**
+ * Create expression that returns the UTF-8 byte length of {@code src} as an int64.
+ * Differs from {@link #strlen} for non-ASCII content where one codepoint can encode
+ * to multiple bytes.
+ *
+ * {@code
+ * // "hello" -> 5
+ * Exp len = StringExp.byteLength(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return integer-typed expression yielding the UTF-8 byte length
+ */
+ public static Exp byteLength(Exp src) {
+ byte[] bytes = Pack.pack(BYTE_LENGTH);
+ return addRead(src, bytes, Exp.Type.INT);
+ }
+
+ /**
+ * Create expression that tests whether {@code src} contains a valid integer or
+ * float literal. Returns an integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * Exp numeric = StringExp.isNumeric(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return integer-typed expression: 1 if numeric, 0 otherwise
+ */
+ public static Exp isNumeric(Exp src) {
+ byte[] bytes = Pack.pack(IS_NUMERIC);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ /**
+ * Create expression that tests whether {@code src} parses as a number of the
+ * requested {@link com.aerospike.client.operation.StringNumericType}. Returns an
+ * integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * // restrict to integer-only validation
+ * Exp isInt = StringExp.isNumeric(StringNumericType.INT, Exp.stringBin("text"));
+ * }
+ *
+ * @param numericType one of the {@link com.aerospike.client.operation.StringNumericType} constants
+ * @param src source string expression
+ * @return integer-typed expression: 1 if numeric of the given type, 0 otherwise
+ */
+ public static Exp isNumeric(int numericType, Exp src) {
+ byte[] bytes = Pack.pack(IS_NUMERIC, numericType);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ /**
+ * Create expression that tests whether every cased codepoint in {@code src} is
+ * uppercase. Returns an integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * Exp upper = StringExp.isUpper(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return integer-typed expression: 1 if all-uppercase, 0 otherwise
+ */
+ public static Exp isUpper(Exp src) {
+ byte[] bytes = Pack.pack(IS_UPPER);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ /**
+ * Create expression that tests whether every cased codepoint in {@code src} is
+ * lowercase. Returns an integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * Exp lower = StringExp.isLower(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return integer-typed expression: 1 if all-lowercase, 0 otherwise
+ */
+ public static Exp isLower(Exp src) {
+ byte[] bytes = Pack.pack(IS_LOWER);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ /**
+ * Create expression that returns the UTF-8 bytes of {@code src} as a blob.
+ *
+ * {@code
+ * // "hello" -> [0x68, 0x65, 0x6c, 0x6c, 0x6f]
+ * Exp blob = StringExp.toBlob(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return blob-typed expression yielding the UTF-8 byte array
+ */
+ public static Exp toBlob(Exp src) {
+ byte[] bytes = Pack.pack(TO_BLOB);
+ return addRead(src, bytes, Exp.Type.BLOB);
+ }
+
+ /**
+ * Create expression that splits {@code src} by Unicode codepoint — each codepoint
+ * becomes its own list element.
+ *
+ * {@code
+ * // "abc" -> ["a", "b", "c"]
+ * Exp parts = StringExp.split(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression
+ * @return list-typed expression yielding a list of single-codepoint strings
+ */
+ public static Exp split(Exp src) {
+ byte[] bytes = Pack.pack(SPLIT);
+ return addRead(src, bytes, Exp.Type.LIST);
+ }
+
+ /**
+ * Create expression that splits {@code src} by the {@code separator} substring.
+ * If the separator is absent, the result is a singleton list containing the whole
+ * source.
+ *
+ * {@code
+ * // "one,two,three" with "," -> ["one", "two", "three"]
+ * Exp tokens = StringExp.split(Exp.val(","), Exp.stringBin("text"));
+ * }
+ *
+ * @param separator substring used to split the source
+ * @param src source string expression
+ * @return list-typed expression yielding the token list
+ */
+ public static Exp split(Exp separator, Exp src) {
+ byte[] bytes = Pack.pack(SPLIT, separator);
+ return addRead(src, bytes, Exp.Type.LIST);
+ }
+
+ /**
+ * Create expression that base64-decodes {@code src} and returns the decoded
+ * bytes as a blob.
+ *
+ * {@code
+ * // "aGVsbG8=" -> "hello".getBytes()
+ * Exp decoded = StringExp.b64Decode(Exp.stringBin("text"));
+ * }
+ *
+ * @param src source string expression holding base64 text
+ * @return blob-typed expression yielding the decoded bytes
+ */
+ public static Exp b64Decode(Exp src) {
+ byte[] bytes = Pack.pack(B64_DECODE);
+ return addRead(src, bytes, Exp.Type.BLOB);
+ }
+
+ /**
+ * Create expression that tests whether {@code pattern} (ICU regex syntax) matches
+ * {@code src}. Returns an integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * // matches if "text" contains any digit run
+ * Exp matched = StringExp.regexCompare(Exp.val("[0-9]+"), Exp.stringBin("text"));
+ * }
+ *
+ * @param pattern ICU-syntax regex pattern (must be valid UTF-8)
+ * @param src source string expression
+ * @return integer-typed expression: 1 on match, 0 otherwise
+ */
+ public static Exp regexCompare(Exp pattern, Exp src) {
+ byte[] bytes = Pack.pack(REGEX_COMPARE, pattern);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ /**
+ * Create expression that tests whether {@code pattern} matches {@code src} under
+ * the supplied {@link StringRegexFlags}. Flags can be combined with bitwise OR.
+ * Returns an integer flag: {@code 1} on match, {@code 0} otherwise.
+ *
+ * {@code
+ * Exp matched = StringExp.regexCompare(
+ * Exp.val("hello"), StringRegexFlags.CASE_INSENSITIVE,
+ * Exp.stringBin("text"));
+ * }
+ *
+ * @param pattern ICU-syntax regex pattern (must be valid UTF-8)
+ * @param regexFlags bitwise-OR of {@link StringRegexFlags} constants
+ * @param src source string expression
+ * @return integer-typed expression: 1 on match, 0 otherwise
+ */
+ public static Exp regexCompare(Exp pattern, int regexFlags, Exp src) {
+ byte[] bytes = Pack.pack(REGEX_COMPARE, pattern, regexFlags);
+ return addRead(src, bytes, Exp.Type.BOOL);
+ }
+
+ //-----------------------------------------------------------------
+ // Modify expressions
+ //-----------------------------------------------------------------
+
+ /**
+ * Create expression that splices {@code value} into {@code src} at codepoint
+ * {@code index} and returns the resulting string. Negative indexes count from the
+ * end. Does not modify the underlying bin.
+ *
+ * {@code
+ * // "hello world" insert " beautiful" at 5 -> "hello beautiful world"
+ * Exp out = StringExp.insert(StringPolicy.Default,
+ * Exp.val(5), Exp.val(" beautiful"), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param index codepoint index at which to insert (negative counts from end)
+ * @param value text to insert
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp insert(StringPolicy policy, Exp index, Exp value, Exp src) {
+ byte[] bytes = Pack.pack(INSERT, index, value, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that overwrites codepoints in {@code src} starting at codepoint
+ * {@code index} with {@code value}, returning the resulting string. The result may
+ * grow beyond the original length when {@code value} extends past the end. Does not
+ * modify the underlying bin.
+ *
+ * {@code
+ * // "hello world" overwrite "earth" at 6 -> "hello earth"
+ * Exp out = StringExp.overwrite(StringPolicy.Default,
+ * Exp.val(6), Exp.val("earth"), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param index codepoint index at which to start overwriting
+ * @param value text to write
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp overwrite(StringPolicy policy, Exp index, Exp value, Exp src) {
+ byte[] bytes = Pack.pack(OVERWRITE, index, value, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that concatenates {@code values} (a list of strings) onto
+ * {@code src} in order, returning the resulting string. Does not modify the
+ * underlying bin.
+ *
+ * {@code
+ * // "hello" + [" ", "big", " world"] -> "hello big world"
+ * Exp out = StringExp.concat(StringPolicy.Default,
+ * Exp.val(Arrays.asList(" ", "big", " world")),
+ * Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param values expression yielding a list of strings to append
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp concat(StringPolicy policy, Exp values, Exp src) {
+ byte[] bytes = Pack.pack(CONCAT, values, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that appends {@code value} (a single string) to the end of {@code src},
+ * returning the resulting string. Does not modify the underlying bin.
+ * + * Unicode/DBCS-aware counterpart to the legacy byte-level append; provides a consistent + * string-package interface alongside {@link com.aerospike.client.exp.Exp}. + * + *
{@code
+ * // "hello" + append "!" -> "hello!"
+ * Exp out = StringExp.append(StringPolicy.Default, Exp.val("!"), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param value expression yielding the string to append to the end
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp append(StringPolicy policy, Exp value, Exp src) {
+ byte[] bytes = Pack.pack(APPEND, value, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that prepends {@code value} (a single string) to the start of {@code src},
+ * returning the resulting string. Does not modify the underlying bin.
+ * + * Unicode/DBCS-aware counterpart to the legacy byte-level prepend; provides a consistent + * string-package interface alongside {@link com.aerospike.client.exp.Exp}. + * + *
{@code
+ * // "world" prepend "hello " -> "hello world"
+ * Exp out = StringExp.prepend(StringPolicy.Default, Exp.val("hello "), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param value expression yielding the string to prepend to the start
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp prepend(StringPolicy policy, Exp value, Exp src) {
+ byte[] bytes = Pack.pack(PREPEND, value, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that removes the half-open codepoint range {@code [start, end)}
+ * from {@code src} and returns the resulting string. Does not modify the underlying
+ * bin.
+ *
+ * {@code
+ * // "hello beautiful world" snip [5, 15) -> "hello world"
+ * Exp out = StringExp.snip(StringPolicy.Default,
+ * Exp.val(5), Exp.val(15), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param start first codepoint to remove (inclusive)
+ * @param end one past the last codepoint to remove (exclusive)
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp snip(StringPolicy policy, Exp start, Exp end, Exp src) {
+ byte[] bytes = Pack.pack(SNIP, start, end, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that replaces the first occurrence of {@code needle} in
+ * {@code src} with {@code replacement} and returns the resulting string. Does not
+ * modify the underlying bin.
+ *
+ * {@code
+ * // "hello world world" replace "world"->"earth" -> "hello earth world"
+ * Exp out = StringExp.replace(StringPolicy.Default,
+ * Exp.val("world"), Exp.val("earth"), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param needle substring to find
+ * @param replacement text to substitute (may be empty to delete the match)
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp replace(StringPolicy policy, Exp needle, Exp replacement, Exp src) {
+ byte[] bytes = packReplace(REPLACE, needle, replacement, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that replaces every occurrence of {@code needle} in {@code src}
+ * with {@code replacement} and returns the resulting string. Does not modify the
+ * underlying bin.
+ *
+ * {@code
+ * // "aabaa" replaceAll "a"->"x" -> "xxbxx"
+ * Exp out = StringExp.replaceAll(StringPolicy.Default,
+ * Exp.val("a"), Exp.val("x"), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param needle substring to find
+ * @param replacement text to substitute (may be empty to delete each match)
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp replaceAll(StringPolicy policy, Exp needle, Exp replacement, Exp src) {
+ byte[] bytes = packReplace(REPLACE_ALL, needle, replacement, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that returns {@code src} uppercased. Does not modify the
+ * underlying bin.
+ *
+ * {@code
+ * Exp out = StringExp.upper(StringPolicy.Default, Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param src source string expression
+ * @return string-typed expression yielding the uppercased string
+ */
+ public static Exp upper(StringPolicy policy, Exp src) {
+ byte[] bytes = Pack.pack(UPPER, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that returns {@code src} lowercased. Does not modify the
+ * underlying bin.
+ *
+ * {@code
+ * Exp out = StringExp.lower(StringPolicy.Default, Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param src source string expression
+ * @return string-typed expression yielding the lowercased string
+ */
+ public static Exp lower(StringPolicy policy, Exp src) {
+ byte[] bytes = Pack.pack(LOWER, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that returns {@code src} case-folded (locale-independent
+ * lowercase). Useful for normalized comparison keys. Does not modify the underlying
+ * bin.
+ *
+ * {@code
+ * Exp out = StringExp.caseFold(StringPolicy.Default, Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param src source string expression
+ * @return string-typed expression yielding the case-folded string
+ */
+ public static Exp caseFold(StringPolicy policy, Exp src) {
+ byte[] bytes = Pack.pack(CASE_FOLD, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that returns {@code src} normalized to Unicode NFC form.
+ * Already-normalized strings are unchanged. Does not modify the underlying bin.
+ *
+ * {@code
+ * Exp out = StringExp.normalizeNFC(StringPolicy.Default, Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param src source string expression
+ * @return string-typed expression yielding the NFC-normalized string
+ */
+ public static Exp normalizeNFC(StringPolicy policy, Exp src) {
+ byte[] bytes = Pack.pack(NORMALIZE_NFC, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that returns {@code src} with whitespace removed from the start.
+ * Does not modify the underlying bin.
+ *
+ * {@code
+ * // " hello " -> "hello "
+ * Exp out = StringExp.trimStart(StringPolicy.Default, Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param src source string expression
+ * @return string-typed expression yielding the left-trimmed string
+ */
+ public static Exp trimStart(StringPolicy policy, Exp src) {
+ byte[] bytes = Pack.pack(TRIM_START, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that returns {@code src} with whitespace removed from the end.
+ * Does not modify the underlying bin.
+ *
+ * {@code
+ * // " hello " -> " hello"
+ * Exp out = StringExp.trimEnd(StringPolicy.Default, Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param src source string expression
+ * @return string-typed expression yielding the right-trimmed string
+ */
+ public static Exp trimEnd(StringPolicy policy, Exp src) {
+ byte[] bytes = Pack.pack(TRIM_END, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that returns {@code src} with whitespace removed from both
+ * ends. Does not modify the underlying bin.
+ *
+ * {@code
+ * // " hello world " -> "hello world"
+ * Exp out = StringExp.trim(StringPolicy.Default, Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param src source string expression
+ * @return string-typed expression yielding the trimmed string
+ */
+ public static Exp trim(StringPolicy policy, Exp src) {
+ byte[] bytes = Pack.pack(TRIM, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that prepends {@code padString} to {@code src} repeatedly until
+ * the result reaches {@code targetLength} codepoints. No-op when the source is
+ * already at or above the target length. Does not modify the underlying bin.
+ *
+ * {@code
+ * // "hello" pad to 10 with "*" -> "*****hello"
+ * Exp out = StringExp.padStart(StringPolicy.Default,
+ * Exp.val(10), Exp.val("*"), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param targetLength codepoint length to pad up to
+ * @param padString text used to fill (repeated as needed)
+ * @param src source string expression
+ * @return string-typed expression yielding the padded string
+ */
+ public static Exp padStart(StringPolicy policy, Exp targetLength, Exp padString, Exp src) {
+ byte[] bytes = Pack.pack(PAD_START, targetLength, padString, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that appends {@code padString} to {@code src} repeatedly until
+ * the result reaches {@code targetLength} codepoints. No-op when the source is
+ * already at or above the target length. Does not modify the underlying bin.
+ *
+ * {@code
+ * // "hello" pad to 10 with "." -> "hello....."
+ * Exp out = StringExp.padEnd(StringPolicy.Default,
+ * Exp.val(10), Exp.val("."), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param targetLength codepoint length to pad up to
+ * @param padString text used to fill (repeated as needed)
+ * @param src source string expression
+ * @return string-typed expression yielding the padded string
+ */
+ public static Exp padEnd(StringPolicy policy, Exp targetLength, Exp padString, Exp src) {
+ byte[] bytes = Pack.pack(PAD_END, targetLength, padString, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that returns {@code src} repeated {@code count} times. Does
+ * not modify the underlying bin.
+ *
+ * {@code
+ * // "ab" repeat 3 -> "ababab"
+ * Exp out = StringExp.repeat(StringPolicy.Default,
+ * Exp.val(3), Exp.stringBin("text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param count number of repetitions (must be non-negative)
+ * @param src source string expression
+ * @return string-typed expression yielding the repeated string
+ */
+ public static Exp repeat(StringPolicy policy, Exp count, Exp src) {
+ byte[] bytes = Pack.pack(REPEAT, count, policy.flags);
+ return addModify(src, bytes);
+ }
+
+ /**
+ * Create expression that replaces matches of {@code pattern} (ICU regex syntax) in
+ * {@code src} with {@code replacement} and returns the resulting string. Pass
+ * {@link StringRegexFlags#GLOBAL} to replace every match. Flag values may be
+ * combined with bitwise OR. Does not modify the underlying bin.
+ *
+ * {@code
+ * // "abc123def456" regexReplace "[0-9]+"->"NUM" with GLOBAL -> "abcNUMdefNUM"
+ * Exp out = StringExp.regexReplace(StringPolicy.Default,
+ * Exp.val("[0-9]+"), Exp.val("NUM"), StringRegexFlags.GLOBAL,
+ * Exp.stringBin("text"));
+ * }
+ *
+ * @param policy kept for API symmetry with the other modify ops; unused — the
+ * regex_replace server op does not accept policy flags
+ * (see implementation note)
+ * @param pattern ICU-syntax regex pattern (must be valid UTF-8)
+ * @param replacement replacement text (must be valid UTF-8)
+ * @param regexFlags bitwise-OR of {@link StringRegexFlags} constants
+ * @param src source string expression
+ * @return string-typed expression yielding the modified string
+ */
+ public static Exp regexReplace(
+ StringPolicy policy,
+ Exp pattern,
+ Exp replacement,
+ int regexFlags,
+ Exp src
+ ) {
+ byte[] bytes = packRegexReplace(pattern, replacement, regexFlags);
+ return addModify(src, bytes);
+ }
+
+ //-----------------------------------------------------------------
+ // Type conversion expression
+ //-----------------------------------------------------------------
+
+ /**
+ * Create expression that returns the string representation of {@code src}, where
+ * {@code src} may be any expression yielding an integer, float, string, or blob
+ * value. Returns an error for any other source type.
+ *
+ * {@code
+ * // integer bin "n" = 42 -> "42"
+ * Exp s = StringExp.toString(Exp.intBin("n"));
+ * }
+ *
+ * @param src source expression (integer, float, string, or blob)
+ * @return string-typed expression yielding the string representation
+ */
+ public static Exp toString(Exp src) {
+ byte[] bytes = reprPayload();
+ return new Exp.Module(src, bytes, Exp.Type.STRING.code, MODULE_REPR);
+ }
+
+ //-----------------------------------------------------------------
+ // Private helpers
+ //-----------------------------------------------------------------
+
+ private static Exp addRead(Exp src, byte[] bytes, Exp.Type retType) {
+ return new Exp.Module(src, bytes, retType.code, MODULE);
+ }
+
+ private static Exp addModify(Exp src, byte[] bytes) {
+ return new Exp.Module(src, bytes, Exp.Type.STRING.code, MODULE | Exp.MODIFY);
+ }
+
+ // QUOTED opcode (mirrors Exp.QUOTED = 126; the constant is private in Exp.java).
+ // Used to mark an inner msgpack list as a literal — without it, the server's
+ // expression compiler at exp.c:3289 treats any bare nested list inside a CALL
+ // payload as a sub-expression and recursively compiles its first element as an
+ // opcode, which fails with PARAMETER_ERROR for our string-pair lists.
+ private static final int QUOTED = 126;
+
+ // [cmd, [QUOTED, [needle, repl]], flags] — needle/replacement nested inside a
+ // QUOTED-wrapped 2-element list so the expression compiler treats it as a literal
+ // rather than a sub-expression. The direct-op path packs the same logical shape
+ // (without QUOTED) because it bypasses the expression engine — see
+ // StringOperation.packStringOp(int, List+ * Index orientation is left-to-right with codepoint addressing. Negative indexes + * count from the end of the string ({@code -1} = last codepoint). Out-of-bounds + * indexes are clamped to the valid range; no error is returned. + *
+ * String operations require server version 8.1.3 or later. A non-empty {@link CTX} + * argument navigates into a string nested inside a list or map bin; with no CTX + * the operation targets the bin itself. The CTX-navigated leaf must already be an + * Aerospike string — operations on non-string leaves return + * {@code AEROSPIKE_ERR_INCOMPATIBLE_TYPE}. + * + *
{@code
+ * // Read: bin "text" = "hello world"
+ * Record r = client.operate(null, key, StringOperation.strlen("text"));
+ * long len = r.getLong("text"); // 11
+ *
+ * // Modify: uppercase a string nested in a list bin "items" at index 0.
+ * client.operate(null, key,
+ * StringOperation.upper(StringPolicy.Default, "items", CTX.listIndex(0)));
+ * }
+ */
+public final class StringOperation {
+ // Read ops
+ private static final int STRLEN = 0;
+ private static final int SUBSTR = 1;
+ private static final int CHAR_AT = 2;
+ private static final int FIND = 3;
+ private static final int CONTAINS = 4;
+ private static final int STARTS_WITH = 5;
+ private static final int ENDS_WITH = 6;
+ private static final int TO_INTEGER = 7;
+ private static final int TO_DOUBLE = 8;
+ private static final int BYTE_LENGTH = 9;
+ private static final int IS_NUMERIC = 10;
+ private static final int IS_UPPER = 11;
+ private static final int IS_LOWER = 12;
+ private static final int TO_BLOB = 13;
+ private static final int SPLIT = 14;
+ private static final int B64_DECODE = 15;
+ private static final int REGEX_COMPARE = 16;
+
+ // Modify ops
+ private static final int INSERT = 50;
+ private static final int OVERWRITE = 51;
+ private static final int CONCAT = 52;
+ private static final int SNIP = 53;
+ private static final int REPLACE = 54;
+ private static final int REPLACE_ALL = 55;
+ private static final int UPPER = 56;
+ private static final int LOWER = 57;
+ private static final int CASE_FOLD = 58;
+ private static final int NORMALIZE_NFC = 59;
+ private static final int TRIM_START = 60;
+ private static final int TRIM_END = 61;
+ private static final int TRIM = 62;
+ private static final int PAD_START = 63;
+ private static final int PAD_END = 64;
+ private static final int REPEAT = 65;
+ private static final int REGEX_REPLACE = 66;
+ private static final int APPEND = 67;
+ private static final int PREPEND = 68;
+
+ //-----------------------------------------------------------------
+ // Read operations
+ //-----------------------------------------------------------------
+
+ /**
+ * Create string {@code strlen} operation. Returns the number of Unicode codepoints
+ * in the string bin as an int64. This matches {@link String#codePointCount(int, int)}
+ * called on the string's full range.
+ * + * The returned value is the codepoint count — not the count of + * user-perceived characters (grapheme clusters). Codepoints and visible characters + * agree for ASCII and simple Latin text, but diverge for combining marks, emoji + * modifiers, and zero-width-joiner sequences: + *
+ * Two related counts that this op does not return: + *
{@code
+ * // ASCII: "hello world" -> 11 (codepoint count == byte length here)
+ * Record r = client.operate(null, key, StringOperation.strlen("text"));
+ * long len = r.getLong("text");
+ *
+ * // Multi-byte UTF-8: "héllo" stores as 6 bytes but 5 codepoints -> 5
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the codepoint count (int64)
+ */
+ public static Operation strlen(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(STRLEN, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code substr} operation that reads from {@code start} to the end of
+ * the string. Negative indexes count from the end.
+ *
+ * {@code
+ * // "hello world" -> "world"
+ * Record r = client.operate(null, key, StringOperation.substr("text", 6));
+ * String tail = r.getString("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param start starting codepoint index (negative counts from end)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the substring
+ */
+ public static Operation substr(String binName, int start, CTX... ctx) {
+ byte[] bytes = packStringOp(SUBSTR, start, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code substr} operation that returns the codepoints of the bin
+ * from {@code start} (inclusive) to {@code end} (exclusive). Negative indexes
+ * count from the end of the string. If, after negative-index normalization,
+ * {@code start >= end}, the result is the empty string.
+ *
+ * {@code
+ * // "hello world" [0, 5) -> "hello"
+ * Record r = client.operate(null, key, StringOperation.substr("text", 0, 5));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param start starting codepoint index, inclusive (negative counts from end)
+ * @param end end codepoint index, exclusive (negative counts from end)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the substring
+ */
+ public static Operation substr(String binName, int start, int end, CTX... ctx) {
+ byte[] bytes = packStringOp(SUBSTR, start, end, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code charAt} operation. Returns the codepoint at {@code index}
+ * as a one-codepoint string. Negative indexes count from the end.
+ *
+ * {@code
+ * // "Hello123World" at index 5 -> "1"
+ * Record r = client.operate(null, key, StringOperation.charAt("text", 5));
+ * String c = r.getString("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param index codepoint index (negative counts from end)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a single-codepoint string
+ */
+ public static Operation charAt(String binName, int index, CTX... ctx) {
+ byte[] bytes = packStringOp(CHAR_AT, index, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code find} operation. Returns the codepoint index of the first
+ * occurrence of {@code needle}, or {@code -1} if not found.
+ *
+ * {@code
+ * // "hello world" -> 6
+ * Record r = client.operate(null, key, StringOperation.find("text", "world"));
+ * long idx = r.getLong("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param needle substring to search for
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the codepoint index, or -1 if absent
+ */
+ public static Operation find(String binName, String needle, CTX... ctx) {
+ byte[] bytes = packStringOp(FIND, Value.get(needle), ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code find} operation that locates a specific {@code occurrence}
+ * of {@code needle} ({@code 1} = first match, {@code -1} = last match). Returns the
+ * codepoint index of that match, or {@code -1} if not found.
+ *
+ * {@code
+ * // "ababab" 2nd occurrence of "ab" -> 2
+ * Record r = client.operate(null, key, StringOperation.find("text", "ab", 2));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param needle substring to search for
+ * @param occurrence 1-based occurrence to return (negative counts from the last match)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the codepoint index, or -1 if absent
+ */
+ public static Operation find(String binName, String needle, int occurrence, CTX... ctx) {
+ byte[] bytes = packStringOp(FIND, Value.get(needle), occurrence, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code contains} operation. Returns {@code true} if the bin contains
+ * {@code needle} as a substring, {@code false} otherwise.
+ *
+ * {@code
+ * // "hello world" -> true
+ * Record r = client.operate(null, key, StringOperation.contains("text", "hello"));
+ * boolean has = r.getBoolean("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param needle substring to test for
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation contains(String binName, String needle, CTX... ctx) {
+ byte[] bytes = packStringOp(CONTAINS, Value.get(needle), ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code startsWith} operation. Returns {@code true} if the bin begins
+ * with {@code prefix}, {@code false} otherwise.
+ *
+ * {@code
+ * // "Hello123World" -> true
+ * Record r = client.operate(null, key, StringOperation.startsWith("text", "Hello"));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param prefix prefix to test for
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation startsWith(String binName, String prefix, CTX... ctx) {
+ byte[] bytes = packStringOp(STARTS_WITH, Value.get(prefix), ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code endsWith} operation. Returns {@code true} if the bin ends
+ * with {@code suffix}, {@code false} otherwise.
+ *
+ * {@code
+ * // "Hello123World" -> true
+ * Record r = client.operate(null, key, StringOperation.endsWith("text", "World"));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param suffix suffix to test for
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation endsWith(String binName, String suffix, CTX... ctx) {
+ byte[] bytes = packStringOp(ENDS_WITH, Value.get(suffix), ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code toInteger} operation. Parses the string as an int64.
+ * Returns {@code AEROSPIKE_ERR_PARAMETER} if the bin cannot be parsed as an integer.
+ *
+ * {@code
+ * // "12345" -> 12345
+ * Record r = client.operate(null, key, StringOperation.toInteger("text"));
+ * long n = r.getLong("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the parsed int64
+ */
+ public static Operation toInteger(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(TO_INTEGER, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code toDouble} operation. Parses the string as a 64-bit float.
+ * Returns {@code AEROSPIKE_ERR_PARAMETER} if the bin cannot be parsed as a double.
+ *
+ * {@code
+ * // "3.14" -> 3.14
+ * Record r = client.operate(null, key, StringOperation.toDouble("text"));
+ * double v = r.getDouble("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the parsed double
+ */
+ public static Operation toDouble(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(TO_DOUBLE, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code byteLength} operation. Returns the number of UTF-8 bytes in
+ * the string (int64). Differs from {@link #strlen} for non-ASCII content where one
+ * codepoint can encode to multiple bytes.
+ *
+ * {@code
+ * // "hello" -> 5
+ * Record r = client.operate(null, key, StringOperation.byteLength("text"));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the byte length (int64)
+ */
+ public static Operation byteLength(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(BYTE_LENGTH, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code isNumeric} operation. Returns {@code true} if the bin
+ * contains a valid integer or float, {@code false} otherwise.
+ *
+ * {@code
+ * // "12345" -> true; "Hello" -> false
+ * Record r = client.operate(null, key, StringOperation.isNumeric("text"));
+ * boolean numeric = r.getBoolean("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation isNumeric(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(IS_NUMERIC, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code isNumeric} operation that filters by {@code numericType}
+ * (see {@link StringNumericType}). For example, restrict to integer-only or
+ * float-only validation.
+ *
+ * {@code
+ * // "12345" with INT filter -> true
+ * Record r = client.operate(null, key,
+ * StringOperation.isNumeric("text", StringNumericType.INT));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param numericType one of the {@link StringNumericType} constants
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation isNumeric(String binName, int numericType, CTX... ctx) {
+ byte[] bytes = packStringOp(IS_NUMERIC, numericType, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code isUpper} operation. Returns {@code true} if every cased
+ * codepoint in the bin is uppercase, {@code false} otherwise.
+ *
+ * {@code
+ * // "HELLO" -> true; "Hello" -> false
+ * Record r = client.operate(null, key, StringOperation.isUpper("text"));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation isUpper(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(IS_UPPER, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code isLower} operation. Returns {@code true} if every cased
+ * codepoint in the bin is lowercase, {@code false} otherwise.
+ *
+ * {@code
+ * // "hello" -> true; "Hello" -> false
+ * Record r = client.operate(null, key, StringOperation.isLower("text"));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation isLower(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(IS_LOWER, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code toBlob} operation. Returns the UTF-8 bytes of the string
+ * as a blob (byte[]).
+ *
+ * {@code
+ * // "hello" -> [0x68, 0x65, 0x6c, 0x6c, 0x6f]
+ * Record r = client.operate(null, key, StringOperation.toBlob("text"));
+ * byte[] bytes = (byte[]) r.getValue("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a byte[] blob
+ */
+ public static Operation toBlob(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(TO_BLOB, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code split} operation that splits by Unicode codepoint — each
+ * codepoint becomes its own element of the returned list.
+ *
+ * {@code
+ * // "abc" -> ["a", "b", "c"]
+ * Record r = client.operate(null, key, StringOperation.split("text"));
+ * List> chars = r.getList("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a list of single-codepoint strings
+ */
+ public static Operation split(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(SPLIT, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code split} operation that splits the bin by the {@code separator}
+ * substring. If the separator is absent the result is a singleton list containing
+ * the whole string.
+ *
+ * {@code
+ * // "one,two,three" with "," -> ["one", "two", "three"]
+ * Record r = client.operate(null, key, StringOperation.split("text", ","));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param separator substring used to split the bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a list of token strings
+ */
+ public static Operation split(String binName, String separator, CTX... ctx) {
+ byte[] bytes = packStringOp(SPLIT, Value.get(separator), ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code b64Decode} operation. Treats the bin as base64-encoded text
+ * and returns the decoded bytes as a blob.
+ *
+ * {@code
+ * // "aGVsbG8=" -> "hello".getBytes()
+ * Record r = client.operate(null, key, StringOperation.b64Decode("text"));
+ * byte[] decoded = (byte[]) r.getValue("text");
+ * }
+ *
+ * @param binName name of the string bin holding base64 text
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning the decoded byte[]
+ */
+ public static Operation b64Decode(String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(B64_DECODE, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code regexCompare} operation. Matches {@code pattern} (ICU regex
+ * syntax) against the bin and returns {@code true} on match, {@code false} otherwise.
+ *
+ * {@code
+ * // "Hello123World" matches "[0-9]+" -> true
+ * Record r = client.operate(null, key, StringOperation.regexCompare("text", "[0-9]+"));
+ * boolean matched = r.getBoolean("text");
+ * }
+ *
+ * @param binName name of the string bin
+ * @param pattern ICU-syntax regex pattern (must be valid UTF-8)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation regexCompare(String binName, String pattern, CTX... ctx) {
+ byte[] bytes = packStringOp(REGEX_COMPARE, Value.get(pattern), ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code regexCompare} operation that honors {@link StringRegexFlags}
+ * (e.g. {@link StringRegexFlags#CASE_INSENSITIVE}). Flag values may be combined
+ * with bitwise OR.
+ *
+ * {@code
+ * // "HELLO" matches "hello" with CASE_INSENSITIVE -> true
+ * Record r = client.operate(null, key,
+ * StringOperation.regexCompare("text", "hello", StringRegexFlags.CASE_INSENSITIVE));
+ * }
+ *
+ * @param binName name of the string bin
+ * @param pattern ICU-syntax regex pattern (must be valid UTF-8)
+ * @param regexFlags bitwise-OR of {@link StringRegexFlags} constants
+ * @param ctx optional path into a string nested inside a list or map
+ * @return read operation returning a boolean match flag
+ */
+ public static Operation regexCompare(String binName, String pattern, int regexFlags, CTX... ctx) {
+ byte[] bytes = packStringOp(REGEX_COMPARE, Value.get(pattern), regexFlags, ctx);
+ return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ //-----------------------------------------------------------------
+ // Modify operations
+ //-----------------------------------------------------------------
+
+ /**
+ * Create string {@code insert} operation that splices {@code value} into the bin at
+ * codepoint {@code index}. Negative indexes count from the end of the string.
+ *
+ * {@code
+ * // "hello world" + insert " beautiful" at 5 -> "hello beautiful world"
+ * client.operate(null, key,
+ * StringOperation.insert(StringPolicy.Default, "text", 5, " beautiful"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param index codepoint index at which to insert (negative counts from end)
+ * @param value text to insert
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation insert(StringPolicy policy, String binName, int index, String value, CTX... ctx) {
+ byte[] bytes = packStringOp(INSERT, index, Value.get(value), policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code overwrite} operation that overwrites codepoints starting at
+ * codepoint {@code index} with {@code value}. The result may grow beyond the
+ * original length when {@code value} extends past the end.
+ *
+ * {@code
+ * // "hello world" overwrite "earth" at 6 -> "hello earth"
+ * client.operate(null, key,
+ * StringOperation.overwrite(StringPolicy.Default, "text", 6, "earth"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param index codepoint index at which to start overwriting
+ * @param value text to write
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation overwrite(StringPolicy policy, String binName, int index, String value, CTX... ctx) {
+ byte[] bytes = packStringOp(OVERWRITE, index, Value.get(value), policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code concat} operation that appends {@code value} to the bin.
+ *
+ * {@code
+ * // "hello" + concat "!" -> "hello!"
+ * client.operate(null, key,
+ * StringOperation.concat(StringPolicy.Default, "text", "!"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param value text to append
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation concat(StringPolicy policy, String binName, String value, CTX... ctx) {
+ List{@code
+ * // "hello" + concat [" ", "big", " world"] -> "hello big world"
+ * client.operate(null, key, StringOperation.concat(StringPolicy.Default, "text",
+ * Arrays.asList(" ", "big", " world")));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param values ordered list of strings to append
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation concat(StringPolicy policy, String binName, List+ * Unlike the legacy {@link com.aerospike.client.Operation#append(com.aerospike.client.Bin)}, this + * operation is Unicode/DBCS-aware and shares the consistent {@link StringPolicy} / CTX interface of + * the rest of the string package. + * + *
{@code
+ * // "hello" + append "!" -> "hello!"
+ * client.operate(null, key,
+ * StringOperation.append(StringPolicy.Default, "text", "!"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param value text to append to the end of the string
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation append(StringPolicy policy, String binName, String value, CTX... ctx) {
+ byte[] bytes = packStringOp(APPEND, Value.get(value), policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code prepend} operation that prepends {@code value} to the start of the bin.
+ * + * Unlike the legacy {@link com.aerospike.client.Operation#prepend(com.aerospike.client.Bin)}, this + * operation is Unicode/DBCS-aware and shares the consistent {@link StringPolicy} / CTX interface of + * the rest of the string package. + * + *
{@code
+ * // "world" prepend "hello " -> "hello world"
+ * client.operate(null, key,
+ * StringOperation.prepend(StringPolicy.Default, "text", "hello "));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param value text to prepend to the start of the string
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation prepend(StringPolicy policy, String binName, String value, CTX... ctx) {
+ byte[] bytes = packStringOp(PREPEND, Value.get(value), policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code snip} operation that removes the half-open codepoint range
+ * {@code [start, end)} from the bin.
+ *
+ * {@code
+ * // "hello beautiful world" snip [5, 15) -> "hello world"
+ * client.operate(null, key,
+ * StringOperation.snip(StringPolicy.Default, "text", 5, 15));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param start first codepoint to remove (inclusive)
+ * @param end one past the last codepoint to remove (exclusive)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation snip(StringPolicy policy, String binName, int start, int end, CTX... ctx) {
+ byte[] bytes = packStringOp(SNIP, start, end, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code replace} operation that replaces the first occurrence of
+ * {@code needle} with {@code replacement}.
+ *
+ * {@code
+ * // "hello world world" replace "world"->"earth" -> "hello earth world"
+ * client.operate(null, key,
+ * StringOperation.replace(StringPolicy.Default, "text", "world", "earth"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param needle substring to find
+ * @param replacement text to substitute (may be empty to delete the match)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation replace(StringPolicy policy, String binName, String needle, String replacement, CTX... ctx) {
+ List{@code
+ * // "aabaa" replaceAll "a"->"x" -> "xxbxx"
+ * client.operate(null, key,
+ * StringOperation.replaceAll(StringPolicy.Default, "text", "a", "x"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param needle substring to find
+ * @param replacement text to substitute (may be empty to delete each match)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation replaceAll(StringPolicy policy, String binName, String needle, String replacement, CTX... ctx) {
+ List{@code
+ * // "hello world" -> "HELLO WORLD"
+ * client.operate(null, key, StringOperation.upper(StringPolicy.Default, "text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation upper(StringPolicy policy, String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(UPPER, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code lower} operation that lowercases the bin in place.
+ *
+ * {@code
+ * // "HELLO WORLD" -> "hello world"
+ * client.operate(null, key, StringOperation.lower(StringPolicy.Default, "text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation lower(StringPolicy policy, String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(LOWER, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code caseFold} operation that applies a locale-independent case
+ * fold (lowercase) to the bin. Useful for normalized comparison keys.
+ *
+ * {@code
+ * // "HELLO World" -> "hello world"
+ * client.operate(null, key, StringOperation.caseFold(StringPolicy.Default, "text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation caseFold(StringPolicy policy, String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(CASE_FOLD, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code normalizeNFC} operation that normalizes the bin to Unicode
+ * NFC form. Already-normalized strings are unchanged.
+ *
+ * {@code
+ * client.operate(null, key,
+ * StringOperation.normalizeNFC(StringPolicy.Default, "text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation normalizeNFC(StringPolicy policy, String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(NORMALIZE_NFC, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code trimStart} operation that removes whitespace from the start
+ * of the bin.
+ *
+ * {@code
+ * // " hello " -> "hello "
+ * client.operate(null, key,
+ * StringOperation.trimStart(StringPolicy.Default, "text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation trimStart(StringPolicy policy, String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(TRIM_START, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code trimEnd} operation that removes whitespace from the end of
+ * the bin.
+ *
+ * {@code
+ * // " hello " -> " hello"
+ * client.operate(null, key,
+ * StringOperation.trimEnd(StringPolicy.Default, "text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation trimEnd(StringPolicy policy, String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(TRIM_END, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code trim} operation that removes whitespace from both ends of
+ * the bin.
+ *
+ * {@code
+ * // " hello world " -> "hello world"
+ * client.operate(null, key,
+ * StringOperation.trim(StringPolicy.Default, "text"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation trim(StringPolicy policy, String binName, CTX... ctx) {
+ byte[] bytes = packStringOp(TRIM, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code padStart} operation that prepends {@code padString}
+ * repeatedly until the bin reaches {@code targetLength} codepoints. No-op when the
+ * bin is already at or above the target length.
+ *
+ * {@code
+ * // "hello" pad to 10 with "*" -> "*****hello"
+ * client.operate(null, key,
+ * StringOperation.padStart(StringPolicy.Default, "text", 10, "*"));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param targetLength codepoint length to pad up to
+ * @param padString text used to fill (repeated as needed)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation padStart(StringPolicy policy, String binName, int targetLength, String padString, CTX... ctx) {
+ byte[] bytes = packStringOp(PAD_START, targetLength, Value.get(padString), policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code padEnd} operation that appends {@code padString} repeatedly
+ * until the bin reaches {@code targetLength} codepoints. No-op when the bin is
+ * already at or above the target length.
+ *
+ * {@code
+ * // "hello" pad to 10 with "." -> "hello....."
+ * client.operate(null, key,
+ * StringOperation.padEnd(StringPolicy.Default, "text", 10, "."));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param targetLength codepoint length to pad up to
+ * @param padString text used to fill (repeated as needed)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation padEnd(StringPolicy policy, String binName, int targetLength, String padString, CTX... ctx) {
+ byte[] bytes = packStringOp(PAD_END, targetLength, Value.get(padString), policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code repeat} operation that repeats the bin contents {@code count}
+ * times.
+ *
+ * {@code
+ * // "ab" repeat 3 -> "ababab"
+ * client.operate(null, key,
+ * StringOperation.repeat(StringPolicy.Default, "text", 3));
+ * }
+ *
+ * @param policy write policy controlling NO_FAIL semantics
+ * @param binName name of the string bin
+ * @param count number of repetitions (must be non-negative)
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation repeat(StringPolicy policy, String binName, int count, CTX... ctx) {
+ byte[] bytes = packStringOp(REPEAT, count, policy.flags, ctx);
+ return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING));
+ }
+
+ /**
+ * Create string {@code regexReplace} operation that replaces the first match of
+ * {@code pattern} with {@code replacement}. Pass {@link StringRegexFlags#GLOBAL}
+ * to replace every match. Flag values from {@link StringRegexFlags} may be combined
+ * with bitwise OR.
+ *
+ * {@code
+ * // "abc123def456" regexReplace "[0-9]+"->"NUM" with GLOBAL -> "abcNUMdefNUM"
+ * client.operate(null, key,
+ * StringOperation.regexReplace(StringPolicy.Default, "text",
+ * "[0-9]+", "NUM", StringRegexFlags.GLOBAL));
+ * }
+ *
+ * @param policy (unused; the regex_replace server op does not accept policy
+ * flags — see implementation note)
+ * @param binName name of the string bin
+ * @param pattern ICU-syntax regex pattern (must be valid UTF-8)
+ * @param replacement replacement text (must be valid UTF-8)
+ * @param regexFlags bitwise-OR of {@link StringRegexFlags} constants
+ * @param ctx optional path into a string nested inside a list or map
+ * @return modify operation
+ */
+ public static Operation regexReplace(
+ StringPolicy policy,
+ String binName,
+ String pattern,
+ String replacement,
+ int regexFlags,
+ CTX... ctx
+ ) {
+ List+ * Unlike the other builders in this class, {@code toString} does not accept a + * {@link CTX}. The other string operations are sent as {@code STRING_READ} / + * {@code STRING_MODIFY} wire ops whose msgpack payload carries the sub-op code, + * arguments, and (when CTX is non-empty) a {@code [0xFF, ctx_list, inner_op]} + * wrapper that the server's CTX-aware dispatcher unwraps to descend into a list + * or map. {@code toString} is a separate top-level wire op + * ({@code Operation.Type.TO_STRING}) that carries no payload at all — the bin is + * referenced solely by the operation header — and the server-side handler for it + * is a different code path that operates on the whole bin particle and never + * inspects an op payload, so there is no place to encode a CTX wrapper and the + * server would not act on it if there were. + *
+ * To convert a value nested inside a list or map, extract the leaf with + * {@link com.aerospike.client.cdt.ListOperation#getByIndex} or + * {@link com.aerospike.client.cdt.MapOperation#getByKey} (using the appropriate + * {@link CTX}) and convert it client-side. + * + *
{@code
+ * // Bin "n" = 42 (integer) -> "42"
+ * Record r = client.operate(null, key, StringOperation.toString("n"));
+ * String s = r.getString("n");
+ * }
+ *
+ * @param binName name of the bin to convert
+ * @return read operation returning the string representation of the bin
+ */
+ public static Operation toString(String binName) {
+ return new Operation(Operation.Type.TO_STRING, binName, Value.getAsNull());
+ }
+
+ //-----------------------------------------------------------------
+ // Private helpers
+ //-----------------------------------------------------------------
+
+ private static List+ * This is a per-operation policy carrying {@link StringWriteFlags}. It is + * passed inline to each {@link StringOperation} builder method and is + * not part of the client's dynamic configuration: there is no + * {@code stringPolicyDefault} on {@link com.aerospike.client.policy.ClientPolicy} + * and no corresponding stanza in the YAML config schema. Changing the flags + * requires constructing a new {@code StringPolicy} and passing it to the + * operation, not editing a config file at runtime. This mirrors how + * {@code BitPolicy} and {@code HLLPolicy} are scoped. + */ +public final class StringPolicy { + /** + * Default string bin write semantics. + */ + public static final StringPolicy Default = new StringPolicy(); + + public final int flags; + + /** + * Use default {@link StringWriteFlags} when performing {@link StringOperation} modify operations. + */ + public StringPolicy() { + this(StringWriteFlags.DEFAULT); + } + + /** + * Use specified {@link StringWriteFlags} when performing {@link StringOperation} modify operations. + */ + public StringPolicy(int flags) { + this.flags = flags; + } +} diff --git a/client/src/com/aerospike/client/operation/StringRegexFlags.java b/client/src/com/aerospike/client/operation/StringRegexFlags.java new file mode 100644 index 000000000..e5d8946a9 --- /dev/null +++ b/client/src/com/aerospike/client/operation/StringRegexFlags.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * 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.aerospike.client.operation; + +/** + * Regex flags for {@link StringOperation#regexCompare} and + * {@link StringOperation#regexReplace}. Combine with bitwise OR. + */ +public final class StringRegexFlags { + /** + * Default. No flags set. + */ + public static final int DEFAULT = 0; + + /** + * Case insensitive matching. + */ + public static final int CASE_INSENSITIVE = 1 << 0; + + /** + * Treat input as a multi-line string. {@code ^} and {@code $} match + * the start and end of any line, not just the start and end of the input. + */ + public static final int MULTILINE = 1 << 1; + + /** + * The {@code .} metacharacter matches any character including line terminators. + */ + public static final int DOTALL = 1 << 2; + + /** + * Treat only {@code \n} as a line terminator (Unix-style line endings). + */ + public static final int UNIX_LINES = 1 << 3; + + /** + * Replace all matches in the input. Only applicable to + * {@link StringOperation#regexReplace}. + */ + public static final int GLOBAL = 1 << 4; +} diff --git a/client/src/com/aerospike/client/operation/StringWriteFlags.java b/client/src/com/aerospike/client/operation/StringWriteFlags.java new file mode 100644 index 000000000..a1e1d591c --- /dev/null +++ b/client/src/com/aerospike/client/operation/StringWriteFlags.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * 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.aerospike.client.operation; + +/** + * String operation policy write bit flags. Use BITWISE OR to combine flags. Example: + * + *
{@code
+ * int flags = StringWriteFlags.NO_FAIL;
+ * }
+ */
+public final class StringWriteFlags {
+ /**
+ * Default. Allow create or update.
+ */
+ public static final int DEFAULT = 0;
+
+ /**
+ * Do not raise error if operation cannot be applied to the bin
+ * (e.g. wrong bin type). The bin is left unchanged and a null
+ * result is returned for that operation.
+ */
+ public static final int NO_FAIL = 4;
+}
diff --git a/client/src/com/aerospike/client/util/Pack.java b/client/src/com/aerospike/client/util/Pack.java
index 21082056a..94a8e169d 100644
--- a/client/src/com/aerospike/client/util/Pack.java
+++ b/client/src/com/aerospike/client/util/Pack.java
@@ -183,6 +183,26 @@ public static byte[] pack(int command, Value value, CTX... ctx) {
return packer.toByteArray();
}
+ public static byte[] pack(int command, Value value, int v1, CTX... ctx) {
+ Packer packer = new Packer();
+
+ init(packer, ctx);
+ packer.packArrayBegin(3);
+ packer.packInt(command);
+ value.pack(packer);
+ packer.packInt(v1);
+
+ packer.createBuffer();
+
+ init(packer, ctx);
+ packer.packArrayBegin(3);
+ packer.packInt(command);
+ value.pack(packer);
+ packer.packInt(v1);
+
+ return packer.getBuffer();
+ }
+
public static byte[] pack(int command, Value value, int v1, int v2, CTX... ctx) {
Packer packer = new Packer();
init(packer, ctx);
@@ -298,6 +318,16 @@ public static byte[] pack(int command, Exp v1) {
return packer.toByteArray();
}
+ public static byte[] pack(int command, Exp v1, int v2, CTX... ctx) {
+ Packer packer = new Packer();
+ init(packer, ctx);
+ packer.packArrayBegin(3);
+ packer.packInt(command);
+ v1.pack(packer);
+ packer.packInt(v2);
+ return packer.toByteArray();
+ }
+
public static byte[] pack(int command, Exp v1, int v2, int v3, CTX... ctx) {
Packer packer = new Packer();
init(packer, ctx);
diff --git a/examples/src/com/aerospike/examples/Main.java b/examples/src/com/aerospike/examples/Main.java
index 41e09877c..1062f0442 100644
--- a/examples/src/com/aerospike/examples/Main.java
+++ b/examples/src/com/aerospike/examples/Main.java
@@ -58,6 +58,8 @@ public class Main extends JPanel {
"OperateList",
"OperateMap",
"PathExpression",
+ "OperateString",
+ "StringExpression",
"ScanPage",
"ScanParallel",
"ScanResume",
diff --git a/examples/src/com/aerospike/examples/OperateString.java b/examples/src/com/aerospike/examples/OperateString.java
new file mode 100644
index 000000000..bc2bcc17a
--- /dev/null
+++ b/examples/src/com/aerospike/examples/OperateString.java
@@ -0,0 +1,300 @@
+/*
+ * Copyright 2012-2026 Aerospike, Inc.
+ *
+ * Portions may be licensed to Aerospike, Inc. under one or more contributor
+ * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0.
+ *
+ * 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.aerospike.examples;
+
+import java.util.Arrays;
+
+import com.aerospike.client.Bin;
+import com.aerospike.client.IAerospikeClient;
+import com.aerospike.client.Key;
+import com.aerospike.client.Operation;
+import com.aerospike.client.Record;
+import com.aerospike.client.operation.StringNumericType;
+import com.aerospike.client.operation.StringOperation;
+import com.aerospike.client.operation.StringPolicy;
+import com.aerospike.client.operation.StringRegexFlags;
+
+public class OperateString extends Example {
+
+ private static final String BIN = "text";
+
+ public OperateString(Console console) {
+ super(console);
+ }
+
+ /**
+ * Demonstrate every {@link StringOperation} method.
+ * Requires server version 8.1.3 or later.
+ */
+ @Override
+ public void runExample(IAerospikeClient client, Parameters params) throws Exception {
+ runReadOps(client, params);
+ runModifyOps(client, params);
+ runToString(client, params);
+ }
+
+ /**
+ * Read-only string operations: return information about the bin without
+ * mutating it.
+ */
+ private void runReadOps(IAerospikeClient client, Parameters params) {
+ Key key = new Key(params.namespace, params.set, "opstr_read");
+ Record r;
+
+ // strlen — codepoint count
+ put(client, params, key, "hello world");
+ r = client.operate(params.writePolicy, key, StringOperation.strlen(BIN));
+ console.info("strlen(\"hello world\") = " + r.getLong(BIN));
+
+ // substr(start) — codepoint slice to end of string
+ r = client.operate(params.writePolicy, key, StringOperation.substr(BIN, 6));
+ console.info("substr(6) = \"" + r.getString(BIN) + "\"");
+
+ // substr(start, end) — half-open codepoint range
+ r = client.operate(params.writePolicy, key, StringOperation.substr(BIN, 0, 5));
+ console.info("substr(0, 5) = \"" + r.getString(BIN) + "\"");
+
+ // charAt — single-codepoint slice
+ r = client.operate(params.writePolicy, key, StringOperation.charAt(BIN, 6));
+ console.info("charAt(6) = \"" + r.getString(BIN) + "\"");
+
+ // find(needle) — index of first match, -1 if absent
+ r = client.operate(params.writePolicy, key, StringOperation.find(BIN, "world"));
+ console.info("find(\"world\") = " + r.getLong(BIN));
+
+ // find(needle, occurrence) — index of nth match
+ put(client, params, key, "ababab");
+ r = client.operate(params.writePolicy, key, StringOperation.find(BIN, "ab", 2));
+ console.info("find(\"ab\", occurrence=2) on \"ababab\" = " + r.getLong(BIN));
+
+ // contains
+ put(client, params, key, "hello world");
+ r = client.operate(params.writePolicy, key, StringOperation.contains(BIN, "hello"));
+ console.info("contains(\"hello\") = " + r.getBoolean(BIN));
+
+ // startsWith
+ r = client.operate(params.writePolicy, key, StringOperation.startsWith(BIN, "hello"));
+ console.info("startsWith(\"hello\") = " + r.getBoolean(BIN));
+
+ // endsWith
+ r = client.operate(params.writePolicy, key, StringOperation.endsWith(BIN, "world"));
+ console.info("endsWith(\"world\") = " + r.getBoolean(BIN));
+
+ // toInteger — parse string as int64
+ put(client, params, key, "12345");
+ r = client.operate(params.writePolicy, key, StringOperation.toInteger(BIN));
+ console.info("toInteger(\"12345\") = " + r.getLong(BIN));
+
+ // toDouble — parse string as float64
+ put(client, params, key, "3.14");
+ r = client.operate(params.writePolicy, key, StringOperation.toDouble(BIN));
+ console.info("toDouble(\"3.14\") = " + r.getDouble(BIN));
+
+ // byteLength — UTF-8 byte count (differs from strlen for non-ASCII)
+ put(client, params, key, "héllo");
+ r = client.operate(params.writePolicy, key, StringOperation.byteLength(BIN));
+ console.info("byteLength(\"héllo\") = " + r.getLong(BIN) + " (5 codepoints, 6 UTF-8 bytes)");
+
+ // isNumeric — accepts integer or float
+ put(client, params, key, "12345");
+ r = client.operate(params.writePolicy, key, StringOperation.isNumeric(BIN));
+ console.info("isNumeric(\"12345\") = " + r.getBoolean(BIN));
+
+ // isNumeric(numericType) — restrict by StringNumericType
+ put(client, params, key, "3.14");
+ r = client.operate(params.writePolicy, key,
+ StringOperation.isNumeric(BIN, StringNumericType.INT));
+ console.info("isNumeric(\"3.14\", INT) = " + r.getBoolean(BIN));
+
+ // isUpper
+ put(client, params, key, "HELLO");
+ r = client.operate(params.writePolicy, key, StringOperation.isUpper(BIN));
+ console.info("isUpper(\"HELLO\") = " + r.getBoolean(BIN));
+
+ // isLower
+ put(client, params, key, "hello");
+ r = client.operate(params.writePolicy, key, StringOperation.isLower(BIN));
+ console.info("isLower(\"hello\") = " + r.getBoolean(BIN));
+
+ // toBlob — UTF-8 bytes as byte[]
+ r = client.operate(params.writePolicy, key, StringOperation.toBlob(BIN));
+ console.info("toBlob(\"hello\") = " + Arrays.toString((byte[]) r.getValue(BIN)));
+
+ // split — one element per codepoint
+ put(client, params, key, "abc");
+ r = client.operate(params.writePolicy, key, StringOperation.split(BIN));
+ console.info("split() = " + r.getList(BIN));
+
+ // split(separator)
+ put(client, params, key, "one,two,three");
+ r = client.operate(params.writePolicy, key, StringOperation.split(BIN, ","));
+ console.info("split(\",\") = " + r.getList(BIN));
+
+ // b64Decode — decode base64 text to byte[]
+ put(client, params, key, "aGVsbG8=");
+ r = client.operate(params.writePolicy, key, StringOperation.b64Decode(BIN));
+ console.info("b64Decode(\"aGVsbG8=\") = \"" + new String((byte[]) r.getValue(BIN)) + "\"");
+
+ // regexCompare — ICU regex pattern match
+ put(client, params, key, "Hello123World");
+ r = client.operate(params.writePolicy, key, StringOperation.regexCompare(BIN, "[0-9]+"));
+ console.info("regexCompare(\"[0-9]+\") = " + r.getBoolean(BIN));
+
+ // regexCompare(flags) — case-insensitive match
+ put(client, params, key, "HELLO");
+ r = client.operate(params.writePolicy, key,
+ StringOperation.regexCompare(BIN, "hello", StringRegexFlags.CASE_INSENSITIVE));
+ console.info("regexCompare(\"hello\", CASE_INSENSITIVE) = " + r.getBoolean(BIN));
+ }
+
+ /**
+ * Modify operations: mutate the bin in place. Each call below performs the
+ * modify op then re-reads the bin to display the new value.
+ */
+ private void runModifyOps(IAerospikeClient client, Parameters params) {
+ Key key = new Key(params.namespace, params.set, "opstr_modify");
+ StringPolicy policy = StringPolicy.Default;
+
+ // insert — splice value at codepoint index
+ put(client, params, key, "hello world");
+ modifyAndShow(client, params, key, "insert(5, \" beautiful\")",
+ StringOperation.insert(policy, BIN, 5, " beautiful"));
+
+ // overwrite — replace codepoints starting at index
+ put(client, params, key, "hello world");
+ modifyAndShow(client, params, key, "overwrite(6, \"earth\")",
+ StringOperation.overwrite(policy, BIN, 6, "earth"));
+
+ // concat(value) — append a single string
+ put(client, params, key, "hello");
+ modifyAndShow(client, params, key, "concat(\"!\")",
+ StringOperation.concat(policy, BIN, "!"));
+
+ // concat(values) — append each list element in order
+ put(client, params, key, "hello");
+ modifyAndShow(client, params, key, "concat([\" \", \"big\", \" world\"])",
+ StringOperation.concat(policy, BIN, Arrays.asList(" ", "big", " world")));
+
+ // append — Unicode-aware end-append (alongside legacy Operation.append)
+ put(client, params, key, "hello");
+ modifyAndShow(client, params, key, "append(\"!\")",
+ StringOperation.append(policy, BIN, "!"));
+
+ // prepend — Unicode-aware front-insert (alongside legacy Operation.prepend)
+ put(client, params, key, "world");
+ modifyAndShow(client, params, key, "prepend(\"hello \")",
+ StringOperation.prepend(policy, BIN, "hello "));
+
+ // snip — remove half-open codepoint range
+ put(client, params, key, "hello beautiful world");
+ modifyAndShow(client, params, key, "snip(5, 15)",
+ StringOperation.snip(policy, BIN, 5, 15));
+
+ // replace — first occurrence only
+ put(client, params, key, "hello world world");
+ modifyAndShow(client, params, key, "replace(\"world\", \"earth\")",
+ StringOperation.replace(policy, BIN, "world", "earth"));
+
+ // replaceAll — every occurrence
+ put(client, params, key, "aabaa");
+ modifyAndShow(client, params, key, "replaceAll(\"a\", \"x\")",
+ StringOperation.replaceAll(policy, BIN, "a", "x"));
+
+ // upper
+ put(client, params, key, "hello world");
+ modifyAndShow(client, params, key, "upper()",
+ StringOperation.upper(policy, BIN));
+
+ // lower
+ put(client, params, key, "HELLO WORLD");
+ modifyAndShow(client, params, key, "lower()",
+ StringOperation.lower(policy, BIN));
+
+ // caseFold — locale-independent fold for comparison keys
+ put(client, params, key, "HELLO World");
+ modifyAndShow(client, params, key, "caseFold()",
+ StringOperation.caseFold(policy, BIN));
+
+ // normalizeNFC — Unicode NFC normalization
+ put(client, params, key, "café");
+ modifyAndShow(client, params, key, "normalizeNFC()",
+ StringOperation.normalizeNFC(policy, BIN));
+
+ // trimStart — drop leading whitespace
+ put(client, params, key, " hello ");
+ modifyAndShow(client, params, key, "trimStart()",
+ StringOperation.trimStart(policy, BIN));
+
+ // trimEnd — drop trailing whitespace
+ put(client, params, key, " hello ");
+ modifyAndShow(client, params, key, "trimEnd()",
+ StringOperation.trimEnd(policy, BIN));
+
+ // trim — drop both ends
+ put(client, params, key, " hello world ");
+ modifyAndShow(client, params, key, "trim()",
+ StringOperation.trim(policy, BIN));
+
+ // padStart — left-pad up to target codepoint length
+ put(client, params, key, "hello");
+ modifyAndShow(client, params, key, "padStart(10, \"*\")",
+ StringOperation.padStart(policy, BIN, 10, "*"));
+
+ // padEnd — right-pad up to target codepoint length
+ put(client, params, key, "hello");
+ modifyAndShow(client, params, key, "padEnd(10, \".\")",
+ StringOperation.padEnd(policy, BIN, 10, "."));
+
+ // repeat — repeat string n times
+ put(client, params, key, "ab");
+ modifyAndShow(client, params, key, "repeat(3)",
+ StringOperation.repeat(policy, BIN, 3));
+
+ // regexReplace — pass GLOBAL to replace every match (default replaces first only)
+ put(client, params, key, "abc123def456");
+ modifyAndShow(client, params, key, "regexReplace(\"[0-9]+\", \"NUM\", GLOBAL)",
+ StringOperation.regexReplace(policy, BIN, "[0-9]+", "NUM",
+ StringRegexFlags.GLOBAL));
+ }
+
+ /**
+ * toString — convert any int / float / string / blob bin to its string
+ * representation. Unlike the other ops, this does not accept a CTX.
+ */
+ private void runToString(IAerospikeClient client, Parameters params) {
+ Key key = new Key(params.namespace, params.set, "opstr_tostring");
+ String numBin = "n";
+
+ client.delete(params.writePolicy, key);
+ client.put(params.writePolicy, key, new Bin(numBin, 42));
+
+ Record r = client.operate(params.writePolicy, key, StringOperation.toString(numBin));
+ console.info("toString(int 42) = \"" + r.getString(numBin) + "\"");
+ }
+
+ private void put(IAerospikeClient client, Parameters params, Key key, String value) {
+ client.delete(params.writePolicy, key);
+ client.put(params.writePolicy, key, new Bin(BIN, value));
+ }
+
+ private void modifyAndShow(IAerospikeClient client, Parameters params, Key key,
+ String label, Operation modifyOp) {
+ client.operate(params.writePolicy, key, modifyOp);
+ String result = client.get(params.policy, key).getString(BIN);
+ console.info(label + " -> \"" + result + "\"");
+ }
+}
diff --git a/examples/src/com/aerospike/examples/StringExpression.java b/examples/src/com/aerospike/examples/StringExpression.java
new file mode 100644
index 000000000..b1d0dbb00
--- /dev/null
+++ b/examples/src/com/aerospike/examples/StringExpression.java
@@ -0,0 +1,322 @@
+/*
+ * Copyright 2012-2026 Aerospike, Inc.
+ *
+ * Portions may be licensed to Aerospike, Inc. under one or more contributor
+ * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0.
+ *
+ * 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.aerospike.examples;
+
+import java.util.Arrays;
+
+import com.aerospike.client.Bin;
+import com.aerospike.client.IAerospikeClient;
+import com.aerospike.client.Key;
+import com.aerospike.client.Record;
+import com.aerospike.client.exp.Exp;
+import com.aerospike.client.exp.ExpOperation;
+import com.aerospike.client.exp.ExpReadFlags;
+import com.aerospike.client.exp.Expression;
+import com.aerospike.client.exp.StringExp;
+import com.aerospike.client.operation.StringNumericType;
+import com.aerospike.client.operation.StringPolicy;
+import com.aerospike.client.operation.StringRegexFlags;
+
+public class StringExpression extends Example {
+
+ private static final String BIN = "text";
+ private static final String VAR = "result";
+
+ public StringExpression(Console console) {
+ super(console);
+ }
+
+ /**
+ * Demonstrate every {@link StringExp} expression builder. Each demo evaluates
+ * the expression with {@code ExpOperation.read} and prints the result.
+ * Modify-style expressions (upper, replace, …) return the modified string
+ * value; the bin is not mutated.
+ * Requires server version 8.1.3 or later.
+ */
+ @Override
+ public void runExample(IAerospikeClient client, Parameters params) throws Exception {
+ runReadExps(client, params);
+ runModifyExps(client, params);
+ runToString(client, params);
+ }
+
+ /**
+ * Read-only string expressions.
+ */
+ private void runReadExps(IAerospikeClient client, Parameters params) {
+ Key key = new Key(params.namespace, params.set, "stringexp_read");
+ Record r;
+
+ // strlen — codepoint count
+ put(client, params, key, "hello world");
+ r = evalExp(client, params, key, StringExp.strlen(Exp.stringBin(BIN)));
+ console.info("strlen(\"hello world\") = " + r.getLong(VAR));
+
+ // substr(start) — codepoint slice to end of string
+ r = evalExp(client, params, key, StringExp.substr(Exp.val(6), Exp.stringBin(BIN)));
+ console.info("substr(6) = \"" + r.getString(VAR) + "\"");
+
+ // substr(start, end) — half-open codepoint range
+ r = evalExp(client, params, key,
+ StringExp.substr(Exp.val(0), Exp.val(5), Exp.stringBin(BIN)));
+ console.info("substr(0, 5) = \"" + r.getString(VAR) + "\"");
+
+ // charAt — single-codepoint slice
+ r = evalExp(client, params, key, StringExp.charAt(Exp.val(6), Exp.stringBin(BIN)));
+ console.info("charAt(6) = \"" + r.getString(VAR) + "\"");
+
+ // find(needle) — index of first match, -1 if absent
+ r = evalExp(client, params, key, StringExp.find(Exp.val("world"), Exp.stringBin(BIN)));
+ console.info("find(\"world\") = " + r.getLong(VAR));
+
+ // find(needle, occurrence) — index of nth match
+ put(client, params, key, "ababab");
+ r = evalExp(client, params, key,
+ StringExp.find(Exp.val("ab"), Exp.val(2), Exp.stringBin(BIN)));
+ console.info("find(\"ab\", occurrence=2) on \"ababab\" = " + r.getLong(VAR));
+
+ // contains
+ put(client, params, key, "hello world");
+ r = evalExp(client, params, key,
+ StringExp.contains(Exp.val("hello"), Exp.stringBin(BIN)));
+ console.info("contains(\"hello\") = " + r.getBoolean(VAR));
+
+ // startsWith
+ r = evalExp(client, params, key,
+ StringExp.startsWith(Exp.val("hello"), Exp.stringBin(BIN)));
+ console.info("startsWith(\"hello\") = " + r.getBoolean(VAR));
+
+ // endsWith
+ r = evalExp(client, params, key,
+ StringExp.endsWith(Exp.val("world"), Exp.stringBin(BIN)));
+ console.info("endsWith(\"world\") = " + r.getBoolean(VAR));
+
+ // toInteger — parse string as int64
+ put(client, params, key, "12345");
+ r = evalExp(client, params, key, StringExp.toInteger(Exp.stringBin(BIN)));
+ console.info("toInteger(\"12345\") = " + r.getLong(VAR));
+
+ // toDouble — parse string as float64
+ put(client, params, key, "3.14");
+ r = evalExp(client, params, key, StringExp.toDouble(Exp.stringBin(BIN)));
+ console.info("toDouble(\"3.14\") = " + r.getDouble(VAR));
+
+ // byteLength — UTF-8 byte count (differs from strlen for non-ASCII)
+ put(client, params, key, "héllo");
+ r = evalExp(client, params, key, StringExp.byteLength(Exp.stringBin(BIN)));
+ console.info("byteLength(\"héllo\") = " + r.getLong(VAR) + " (5 codepoints, 6 UTF-8 bytes)");
+
+ // isNumeric — accepts integer or float
+ put(client, params, key, "12345");
+ r = evalExp(client, params, key, StringExp.isNumeric(Exp.stringBin(BIN)));
+ console.info("isNumeric(\"12345\") = " + r.getBoolean(VAR));
+
+ // isNumeric(numericType) — restrict by StringNumericType
+ put(client, params, key, "3.14");
+ r = evalExp(client, params, key,
+ StringExp.isNumeric(StringNumericType.INT, Exp.stringBin(BIN)));
+ console.info("isNumeric(\"3.14\", INT) = " + r.getBoolean(VAR));
+
+ // isUpper
+ put(client, params, key, "HELLO");
+ r = evalExp(client, params, key, StringExp.isUpper(Exp.stringBin(BIN)));
+ console.info("isUpper(\"HELLO\") = " + r.getBoolean(VAR));
+
+ // isLower
+ put(client, params, key, "hello");
+ r = evalExp(client, params, key, StringExp.isLower(Exp.stringBin(BIN)));
+ console.info("isLower(\"hello\") = " + r.getBoolean(VAR));
+
+ // toBlob — UTF-8 bytes as byte[]
+ r = evalExp(client, params, key, StringExp.toBlob(Exp.stringBin(BIN)));
+ console.info("toBlob(\"hello\") = " + Arrays.toString((byte[]) r.getValue(VAR)));
+
+ // split — one element per codepoint
+ put(client, params, key, "abc");
+ r = evalExp(client, params, key, StringExp.split(Exp.stringBin(BIN)));
+ console.info("split() = " + r.getList(VAR));
+
+ // split(separator)
+ put(client, params, key, "one,two,three");
+ r = evalExp(client, params, key,
+ StringExp.split(Exp.val(","), Exp.stringBin(BIN)));
+ console.info("split(\",\") = " + r.getList(VAR));
+
+ // b64Decode — decode base64 text to byte[]
+ put(client, params, key, "aGVsbG8=");
+ r = evalExp(client, params, key, StringExp.b64Decode(Exp.stringBin(BIN)));
+ console.info("b64Decode(\"aGVsbG8=\") = \"" + new String((byte[]) r.getValue(VAR)) + "\"");
+
+ // regexCompare — ICU regex pattern match
+ put(client, params, key, "Hello123World");
+ r = evalExp(client, params, key,
+ StringExp.regexCompare(Exp.val("[0-9]+"), Exp.stringBin(BIN)));
+ console.info("regexCompare(\"[0-9]+\") = " + r.getBoolean(VAR));
+
+ // regexCompare(flags) — case-insensitive match
+ put(client, params, key, "HELLO");
+ r = evalExp(client, params, key,
+ StringExp.regexCompare(Exp.val("hello"),
+ StringRegexFlags.CASE_INSENSITIVE, Exp.stringBin(BIN)));
+ console.info("regexCompare(\"hello\", CASE_INSENSITIVE) = " + r.getBoolean(VAR));
+ }
+
+ /**
+ * Modify-style expressions: each returns the transformed string value.
+ * The underlying bin is not mutated.
+ */
+ private void runModifyExps(IAerospikeClient client, Parameters params) {
+ Key key = new Key(params.namespace, params.set, "stringexp_modify");
+ StringPolicy policy = StringPolicy.Default;
+ Record r;
+
+ // insert — splice value at codepoint index
+ put(client, params, key, "hello world");
+ r = evalExp(client, params, key,
+ StringExp.insert(policy, Exp.val(5), Exp.val(" beautiful"), Exp.stringBin(BIN)));
+ console.info("insert(5, \" beautiful\") -> \"" + r.getString(VAR) + "\"");
+
+ // overwrite — replace codepoints starting at index
+ r = evalExp(client, params, key,
+ StringExp.overwrite(policy, Exp.val(6), Exp.val("earth"), Exp.stringBin(BIN)));
+ console.info("overwrite(6, \"earth\") -> \"" + r.getString(VAR) + "\"");
+
+ // concat — append a list of strings in order
+ put(client, params, key, "hello");
+ r = evalExp(client, params, key,
+ StringExp.concat(policy,
+ Exp.val(Arrays.asList(" ", "big", " world")),
+ Exp.stringBin(BIN)));
+ console.info("concat([\" \", \"big\", \" world\"]) -> \"" + r.getString(VAR) + "\"");
+
+ // append — Unicode-aware end-append
+ r = evalExp(client, params, key,
+ StringExp.append(policy, Exp.val("!"), Exp.stringBin(BIN)));
+ console.info("append(\"!\") -> \"" + r.getString(VAR) + "\"");
+
+ // prepend — Unicode-aware front-insert
+ put(client, params, key, "world");
+ r = evalExp(client, params, key,
+ StringExp.prepend(policy, Exp.val("hello "), Exp.stringBin(BIN)));
+ console.info("prepend(\"hello \") -> \"" + r.getString(VAR) + "\"");
+
+ // snip — remove half-open codepoint range
+ put(client, params, key, "hello beautiful world");
+ r = evalExp(client, params, key,
+ StringExp.snip(policy, Exp.val(5), Exp.val(15), Exp.stringBin(BIN)));
+ console.info("snip(5, 15) -> \"" + r.getString(VAR) + "\"");
+
+ // replace — first occurrence only
+ put(client, params, key, "hello world world");
+ r = evalExp(client, params, key,
+ StringExp.replace(policy, Exp.val("world"), Exp.val("earth"), Exp.stringBin(BIN)));
+ console.info("replace(\"world\", \"earth\") -> \"" + r.getString(VAR) + "\"");
+
+ // replaceAll — every occurrence
+ put(client, params, key, "aabaa");
+ r = evalExp(client, params, key,
+ StringExp.replaceAll(policy, Exp.val("a"), Exp.val("x"), Exp.stringBin(BIN)));
+ console.info("replaceAll(\"a\", \"x\") -> \"" + r.getString(VAR) + "\"");
+
+ // upper
+ put(client, params, key, "hello world");
+ r = evalExp(client, params, key, StringExp.upper(policy, Exp.stringBin(BIN)));
+ console.info("upper() -> \"" + r.getString(VAR) + "\"");
+
+ // lower
+ put(client, params, key, "HELLO WORLD");
+ r = evalExp(client, params, key, StringExp.lower(policy, Exp.stringBin(BIN)));
+ console.info("lower() -> \"" + r.getString(VAR) + "\"");
+
+ // caseFold — locale-independent fold for comparison keys
+ put(client, params, key, "HELLO World");
+ r = evalExp(client, params, key, StringExp.caseFold(policy, Exp.stringBin(BIN)));
+ console.info("caseFold() -> \"" + r.getString(VAR) + "\"");
+
+ // normalizeNFC — Unicode NFC normalization
+ put(client, params, key, "café");
+ r = evalExp(client, params, key, StringExp.normalizeNFC(policy, Exp.stringBin(BIN)));
+ console.info("normalizeNFC() -> \"" + r.getString(VAR) + "\"");
+
+ // trimStart — drop leading whitespace
+ put(client, params, key, " hello ");
+ r = evalExp(client, params, key, StringExp.trimStart(policy, Exp.stringBin(BIN)));
+ console.info("trimStart() -> \"" + r.getString(VAR) + "\"");
+
+ // trimEnd — drop trailing whitespace
+ r = evalExp(client, params, key, StringExp.trimEnd(policy, Exp.stringBin(BIN)));
+ console.info("trimEnd() -> \"" + r.getString(VAR) + "\"");
+
+ // trim — drop both ends
+ put(client, params, key, " hello world ");
+ r = evalExp(client, params, key, StringExp.trim(policy, Exp.stringBin(BIN)));
+ console.info("trim() -> \"" + r.getString(VAR) + "\"");
+
+ // padStart — left-pad up to target codepoint length
+ put(client, params, key, "hello");
+ r = evalExp(client, params, key,
+ StringExp.padStart(policy, Exp.val(10), Exp.val("*"), Exp.stringBin(BIN)));
+ console.info("padStart(10, \"*\") -> \"" + r.getString(VAR) + "\"");
+
+ // padEnd — right-pad up to target codepoint length
+ r = evalExp(client, params, key,
+ StringExp.padEnd(policy, Exp.val(10), Exp.val("."), Exp.stringBin(BIN)));
+ console.info("padEnd(10, \".\") -> \"" + r.getString(VAR) + "\"");
+
+ // repeat — repeat string n times
+ put(client, params, key, "ab");
+ r = evalExp(client, params, key,
+ StringExp.repeat(policy, Exp.val(3), Exp.stringBin(BIN)));
+ console.info("repeat(3) -> \"" + r.getString(VAR) + "\"");
+
+ // regexReplace — pass GLOBAL to replace every match (default replaces first only)
+ put(client, params, key, "abc123def456");
+ r = evalExp(client, params, key,
+ StringExp.regexReplace(policy,
+ Exp.val("[0-9]+"), Exp.val("NUM"),
+ StringRegexFlags.GLOBAL, Exp.stringBin(BIN)));
+ console.info("regexReplace(\"[0-9]+\", \"NUM\", GLOBAL) -> \"" + r.getString(VAR) + "\"");
+ }
+
+ /**
+ * toString — stringify any int / float / string / blob source. Demonstrated
+ * on an integer bin.
+ */
+ private void runToString(IAerospikeClient client, Parameters params) {
+ Key key = new Key(params.namespace, params.set, "stringexp_tostring");
+ String numBin = "n";
+
+ client.delete(params.writePolicy, key);
+ client.put(params.writePolicy, key, new Bin(numBin, 42));
+
+ Expression exp = Exp.build(StringExp.toString(Exp.intBin(numBin)));
+ Record r = client.operate(params.writePolicy, key,
+ ExpOperation.read(VAR, exp, ExpReadFlags.DEFAULT));
+ console.info("toString(intBin(\"n\") = 42) -> \"" + r.getString(VAR) + "\"");
+ }
+
+ private void put(IAerospikeClient client, Parameters params, Key key, String value) {
+ client.delete(params.writePolicy, key);
+ client.put(params.writePolicy, key, new Bin(BIN, value));
+ }
+
+ private Record evalExp(IAerospikeClient client, Parameters params, Key key, Exp e) {
+ Expression exp = Exp.build(e);
+ return client.operate(params.writePolicy, key,
+ ExpOperation.read(VAR, exp, ExpReadFlags.DEFAULT));
+ }
+}
diff --git a/test/src/com/aerospike/test/SuiteSync.java b/test/src/com/aerospike/test/SuiteSync.java
index c58a563a5..5ff2cd231 100644
--- a/test/src/com/aerospike/test/SuiteSync.java
+++ b/test/src/com/aerospike/test/SuiteSync.java
@@ -47,10 +47,14 @@
import com.aerospike.test.sync.basic.TestOperateHll;
import com.aerospike.test.sync.basic.TestOperateList;
import com.aerospike.test.sync.basic.TestOperateMap;
+import com.aerospike.test.sync.basic.TestOperateString;
import com.aerospike.test.sync.basic.TestPutGet;
import com.aerospike.test.sync.basic.TestReplace;
import com.aerospike.test.sync.basic.TestScan;
import com.aerospike.test.sync.basic.TestServerInfo;
+import com.aerospike.test.sync.basic.TestStringExp;
+import com.aerospike.test.sync.basic.TestStringInvalidUtf8;
+import com.aerospike.test.sync.basic.TestStringMasking;
import com.aerospike.test.sync.basic.TestTouch;
import com.aerospike.test.sync.basic.TestTxn;
import com.aerospike.test.sync.basic.TestUDF;
@@ -96,10 +100,14 @@
TestOperateHll.class,
TestOperateList.class,
TestOperateMap.class,
+ TestOperateString.class,
TestPutGet.class,
TestReplace.class,
TestScan.class,
TestServerInfo.class,
+ TestStringExp.class,
+ TestStringInvalidUtf8.class,
+ TestStringMasking.class,
TestTouch.class,
TestTxn.class,
TestUDF.class,
diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java
new file mode 100644
index 000000000..ecb82051f
--- /dev/null
+++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java
@@ -0,0 +1,1159 @@
+/*
+ * Copyright 2012-2026 Aerospike, Inc.
+ *
+ * Portions may be licensed to Aerospike, Inc. under one or more contributor
+ * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0.
+ *
+ * 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.aerospike.test.sync.basic;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Assume;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import com.aerospike.client.AerospikeException;
+import com.aerospike.client.Bin;
+import com.aerospike.client.Key;
+import com.aerospike.client.Operation;
+import com.aerospike.client.Record;
+import com.aerospike.client.ResultCode;
+import com.aerospike.client.Value;
+import com.aerospike.client.cdt.CTX;
+import com.aerospike.client.operation.StringOperation;
+import com.aerospike.client.operation.StringPolicy;
+import com.aerospike.client.operation.StringRegexFlags;
+import com.aerospike.client.operation.StringWriteFlags;
+import com.aerospike.test.sync.TestSync;
+
+/**
+ * Integration tests for the string operations exposed by {@link StringOperation}.
+ *
+ * The tests are organized around the operation behavior they verify rather + * than around individual API methods, so each test exercises a single intent + * (e.g. "uppercase mutates the bin", "find returns the first match index"). + * + *
String operations require server version 8.1.3+; the tests are skipped
+ * on older clusters via {@link Assume}.
+ */
+public class TestOperateString extends TestSync {
+ private static final String BIN = "sbin";
+ private static final Key KEY = new Key(args.namespace, args.set, "stringop-key");
+ private static final StringPolicy POLICY = StringPolicy.Default;
+
+ @BeforeClass
+ public static void serverVersionCheck() {
+ Assume.assumeTrue(
+ "Skipping: string operations require server version 8.1.3 or later",
+ args.serverVersion.isGreaterOrEqual(8, 1, 3, 0));
+ }
+
+ //-----------------------------------------------------------------
+ // Helpers
+ //-----------------------------------------------------------------
+
+ private static void put(String value) {
+ client.delete(null, KEY);
+ client.put(null, KEY, new Bin(BIN, value));
+ }
+
+ private static void put(Bin... bins) {
+ client.delete(null, KEY);
+ client.put(null, KEY, bins);
+ }
+
+ private static Record operate(Operation... ops) {
+ return client.operate(null, KEY, ops);
+ }
+
+ private static String stringValue() {
+ return client.get(null, KEY).getString(BIN);
+ }
+
+ //=================================================================
+ // Read operations
+ //=================================================================
+
+ @Test
+ public void strlenReturnsCodepointCount() {
+ put("hello world");
+ Record r = operate(StringOperation.strlen(BIN));
+ assertEquals(11L, r.getLong(BIN));
+ }
+
+ @Test
+ public void strlenOnEmptyStringIsZero() {
+ put("");
+ Record r = operate(StringOperation.strlen(BIN));
+ assertEquals(0L, r.getLong(BIN));
+ }
+
+ @Test
+ public void byteLengthReturnsUtf8Bytes() {
+ put("hello");
+ Record r = operate(StringOperation.byteLength(BIN));
+ assertEquals(5L, r.getLong(BIN));
+ }
+
+ //-----------------------------------------------------------------
+ // Multi-byte / codepoint-vs-byte tests
+ //
+ // Server-side indices and strlen are in Unicode code points, not bytes
+ // and not Java UTF-16 chars. These tests anchor the contract for Java
+ // callers whose String.length() intuition is UTF-16 code-unit count.
+ //-----------------------------------------------------------------
+
+ @Test
+ public void strlenCountsCodepointsNotJavaChars() {
+ // "café" = 4 codepoints; UTF-8 = 5 bytes; Java .length() = 4.
+ put("café");
+ assertEquals(4L, operate(StringOperation.strlen(BIN)).getLong(BIN));
+
+ // "日本語" = 3 codepoints; UTF-8 = 9 bytes; Java .length() = 3.
+ put("日本語");
+ assertEquals(3L, operate(StringOperation.strlen(BIN)).getLong(BIN));
+
+ // "👋hi" — emoji is U+1F44B, a supplementary codepoint encoded as
+ // a UTF-16 surrogate pair in Java. Codepoints = 3; Java .length() = 4.
+ put("👋hi");
+ assertEquals(3L, operate(StringOperation.strlen(BIN)).getLong(BIN));
+ }
+
+ @Test
+ public void byteLengthCountsBytesNotCodepoints() {
+ put("café");
+ assertEquals(5L, operate(StringOperation.byteLength(BIN)).getLong(BIN));
+ put("日本語");
+ assertEquals(9L, operate(StringOperation.byteLength(BIN)).getLong(BIN));
+ put("👋hi");
+ // 👋 = 4 UTF-8 bytes, "hi" = 2 bytes.
+ assertEquals(6L, operate(StringOperation.byteLength(BIN)).getLong(BIN));
+ }
+
+ @Test
+ public void substrIndexesCodepointsNotBytes() {
+ // "日本語hi" — substr(start=3, end=5) returns codepoints 3..4 = "hi".
+ // A byte-indexed substr would land mid-way through "日" (each CJK char
+ // occupies 3 UTF-8 bytes).
+ put("日本語hi");
+ Record r = operate(StringOperation.substr(BIN, 3, 5));
+ assertEquals("hi", r.getString(BIN));
+ }
+
+ @Test
+ public void charAtReturnsWholeCodepoint() {
+ // charAt at the emoji position should return the full 4-byte codepoint,
+ // not a half-surrogate.
+ put("a👋b");
+ Record r = operate(StringOperation.charAt(BIN, 1));
+ assertEquals("👋", r.getString(BIN));
+ }
+
+ @Test
+ public void findReturnsCodepointIndex() {
+ // "café-world": "world" starts at codepoint 5 (UTF-16 .indexOf would
+ // also return 5 here because "é" is a single Java char, but the contract
+ // is codepoint-indexed).
+ put("café-world");
+ assertEquals(5L, operate(StringOperation.find(BIN, "world")).getLong(BIN));
+
+ // "👋-world": "world" starts at codepoint index 2 (after emoji and dash).
+ // Java's .indexOf would return 3 (UTF-16 code-unit index), so this
+ // test catches a regression that returned UTF-16 indices.
+ put("👋-world");
+ assertEquals(2L, operate(StringOperation.find(BIN, "world")).getLong(BIN));
+ }
+
+ @Test
+ public void findAndContainsRequireMatchingNormalizationForm() {
+ // "café" can be stored as NFC (U+00E9, 1 codepoint, 2 UTF-8 bytes) or NFD
+ // (U+0065 U+0301, 2 codepoints, 3 UTF-8 bytes). They render identically but
+ // are distinct byte sequences. The server's find / contains uses ICU binary
+ // string search — NFC and NFD are NOT considered equal. Callers who need
+ // normalization-insensitive search must normalizeNFC the bin (and the needle)
+ // first. This test anchors the contract so a future change to ICU comparison
+ // mode does not silently flip the behavior.
+ final String NFC = "caf\u00E9"; // "café" composed
+ final String NFD = "cafe\u0301"; // "café" decomposed
+
+ put(NFC);
+ // NFC haystack vs NFC needle — match.
+ assertEquals(0L, operate(StringOperation.find(BIN, NFC)).getLong(BIN));
+ assertTrue(operate(StringOperation.contains(BIN, NFC)).getBoolean(BIN));
+ // NFC haystack vs NFD needle — no match (byte sequences differ).
+ assertEquals(-1L, operate(StringOperation.find(BIN, NFD)).getLong(BIN));
+ assertFalse(operate(StringOperation.contains(BIN, NFD)).getBoolean(BIN));
+ }
+
+ @Test
+ public void substrFromOffsetToEnd() {
+ put("hello world");
+ Record r = operate(StringOperation.substr(BIN, 6));
+ assertEquals("world", r.getString(BIN));
+ }
+
+ @Test
+ public void substrSlicesARange() {
+ put("hello world");
+ Record r = operate(StringOperation.substr(BIN, 0, 5));
+ assertEquals("hello", r.getString(BIN));
+ }
+
+ @Test
+ public void substrSupportsNegativeStart() {
+ put("hello world");
+ Record r = operate(StringOperation.substr(BIN, -5));
+ assertEquals("world", r.getString(BIN));
+ }
+
+ @Test
+ public void charAtReturnsSingleCharacter() {
+ put("Hello123World");
+ Record r = operate(StringOperation.charAt(BIN, 5));
+ assertEquals("1", r.getString(BIN));
+ }
+
+ @Test
+ public void findReturnsIndexOfFirstMatch() {
+ put("hello world");
+ Record r = operate(StringOperation.find(BIN, "world"));
+ assertEquals(6L, r.getLong(BIN));
+ }
+
+ @Test
+ public void findReturnsMinusOneWhenAbsent() {
+ put("hello world");
+ Record r = operate(StringOperation.find(BIN, "xyz"));
+ assertEquals(-1L, r.getLong(BIN));
+ }
+
+ @Test
+ public void findSkipsOverlappingMatchesAscii() {
+ // "aa" is a self-overlapping needle (prefix "a" == suffix "a"). After
+ // matching at index 0 the search resumes *after* the match (index 2),
+ // so the 2nd occurrence is at 2 — not 1. This matches replace() and
+ // the ICU usearch path used for non-ASCII haystacks.
+ put("aaaa");
+ assertEquals(0L, operate(StringOperation.find(BIN, "aa", 1)).getLong(BIN));
+ assertEquals(2L, operate(StringOperation.find(BIN, "aa", 2)).getLong(BIN));
+ assertEquals(-1L, operate(StringOperation.find(BIN, "aa", 3)).getLong(BIN));
+ }
+
+ @Test
+ public void findSkipsOverlappingMatchesUnicode() {
+ // Same overlap-skip rule on the ICU path. "👋👋" is self-overlapping in
+ // codepoints; matches land at codepoint indices 0 and 2, not 0 and 1.
+ put("👋👋👋👋");
+ assertEquals(0L, operate(StringOperation.find(BIN, "👋👋", 1)).getLong(BIN));
+ assertEquals(2L, operate(StringOperation.find(BIN, "👋👋", 2)).getLong(BIN));
+ assertEquals(-1L, operate(StringOperation.find(BIN, "👋👋", 3)).getLong(BIN));
+ }
+
+ @Test
+ public void containsReturnsBoolean() {
+ put("hello world");
+ Record present = operate(StringOperation.contains(BIN, "hello"));
+ Record absent = operate(StringOperation.contains(BIN, "xyz"));
+ assertTrue(present.getBoolean(BIN));
+ assertFalse(absent.getBoolean(BIN));
+ }
+
+ @Test
+ public void startsWithMatchesPrefix() {
+ put("Hello123World");
+ assertTrue(operate(StringOperation.startsWith(BIN, "Hello")).getBoolean(BIN));
+ assertFalse(operate(StringOperation.startsWith(BIN, "World")).getBoolean(BIN));
+ }
+
+ @Test
+ public void endsWithMatchesSuffix() {
+ put("Hello123World");
+ assertTrue(operate(StringOperation.endsWith(BIN, "World")).getBoolean(BIN));
+ assertFalse(operate(StringOperation.endsWith(BIN, "Hello")).getBoolean(BIN));
+ }
+
+ @Test
+ public void isUpperOnlyTrueForUppercase() {
+ put("HELLO");
+ assertTrue(operate(StringOperation.isUpper(BIN)).getBoolean(BIN));
+ put("hello");
+ assertFalse(operate(StringOperation.isUpper(BIN)).getBoolean(BIN));
+ }
+
+ @Test
+ public void isLowerOnlyTrueForLowercase() {
+ put("hello");
+ assertTrue(operate(StringOperation.isLower(BIN)).getBoolean(BIN));
+ put("HELLO");
+ assertFalse(operate(StringOperation.isLower(BIN)).getBoolean(BIN));
+ }
+
+ @Test
+ public void isNumericMatchesIntegerStrings() {
+ put("12345");
+ assertTrue(operate(StringOperation.isNumeric(BIN)).getBoolean(BIN));
+ put("Hello123World");
+ assertFalse(operate(StringOperation.isNumeric(BIN)).getBoolean(BIN));
+ }
+
+ @Test
+ public void toIntegerParsesDigitsAsLong() {
+ put("12345");
+ Record r = operate(StringOperation.toInteger(BIN));
+ assertEquals(12345L, r.getLong(BIN));
+ }
+
+ @Test
+ public void toDoubleParsesDecimalNumbers() {
+ put("3.14");
+ Record r = operate(StringOperation.toDouble(BIN));
+ assertEquals(3.14, r.getDouble(BIN), 0.001);
+ }
+
+ @Test
+ public void splitReturnsListOfTokens() {
+ put("one,two,three");
+ Record r = operate(StringOperation.split(BIN, ","));
+ assertEquals(Arrays.asList("one", "two", "three"), r.getList(BIN));
+ }
+
+ @Test
+ public void splitWithoutMatchReturnsSingletonList() {
+ put("Hello123World");
+ Record r = operate(StringOperation.split(BIN, "|"));
+ assertEquals(Arrays.asList("Hello123World"), r.getList(BIN));
+ }
+
+ @Test
+ public void regexCompareDistinguishesMatchVsMiss() {
+ put("Hello123World");
+ assertTrue(operate(StringOperation.regexCompare(BIN, "[0-9]+")).getBoolean(BIN));
+ put("HELLO");
+ assertFalse(operate(StringOperation.regexCompare(BIN, "[0-9]+")).getBoolean(BIN));
+ }
+
+ @Test
+ public void regexCompareHonorsCaseInsensitiveFlag() {
+ put("HELLO");
+ assertTrue(operate(StringOperation.regexCompare(
+ BIN, "hello", StringRegexFlags.CASE_INSENSITIVE)).getBoolean(BIN));
+ }
+
+ @Test
+ public void toBlobReturnsUtf8Bytes() {
+ put("hello");
+ Record r = operate(StringOperation.toBlob(BIN));
+ assertArrayEquals("hello".getBytes(), (byte[])r.getValue(BIN));
+ }
+
+ @Test
+ public void b64DecodeReturnsOriginalBlob() {
+ put("aGVsbG8=");
+ Record r = operate(StringOperation.b64Decode(BIN));
+ assertArrayEquals("hello".getBytes(), (byte[])r.getValue(BIN));
+ }
+
+ //=================================================================
+ // Modify operations
+ //=================================================================
+
+ @Test
+ public void upperMutatesBinInPlace() {
+ put("hello world");
+ operate(StringOperation.upper(POLICY, BIN));
+ assertEquals("HELLO WORLD", stringValue());
+ }
+
+ @Test
+ public void lowerMutatesBinInPlace() {
+ put("HELLO WORLD");
+ operate(StringOperation.lower(POLICY, BIN));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void caseFoldLowercasesIndependentlyOfLocale() {
+ put("HELLO World");
+ operate(StringOperation.caseFold(POLICY, BIN));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void normalizeNFCLeavesAlreadyNormalizedStringUnchanged() {
+ put("hello");
+ operate(StringOperation.normalizeNFC(POLICY, BIN));
+ assertEquals("hello", stringValue());
+ }
+
+ @Test
+ public void normalizeNFCComposesDecomposedSequence() {
+ // "e\u0301" is the NFD ("decomposed") form of "é": Latin small "e"
+ // followed by combining acute accent. normalizeNFC must compose it to
+ // U+00E9 (NFC, single codepoint) — proving the op actually transforms
+ // non-normalized input, not just the no-op case.
+ put("e\u0301");
+ operate(StringOperation.normalizeNFC(POLICY, BIN));
+ assertEquals("\u00E9", stringValue());
+ // Composed form is 1 codepoint; the decomposed input would be 2.
+ assertEquals(1L, operate(StringOperation.strlen(BIN)).getLong(BIN));
+ }
+
+ @Test
+ public void insertAtMiddleSplicesValue() {
+ put("hello world");
+ operate(StringOperation.insert(POLICY, BIN, 5, " beautiful"));
+ assertEquals("hello beautiful world", stringValue());
+ }
+
+ @Test
+ public void insertAtZeroPrependsValue() {
+ put("world");
+ operate(StringOperation.insert(POLICY, BIN, 0, "hello "));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void insertAtEndAppendsValue() {
+ put("hello");
+ operate(StringOperation.insert(POLICY, BIN, 5, " world"));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void insertWithNegativeIndexCountsFromEnd() {
+ put("hello world");
+ operate(StringOperation.insert(POLICY, BIN, -5, "big "));
+ assertEquals("hello big world", stringValue());
+ }
+
+ @Test
+ public void overwriteReplacesCharactersStartingAtIndex() {
+ put("hello world");
+ operate(StringOperation.overwrite(POLICY, BIN, 6, "earth"));
+ assertEquals("hello earth", stringValue());
+ }
+
+ @Test
+ public void overwriteAtZeroReplacesPrefix() {
+ put("hello world");
+ operate(StringOperation.overwrite(POLICY, BIN, 0, "HELLO"));
+ assertEquals("HELLO world", stringValue());
+ }
+
+ @Test
+ public void overwriteCanExtendBeyondOriginalLength() {
+ put("hello");
+ operate(StringOperation.overwrite(POLICY, BIN, 3, "ping!"));
+ assertEquals("helping!", stringValue());
+ }
+
+ @Test
+ public void snipRemovesCharacterRange() {
+ put("hello beautiful world");
+ operate(StringOperation.snip(POLICY, BIN, 5, 15));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void snipFromStartTrimsPrefix() {
+ put("hello world");
+ operate(StringOperation.snip(POLICY, BIN, 0, 6));
+ assertEquals("world", stringValue());
+ }
+
+ @Test
+ public void snipToEndTrimsSuffix() {
+ put("hello world");
+ operate(StringOperation.snip(POLICY, BIN, 5, 11));
+ assertEquals("hello", stringValue());
+ }
+
+ @Test
+ public void replaceTouchesOnlyFirstOccurrence() {
+ put("hello world world");
+ operate(StringOperation.replace(POLICY, BIN, "world", "earth"));
+ assertEquals("hello earth world", stringValue());
+ }
+
+ @Test
+ public void replaceWithNoMatchLeavesBinUnchanged() {
+ put("hello world");
+ operate(StringOperation.replace(POLICY, BIN, "xyz", "abc"));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void replaceCanGrowTheString() {
+ put("hi world");
+ operate(StringOperation.replace(POLICY, BIN, "hi", "hello"));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void replaceWithEmptyDeletesMatch() {
+ put("hello world");
+ operate(StringOperation.replace(POLICY, BIN, " world", ""));
+ assertEquals("hello", stringValue());
+ }
+
+ @Test
+ public void replaceAllSubstitutesEveryMatch() {
+ put("aabaa");
+ operate(StringOperation.replaceAll(POLICY, BIN, "a", "x"));
+ assertEquals("xxbxx", stringValue());
+ }
+
+ @Test
+ public void replaceAllWithNoMatchLeavesBinUnchanged() {
+ put("hello");
+ operate(StringOperation.replaceAll(POLICY, BIN, "z", "!"));
+ assertEquals("hello", stringValue());
+ }
+
+ @Test
+ public void replaceAllSkipsOverlappingMatches() {
+ // Self-overlapping needle "aa" in "aaaa": replacement resumes after each
+ // match, yielding "XX" — not "XaX" (which would require allowing the
+ // 2nd match to start at index 1). Anchors the contract that find() now
+ // mirrors.
+ put("aaaa");
+ operate(StringOperation.replaceAll(POLICY, BIN, "aa", "X"));
+ assertEquals("XX", stringValue());
+ }
+
+ @Test
+ public void trimRemovesWhitespaceOnBothEnds() {
+ put(" hello world ");
+ operate(StringOperation.trim(POLICY, BIN));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void trimOnCleanStringIsNoOp() {
+ put("hello");
+ operate(StringOperation.trim(POLICY, BIN));
+ assertEquals("hello", stringValue());
+ }
+
+ @Test
+ public void trimStartRemovesLeadingWhitespaceOnly() {
+ put(" hello ");
+ operate(StringOperation.trimStart(POLICY, BIN));
+ assertEquals("hello ", stringValue());
+ }
+
+ @Test
+ public void trimEndRemovesTrailingWhitespaceOnly() {
+ put(" hello ");
+ operate(StringOperation.trimEnd(POLICY, BIN));
+ assertEquals(" hello", stringValue());
+ }
+
+ @Test
+ public void padStartFillsLeftToTargetLength() {
+ put("hello");
+ operate(StringOperation.padStart(POLICY, BIN, 10, "*"));
+ assertEquals("*****hello", stringValue());
+ }
+
+ @Test
+ public void padStartIsNoOpWhenAlreadyLongEnough() {
+ put("hello world");
+ operate(StringOperation.padStart(POLICY, BIN, 5, "*"));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void padEndFillsRightToTargetLength() {
+ put("hello");
+ operate(StringOperation.padEnd(POLICY, BIN, 10, "."));
+ assertEquals("hello.....", stringValue());
+ }
+
+ @Test
+ public void padStartRepeatsMultiCharFiller() {
+ put("hi");
+ operate(StringOperation.padStart(POLICY, BIN, 8, "ab"));
+ assertEquals("abababhi", stringValue());
+ }
+
+ @Test
+ public void repeatDuplicatesContents() {
+ put("ab");
+ operate(StringOperation.repeat(POLICY, BIN, 3));
+ assertEquals("ababab", stringValue());
+ }
+
+ @Test
+ public void repeatOnceLeavesBinUnchanged() {
+ put("hello");
+ operate(StringOperation.repeat(POLICY, BIN, 1));
+ assertEquals("hello", stringValue());
+ }
+
+ @Test
+ public void concatAppendsSingleString() {
+ put(" hello world ");
+ operate(StringOperation.concat(POLICY, BIN, "!"));
+ assertEquals(" hello world !", stringValue());
+ }
+
+ @Test
+ public void concatAppendsListOfValues() {
+ put("hello");
+ operate(StringOperation.concat(POLICY, BIN, Arrays.asList(" ", "big", " world")));
+ assertEquals("hello big world", stringValue());
+ }
+
+ @Test
+ public void appendAddsValueToEnd() {
+ put("hello");
+ operate(StringOperation.append(POLICY, BIN, " world"));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void appendToEmptyStringYieldsValue() {
+ put("");
+ operate(StringOperation.append(POLICY, BIN, "hi"));
+ assertEquals("hi", stringValue());
+ }
+
+ @Test
+ public void appendPreservesMultibyteCodepoints() {
+ // Unicode/DBCS-aware: appending a multi-byte string must not corrupt
+ // either side. "日本" + "語" -> "日本語" (3 codepoints, 9 UTF-8 bytes).
+ put("日本");
+ operate(StringOperation.append(POLICY, BIN, "語"));
+ assertEquals("日本語", stringValue());
+ assertEquals(3L, operate(StringOperation.strlen(BIN)).getLong(BIN));
+ }
+
+ @Test
+ public void prependAddsValueToStart() {
+ put("world");
+ operate(StringOperation.prepend(POLICY, BIN, "hello "));
+ assertEquals("hello world", stringValue());
+ }
+
+ @Test
+ public void prependToEmptyStringYieldsValue() {
+ put("");
+ operate(StringOperation.prepend(POLICY, BIN, "hi"));
+ assertEquals("hi", stringValue());
+ }
+
+ @Test
+ public void prependPreservesMultibyteCodepoints() {
+ // Unicode/DBCS-aware: prepending a multi-byte string must not corrupt
+ // either side. "語" prepended with "日本" -> "日本語".
+ put("語");
+ operate(StringOperation.prepend(POLICY, BIN, "日本"));
+ assertEquals("日本語", stringValue());
+ assertEquals(3L, operate(StringOperation.strlen(BIN)).getLong(BIN));
+ }
+
+ @Test
+ public void appendOnMissingBinCreatesTheBinFromEmpty() {
+ // Create-ops {insert, concat, append, prepend} bootstrap an empty string
+ // and create a missing bin. NO_FAIL is irrelevant — the op always succeeds.
+ client.delete(null, KEY);
+ client.put(null, KEY, new Bin("other", "untouched"));
+
+ operate(StringOperation.append(POLICY, BIN, "x"));
+
+ Record r = client.get(null, KEY);
+ assertEquals("x", r.getString(BIN));
+ assertEquals("untouched", r.getString("other"));
+ }
+
+ @Test
+ public void regexReplaceTargetsFirstMatchByDefault() {
+ put("abc123def456");
+ operate(StringOperation.regexReplace(POLICY, BIN, "[0-9]+", "NUM", StringRegexFlags.DEFAULT));
+ assertEquals("abcNUMdef456", stringValue());
+ }
+
+ @Test
+ public void regexReplaceWithGlobalFlagReplacesEveryMatch() {
+ put("abc123def456");
+ operate(StringOperation.regexReplace(POLICY, BIN, "[0-9]+", "NUM", StringRegexFlags.GLOBAL));
+ assertEquals("abcNUMdefNUM", stringValue());
+ }
+
+ @Test
+ public void regexReplaceWithNoMatchLeavesBinUnchanged() {
+ put("hello");
+ operate(StringOperation.regexReplace(POLICY, BIN, "[0-9]+", "NUM", StringRegexFlags.GLOBAL));
+ assertEquals("hello", stringValue());
+ }
+
+ //=================================================================
+ // Multi-op pipelines
+ //=================================================================
+
+ @Test
+ public void readsAcrossMultipleBinsInOneOperate() {
+ put(
+ new Bin("text", " hello world "),
+ new Bin("number_str", "12345"),
+ new Bin("upper_str", "HELLO"));
+
+ Record r = operate(
+ StringOperation.strlen("text"),
+ StringOperation.toInteger("number_str"),
+ StringOperation.isUpper("upper_str"));
+
+ // strlen and toInteger return INT; isUpper returns BOOL.
+ assertEquals(15L, r.getLong("text"));
+ assertEquals(12345L, r.getLong("number_str"));
+ assertTrue(r.getBoolean("upper_str"));
+ }
+
+ @Test
+ public void modifyAndReadInOneOperatePipelineCommitsThenObserves() {
+ put(" hello world ");
+
+ Record r = operate(
+ StringOperation.trim(POLICY, BIN),
+ StringOperation.upper(POLICY, BIN),
+ StringOperation.strlen(BIN));
+
+ // String ops set RESPOND_ALL_OPS (like BIT/EXP/HLL/MAP), so the three ops
+ // targeting the same bin come back as an ordered per-op result list rather
+ // than a single collapsed value. strlen runs last and therefore observes the
+ // post-trim+upper length.
+ List> results = r.getList(BIN);
+ assertEquals(11L, results.get(results.size() - 1));
+ assertEquals("HELLO WORLD", stringValue());
+ }
+
+ @Test
+ public void chainedReplaceAllAndPaddingComposeAsExpected() {
+ put("aabaa");
+
+ operate(
+ StringOperation.replaceAll(POLICY, BIN, "a", "x"),
+ StringOperation.padEnd(POLICY, BIN, 10, "."));
+
+ assertEquals("xxbxx.....", stringValue());
+ }
+
+ @Test
+ public void snipThenConcatInOneOperate() {
+ put("hello beautiful world");
+
+ operate(
+ StringOperation.snip(POLICY, BIN, 5, 15),
+ StringOperation.concat(POLICY, BIN, "!"));
+
+ assertEquals("hello world!", stringValue());
+ }
+
+ @Test
+ public void splitResultListEntriesAreReadableStrings() {
+ put("one,two,three");
+ Record r = operate(StringOperation.split(BIN, ","));
+
+ List> tokens = r.getList(BIN);
+ assertEquals(3, tokens.size());
+ // Each entry should round-trip as a String regardless of internal encoding.
+ for (Object t : tokens) {
+ assertTrue("expected String element but got " + (t == null ? "null" : t.getClass()),
+ t instanceof String);
+ }
+ }
+
+ //=================================================================
+ // CTX navigation — string nested in list/map bins
+ //
+ // Exercises the §2.3.1 CTX-wrapper wire envelope: the op-data is
+ // wrapped in a 3-element context-eval array (sub-op 0xFF) when CTX
+ // is non-empty. The server dispatches these through
+ // as_bin_string_modify_ctx_tr / its read-side twin, which is a
+ // separate code path from the top-level-bin variant exercised above.
+ //=================================================================
+
+ private static void putList(List String expressions require server version 8.1.3+; the tests are skipped
+ * on older clusters via {@link Assume}.
+ *
+ * Unlike {@link com.aerospike.client.operation.StringOperation}, the
+ * expression path does not take a CTX directly. To target a
+ * string nested in a list/map, callers project the nested value via
+ * {@link ListExp#getByIndex} or {@link MapExp#getByKey} and feed the result
+ * as {@code src}. Two such cases are exercised at the end of this file.
+ */
+public class TestStringExp extends TestSync {
+ private static final String BIN = "sbin";
+ private static final String VAR = "v";
+ private static final Key KEY = new Key(args.namespace, args.set, "stringexp-key");
+ private static final StringPolicy POLICY = StringPolicy.Default;
+
+ @BeforeClass
+ public static void serverVersionCheck() {
+ Assume.assumeTrue(
+ "Skipping: string expressions require server version 8.1.3 or later",
+ args.serverVersion.isGreaterOrEqual(8, 1, 3, 0));
+ }
+
+ //-----------------------------------------------------------------
+ // Helpers
+ //-----------------------------------------------------------------
+
+ private static void put(String value) {
+ client.delete(null, KEY);
+ client.put(null, KEY, new Bin(BIN, value));
+ }
+
+ private static void putRaw(Bin bin) {
+ client.delete(null, KEY);
+ client.put(null, KEY, bin);
+ }
+
+ private static Record eval(Exp e) {
+ return client.operate(null, KEY,
+ ExpOperation.read(VAR, Exp.build(e), ExpReadFlags.DEFAULT));
+ }
+
+ //=================================================================
+ // Read expressions
+ //=================================================================
+
+ @Test
+ public void strlenReturnsCodepointCount() {
+ put("hello world");
+ Record r = eval(StringExp.strlen(Exp.stringBin(BIN)));
+ assertEquals(11L, r.getLong(VAR));
+ }
+
+ @Test
+ public void substrFromOffsetAndRange() {
+ put("hello world");
+ // Single-arg form: offset to end.
+ Record r1 = eval(StringExp.substr(Exp.val(6), Exp.stringBin(BIN)));
+ assertEquals("world", r1.getString(VAR));
+ // Two-arg form: [start, end) — end is exclusive.
+ Record r2 = eval(StringExp.substr(Exp.val(0), Exp.val(5), Exp.stringBin(BIN)));
+ assertEquals("hello", r2.getString(VAR));
+ }
+
+ @Test
+ public void charAtReturnsSingleCharacter() {
+ put("Hello123World");
+ Record r = eval(StringExp.charAt(Exp.val(5), Exp.stringBin(BIN)));
+ assertEquals("1", r.getString(VAR));
+ }
+
+ @Test
+ public void findReturnsIndexOfFirstAndNthMatch() {
+ put("ababab");
+ // Default (first match).
+ Record r1 = eval(StringExp.find(Exp.val("ab"), Exp.stringBin(BIN)));
+ assertEquals(0L, r1.getLong(VAR));
+ // Occurrence overload (1-based) — second occurrence starts at index 2.
+ Record r2 = eval(StringExp.find(Exp.val("ab"), Exp.val(2), Exp.stringBin(BIN)));
+ assertEquals(2L, r2.getLong(VAR));
+ }
+
+ @Test
+ public void findSkipsOverlappingMatches() {
+ // Self-overlapping needle "aa" in "aaaa": after match at 0, search
+ // resumes at 2 — so the 2nd occurrence is at 2, not 1. Mirrors the
+ // StringOperation.find contract and ICU usearch behavior.
+ put("aaaa");
+ assertEquals(0L,
+ eval(StringExp.find(Exp.val("aa"), Exp.val(1), Exp.stringBin(BIN))).getLong(VAR));
+ assertEquals(2L,
+ eval(StringExp.find(Exp.val("aa"), Exp.val(2), Exp.stringBin(BIN))).getLong(VAR));
+ assertEquals(-1L,
+ eval(StringExp.find(Exp.val("aa"), Exp.val(3), Exp.stringBin(BIN))).getLong(VAR));
+ }
+
+ @Test
+ public void containsReturnsBoolean() {
+ put("hello world");
+ Record present = eval(StringExp.contains(Exp.val("hello"), Exp.stringBin(BIN)));
+ Record absent = eval(StringExp.contains(Exp.val("xyz"), Exp.stringBin(BIN)));
+ assertTrue(present.getBoolean(VAR));
+ assertFalse(absent.getBoolean(VAR));
+ }
+
+ @Test
+ public void startsWithMatchesPrefix() {
+ put("Hello123World");
+ assertTrue(eval(StringExp.startsWith(Exp.val("Hello"), Exp.stringBin(BIN))).getBoolean(VAR));
+ assertFalse(eval(StringExp.startsWith(Exp.val("World"), Exp.stringBin(BIN))).getBoolean(VAR));
+ }
+
+ @Test
+ public void endsWithMatchesSuffix() {
+ put("Hello123World");
+ assertTrue(eval(StringExp.endsWith(Exp.val("World"), Exp.stringBin(BIN))).getBoolean(VAR));
+ assertFalse(eval(StringExp.endsWith(Exp.val("Hello"), Exp.stringBin(BIN))).getBoolean(VAR));
+ }
+
+ @Test
+ public void toIntegerParsesDigitsAsLong() {
+ put("12345");
+ Record r = eval(StringExp.toInteger(Exp.stringBin(BIN)));
+ assertEquals(12345L, r.getLong(VAR));
+ }
+
+ @Test
+ public void toDoubleParsesDecimalNumbers() {
+ put("3.14");
+ Record r = eval(StringExp.toDouble(Exp.stringBin(BIN)));
+ assertEquals(3.14, r.getDouble(VAR), 0.001);
+ }
+
+ @Test
+ public void byteLengthReturnsUtf8Bytes() {
+ put("hello");
+ Record r = eval(StringExp.byteLength(Exp.stringBin(BIN)));
+ assertEquals(5L, r.getLong(VAR));
+ }
+
+ //-----------------------------------------------------------------
+ // Codepoint-vs-byte anchors (mirror of TestOperateString)
+ //-----------------------------------------------------------------
+
+ @Test
+ public void strlenCountsCodepointsAndByteLengthCountsBytes() {
+ // "café" = 4 codepoints, 5 UTF-8 bytes; "日本語" = 3 codepoints, 9 bytes.
+ put("café");
+ assertEquals(4L, eval(StringExp.strlen(Exp.stringBin(BIN))).getLong(VAR));
+ assertEquals(5L, eval(StringExp.byteLength(Exp.stringBin(BIN))).getLong(VAR));
+
+ put("日本語");
+ assertEquals(3L, eval(StringExp.strlen(Exp.stringBin(BIN))).getLong(VAR));
+ assertEquals(9L, eval(StringExp.byteLength(Exp.stringBin(BIN))).getLong(VAR));
+ }
+
+ @Test
+ public void charAtReturnsWholeSupplementaryCodepoint() {
+ // 👋 is U+1F44B (4 UTF-8 bytes, a surrogate pair in Java UTF-16).
+ // charAt must return the whole codepoint, not a half-surrogate.
+ put("a👋b");
+ Record r = eval(StringExp.charAt(Exp.val(1), Exp.stringBin(BIN)));
+ assertEquals("👋", r.getString(VAR));
+ }
+
+ @Test
+ public void isNumericMatchesIntegerStringsByDefaultAndByType() {
+ put("12345");
+ // Default (ANY): both ints and floats pass.
+ assertTrue(eval(StringExp.isNumeric(Exp.stringBin(BIN))).getBoolean(VAR));
+ // INT-only: still passes for pure-digit string.
+ assertTrue(eval(StringExp.isNumeric(StringNumericType.INT, Exp.stringBin(BIN))).getBoolean(VAR));
+ put("3.14");
+ // INT-only: fails for a float-shaped string.
+ assertFalse(eval(StringExp.isNumeric(StringNumericType.INT, Exp.stringBin(BIN))).getBoolean(VAR));
+ put("hello");
+ assertFalse(eval(StringExp.isNumeric(Exp.stringBin(BIN))).getBoolean(VAR));
+ }
+
+ @Test
+ public void isUpperAndIsLowerDistinguishCase() {
+ put("HELLO");
+ assertTrue(eval(StringExp.isUpper(Exp.stringBin(BIN))).getBoolean(VAR));
+ assertFalse(eval(StringExp.isLower(Exp.stringBin(BIN))).getBoolean(VAR));
+
+ put("hello");
+ assertFalse(eval(StringExp.isUpper(Exp.stringBin(BIN))).getBoolean(VAR));
+ assertTrue(eval(StringExp.isLower(Exp.stringBin(BIN))).getBoolean(VAR));
+ }
+
+ @Test
+ public void toBlobReturnsUtf8Bytes() {
+ put("hello");
+ Record r = eval(StringExp.toBlob(Exp.stringBin(BIN)));
+ assertArrayEquals("hello".getBytes(), (byte[])r.getValue(VAR));
+ }
+
+ @Test
+ public void splitWithAndWithoutSeparator() {
+ put("one,two,three");
+ Record r1 = eval(StringExp.split(Exp.val(","), Exp.stringBin(BIN)));
+ assertEquals(Arrays.asList("one", "two", "three"), r1.getList(VAR));
+
+ // No-separator form: per spec §2.4, returns one element per Unicode codepoint.
+ put("abc");
+ Record r2 = eval(StringExp.split(Exp.stringBin(BIN)));
+ assertEquals(Arrays.asList("a", "b", "c"), r2.getList(VAR));
+ }
+
+ @Test
+ public void b64DecodeReturnsOriginalBlob() {
+ put("aGVsbG8=");
+ Record r = eval(StringExp.b64Decode(Exp.stringBin(BIN)));
+ assertArrayEquals("hello".getBytes(), (byte[])r.getValue(VAR));
+ }
+
+ @Test
+ public void regexCompareWithAndWithoutCaseInsensitiveFlag() {
+ put("Hello123World");
+ assertTrue(eval(StringExp.regexCompare(
+ Exp.val("[0-9]+"), Exp.stringBin(BIN))).getBoolean(VAR));
+
+ put("HELLO");
+ assertFalse(eval(StringExp.regexCompare(
+ Exp.val("hello"), Exp.stringBin(BIN))).getBoolean(VAR));
+ assertTrue(eval(StringExp.regexCompare(
+ Exp.val("hello"), StringRegexFlags.CASE_INSENSITIVE,
+ Exp.stringBin(BIN))).getBoolean(VAR));
+ }
+
+ // Note: a literal-source variant (e.g. StringExp.regexCompare(Exp.val("[A-Z]+"),
+ // Exp.val("HELLO"))) is not exercised here. The server's expression engine returns
+ // OP_NOT_APPLICABLE (26) for that shape — the engine evaluates the literal but does
+ // not tag the resulting value as a STRING particle, so string_read's type check at
+ // particle_string.c:1040 rejects it. Spec §3.7 claims any string-yielding expression
+ // is accepted; the server does not honor that today. Bin-sourced regexCompare is
+ // covered in regexCompareWithAndWithoutCaseInsensitiveFlag above.
+
+ //=================================================================
+ // Modify expressions (return the modified string; do not persist)
+ //=================================================================
+
+ @Test
+ public void insertSplicesIntoSource() {
+ put("hello world");
+ Record r = eval(StringExp.insert(
+ POLICY, Exp.val(5), Exp.val(" beautiful"), Exp.stringBin(BIN)));
+ assertEquals("hello beautiful world", r.getString(VAR));
+ // Modify expressions do not persist — original bin is unchanged.
+ assertEquals("hello world", client.get(null, KEY).getString(BIN));
+ }
+
+ @Test
+ public void overwriteReplacesRange() {
+ put("hello world");
+ Record r = eval(StringExp.overwrite(
+ POLICY, Exp.val(6), Exp.val("earth"), Exp.stringBin(BIN)));
+ assertEquals("hello earth", r.getString(VAR));
+ }
+
+ @Test
+ public void concatAppendsListOfValues() {
+ put("hello");
+ Exp values = Exp.val(Arrays.asList(" ", "big", " world"));
+ Record r = eval(StringExp.concat(POLICY, values, Exp.stringBin(BIN)));
+ assertEquals("hello big world", r.getString(VAR));
+ }
+
+ @Test
+ public void appendAddsValueToEnd() {
+ put("hello");
+ Record r = eval(StringExp.append(POLICY, Exp.val(" world"), Exp.stringBin(BIN)));
+ assertEquals("hello world", r.getString(VAR));
+ }
+
+ @Test
+ public void appendPreservesMultibyteCodepoints() {
+ // Unicode/DBCS-aware: "日本" + "語" -> "日本語".
+ put("日本");
+ Record r = eval(StringExp.append(POLICY, Exp.val("語"), Exp.stringBin(BIN)));
+ assertEquals("日本語", r.getString(VAR));
+ }
+
+ @Test
+ public void prependAddsValueToStart() {
+ put("world");
+ Record r = eval(StringExp.prepend(POLICY, Exp.val("hello "), Exp.stringBin(BIN)));
+ assertEquals("hello world", r.getString(VAR));
+ }
+
+ @Test
+ public void prependPreservesMultibyteCodepoints() {
+ // Unicode/DBCS-aware: "語" prepended with "日本" -> "日本語".
+ put("語");
+ Record r = eval(StringExp.prepend(POLICY, Exp.val("日本"), Exp.stringBin(BIN)));
+ assertEquals("日本語", r.getString(VAR));
+ }
+
+ @Test
+ public void snipRemovesRange() {
+ // Note: only the two-arg form is exercised. The server's snip op table
+ // (particle_string.c:443) requires (start, end[, flags]); the 1-arg client
+ // form [SNIP, start, flags] is silently misparsed — the trailing flags slot
+ // is read as `end`, producing a no-op when flags==DEFAULT==0.
+ put("hello beautiful world");
+ Record r = eval(StringExp.snip(POLICY, Exp.val(5), Exp.val(15), Exp.stringBin(BIN)));
+ assertEquals("hello world", r.getString(VAR));
+ }
+
+ @Test
+ public void replaceTouchesOnlyFirstMatch() {
+ put("hello world world");
+ Record r = eval(StringExp.replace(
+ POLICY, Exp.val("world"), Exp.val("earth"), Exp.stringBin(BIN)));
+ assertEquals("hello earth world", r.getString(VAR));
+ }
+
+ @Test
+ public void replaceAllSubstitutesEveryMatch() {
+ put("aabaa");
+ Record r = eval(StringExp.replaceAll(
+ POLICY, Exp.val("a"), Exp.val("x"), Exp.stringBin(BIN)));
+ assertEquals("xxbxx", r.getString(VAR));
+ }
+
+ @Test
+ public void upperAndLowerProduceCorrectCase() {
+ put("hello World");
+ assertEquals("HELLO WORLD",
+ eval(StringExp.upper(POLICY, Exp.stringBin(BIN))).getString(VAR));
+ assertEquals("hello world",
+ eval(StringExp.lower(POLICY, Exp.stringBin(BIN))).getString(VAR));
+ }
+
+ @Test
+ public void caseFoldLowercasesIndependentlyOfLocale() {
+ put("HELLO World");
+ Record r = eval(StringExp.caseFold(POLICY, Exp.stringBin(BIN)));
+ assertEquals("hello world", r.getString(VAR));
+ }
+
+ @Test
+ public void normalizeNFCLeavesAlreadyNormalizedStringUnchanged() {
+ put("hello");
+ Record r = eval(StringExp.normalizeNFC(POLICY, Exp.stringBin(BIN)));
+ assertEquals("hello", r.getString(VAR));
+ }
+
+ @Test
+ public void trimVariantsStripAppropriateEdges() {
+ put(" hello world ");
+ assertEquals("hello world",
+ eval(StringExp.trim(POLICY, Exp.stringBin(BIN))).getString(VAR));
+ assertEquals("hello world ",
+ eval(StringExp.trimStart(POLICY, Exp.stringBin(BIN))).getString(VAR));
+ assertEquals(" hello world",
+ eval(StringExp.trimEnd(POLICY, Exp.stringBin(BIN))).getString(VAR));
+ }
+
+ @Test
+ public void padStartFillsLeftToTargetLength() {
+ put("hello");
+ Record r = eval(StringExp.padStart(
+ POLICY, Exp.val(10), Exp.val("*"), Exp.stringBin(BIN)));
+ assertEquals("*****hello", r.getString(VAR));
+ }
+
+ @Test
+ public void padEndFillsRightToTargetLength() {
+ put("hello");
+ Record r = eval(StringExp.padEnd(
+ POLICY, Exp.val(10), Exp.val("."), Exp.stringBin(BIN)));
+ assertEquals("hello.....", r.getString(VAR));
+ }
+
+ @Test
+ public void repeatDuplicatesContents() {
+ put("ab");
+ Record r = eval(StringExp.repeat(POLICY, Exp.val(3), Exp.stringBin(BIN)));
+ assertEquals("ababab", r.getString(VAR));
+ }
+
+ @Test
+ public void regexReplaceFirstAndGlobal() {
+ put("abc123def456");
+ // Default: first match only.
+ Record r1 = eval(StringExp.regexReplace(
+ POLICY, Exp.val("[0-9]+"), Exp.val("NUM"),
+ StringRegexFlags.DEFAULT, Exp.stringBin(BIN)));
+ assertEquals("abcNUMdef456", r1.getString(VAR));
+
+ // GLOBAL: every match.
+ Record r2 = eval(StringExp.regexReplace(
+ POLICY, Exp.val("[0-9]+"), Exp.val("NUM"),
+ StringRegexFlags.GLOBAL, Exp.stringBin(BIN)));
+ assertEquals("abcNUMdefNUM", r2.getString(VAR));
+ }
+
+ //=================================================================
+ // Type conversion expression
+ //=================================================================
+
+ @Test
+ public void toStringConvertsIntegerBin() {
+ putRaw(new Bin(BIN, 42));
+ Record r = eval(StringExp.toString(Exp.intBin(BIN)));
+ assertEquals("42", r.getString(VAR));
+ }
+
+ //=================================================================
+ // Chained expressions — modify result feeds another StringExp
+ //=================================================================
+
+ @Test
+ public void chainedTrimThenUpperComposes() {
+ put(" hello world ");
+ // trim -> upper, both inside one expression tree.
+ Exp chain = StringExp.upper(
+ POLICY,
+ StringExp.trim(POLICY, Exp.stringBin(BIN)));
+ Record r = eval(chain);
+ assertEquals("HELLO WORLD", r.getString(VAR));
+ }
+
+ //=================================================================
+ // Filter-expression usage — predicate gates record retrieval
+ //=================================================================
+
+ @Test
+ public void startsWithFilterGatesGet() {
+ put("hello world");
+ Policy p = new Policy();
+
+ // Matching filter -> record returned.
+ p.filterExp = Exp.build(StringExp.startsWith(
+ Exp.val("hello"), Exp.stringBin(BIN)));
+ assertEquals("hello world", client.get(p, KEY).getString(BIN));
+
+ // Non-matching filter -> filtered out, get returns null.
+ p.filterExp = Exp.build(StringExp.startsWith(
+ Exp.val("world"), Exp.stringBin(BIN)));
+ assertEquals(null, client.get(p, KEY));
+ }
+
+ //=================================================================
+ // Nested-source — string inside a list/map projected via Exp getters
+ //
+ // StringExp does not accept CTX directly; callers compose with
+ // ListExp.getByIndex / MapExp.getByKey to project the nested string
+ // into an Exp and pass it as src.
+ //=================================================================
+
+ @Test
+ public void strlenOnStringNestedInListProjectedViaListExp() {
+ List Every read and modify op in {@link StringOperation} must reject a string
+ * bin whose stored bytes are not well-formed UTF-8. The server's
+ * {@code as_bin_string_read} / {@code as_bin_string_modify} entry helpers run
+ * {@code utf8_string_length} on the bin before dispatching to the op-specific
+ * code, returning {@code AS_ERR_INVALID_ENCODING} ({@link ResultCode#INVALID_ENCODING}).
+ *
+ * Java {@link String} cannot directly hold an ill-formed UTF-8 sequence —
+ * the standard {@code UTF_8} decoder substitutes {@code U+FFFD}. To plant
+ * raw invalid bytes in a string-typed bin we use
+ * {@link Value.BytesValue#BytesValue(byte[], int) Value.BytesValue(bytes, ParticleType.STRING)},
+ * which writes the bytes verbatim with the {@code STRING} particle type byte.
+ *
+ * The fixture {@code BAD = {0xED, 0xA0, 0x80}} is U+D800 (ill-formed surrogate),
+ * the same fixture used by the server's {@code EntryParityUtf8} unit tests.
+ */
+public class TestStringInvalidUtf8 extends TestSync {
+ private static final String BIN = "sbin";
+ private static final Key KEY = new Key(args.namespace, args.set, "string-invalid-utf8-key");
+ private static final StringPolicy POLICY = StringPolicy.Default;
+
+ /** Ill-formed UTF-8: 3-byte encoding of U+D800 (surrogate). */
+ private static final byte[] BAD = new byte[] { (byte)0xED, (byte)0xA0, (byte)0x80 };
+
+ @BeforeClass
+ public static void serverVersionCheck() {
+ Assume.assumeTrue(
+ "Skipping: string operations require server version 8.1.3 or later",
+ args.serverVersion.isGreaterOrEqual(8, 1, 3, 0));
+ }
+
+ @Before
+ public void plantInvalidBin() {
+ client.delete(null, KEY);
+ // BytesValue(..., ParticleType.STRING) writes the bytes verbatim under the
+ // STRING particle type, bypassing Java-side UTF-8 sanitization.
+ client.put(null, KEY,
+ new Bin(BIN, new Value.BytesValue(BAD, ParticleType.STRING)));
+ }
+
+ private static void assertInvalidEncoding(Operation op) {
+ AerospikeException ae = assertThrows(AerospikeException.class,
+ () -> client.operate(null, KEY, op));
+ assertEquals(ResultCode.INVALID_ENCODING, ae.getResultCode());
+ }
+
+ //=================================================================
+ // Read ops — bin gate fires before op-specific logic
+ //=================================================================
+
+ @Test public void strlenRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.strlen(BIN));
+ }
+
+ @Test public void substrRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.substr(BIN, 0));
+ }
+
+ @Test public void charAtRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.charAt(BIN, 0));
+ }
+
+ @Test public void findRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.find(BIN, "x"));
+ }
+
+ @Test public void containsRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.contains(BIN, "x"));
+ }
+
+ @Test public void startsWithRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.startsWith(BIN, "x"));
+ }
+
+ @Test public void endsWithRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.endsWith(BIN, "x"));
+ }
+
+ @Test public void toIntegerRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.toInteger(BIN));
+ }
+
+ @Test public void toDoubleRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.toDouble(BIN));
+ }
+
+ // byte_length, to_blob, b64_decode, trim*, repeat, concat are listed in the
+ // 8.1.3 client report as "unaffected" by UTF-8, but per the doc's §3 and
+ // §11 they hit the same bin gate as strlen and must also reject.
+ @Test public void byteLengthRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.byteLength(BIN));
+ }
+
+ @Test public void isNumericRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.isNumeric(BIN));
+ }
+
+ @Test public void isUpperRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.isUpper(BIN));
+ }
+
+ @Test public void isLowerRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.isLower(BIN));
+ }
+
+ @Test public void toBlobRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.toBlob(BIN));
+ }
+
+ @Test public void splitRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.split(BIN, ","));
+ }
+
+ @Test public void b64DecodeRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.b64Decode(BIN));
+ }
+
+ @Test public void regexCompareRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.regexCompare(BIN, "x"));
+ }
+
+ //=================================================================
+ // Modify ops — bin gate also fires here; bin must remain unchanged.
+ //
+ // We can't easily verify "bin bytes unchanged" via client.get because the
+ // Java client decodes STRING particles through java.nio UTF-8, which
+ // replaces ill-formed sequences with U+FFFD; the raw bytes are not
+ // recoverable through the public client surface. The fact that a
+ // subsequent strlen on the same bin still hits INVALID_ENCODING (see
+ // failedModifyDoesNotOverwriteBin below) proves the failed modify did
+ // not replace the bin with a well-formed value.
+ //=================================================================
+
+ @Test public void insertRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.insert(POLICY, BIN, 0, "x"));
+ }
+
+ @Test public void overwriteRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.overwrite(POLICY, BIN, 0, "x"));
+ }
+
+ @Test public void concatRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.concat(POLICY, BIN, "x"));
+ }
+
+ @Test public void snipRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.snip(POLICY, BIN, 0, 1));
+ }
+
+ @Test public void replaceRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.replace(POLICY, BIN, "x", "y"));
+ }
+
+ @Test public void replaceAllRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.replaceAll(POLICY, BIN, "x", "y"));
+ }
+
+ @Test public void upperRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.upper(POLICY, BIN));
+ }
+
+ @Test public void lowerRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.lower(POLICY, BIN));
+ }
+
+ @Test public void caseFoldRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.caseFold(POLICY, BIN));
+ }
+
+ @Test public void normalizeNFCRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.normalizeNFC(POLICY, BIN));
+ }
+
+ @Test public void trimStartRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.trimStart(POLICY, BIN));
+ }
+
+ @Test public void trimEndRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.trimEnd(POLICY, BIN));
+ }
+
+ @Test public void trimRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.trim(POLICY, BIN));
+ }
+
+ @Test public void padStartRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.padStart(POLICY, BIN, 10, "*"));
+ }
+
+ @Test public void padEndRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.padEnd(POLICY, BIN, 10, "*"));
+ }
+
+ @Test public void repeatRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.repeat(POLICY, BIN, 2));
+ }
+
+ @Test public void regexReplaceRejectsInvalidBin() {
+ assertInvalidEncoding(StringOperation.regexReplace(
+ POLICY, BIN, "x", "y", StringRegexFlags.DEFAULT));
+ }
+
+ //=================================================================
+ // Post-failure invariant
+ //=================================================================
+
+ @Test
+ public void failedModifyDoesNotOverwriteBin() {
+ // First modify attempt must fail with INVALID_ENCODING.
+ assertInvalidEncoding(StringOperation.upper(POLICY, BIN));
+ // A subsequent read on the same bin must also fail at the gate, proving
+ // the bin still holds the original invalid bytes (the failed modify
+ // did not replace it with a well-formed value).
+ assertInvalidEncoding(StringOperation.strlen(BIN));
+ }
+
+ //=================================================================
+ // Client-side defense — invalid UTF-8 in an op argument is rejected
+ // before the wire by Utf8.encodedLength (client/src/.../util/Utf8.java:87).
+ // This complements the server's invalid-arg gate (which Java callers can't
+ // normally reach because String → UTF-8 conversion either throws here or
+ // substitutes well-formed bytes).
+ //=================================================================
+
+ @Test
+ public void unpairedSurrogateInArgIsRejectedClientSide() {
+ client.delete(null, KEY);
+ client.put(null, KEY, new Bin(BIN, "hello"));
+ // "\uD800" is an unpaired high surrogate. The client's UTF-8 encoder
+ // throws AerospikeException before sending the op.
+ AerospikeException ae = assertThrows(AerospikeException.class,
+ () -> client.operate(null, KEY,
+ StringOperation.find(BIN, "\uD800")));
+ // Sanity-check the message — encodedLength's throw includes the word.
+ assertTrue("expected 'surrogate' in message: " + ae.getMessage(),
+ ae.getMessage() != null && ae.getMessage().contains("surrogate"));
+ }
+}
diff --git a/test/src/com/aerospike/test/sync/basic/TestStringMasking.java b/test/src/com/aerospike/test/sync/basic/TestStringMasking.java
new file mode 100644
index 000000000..6405fc0eb
--- /dev/null
+++ b/test/src/com/aerospike/test/sync/basic/TestStringMasking.java
@@ -0,0 +1,408 @@
+/*
+ * Copyright 2012-2026 Aerospike, Inc.
+ *
+ * Portions may be licensed to Aerospike, Inc. under one or more contributor
+ * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0.
+ *
+ * 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.aerospike.test.sync.basic;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.fail;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.AfterClass;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import com.aerospike.client.AerospikeClient;
+import com.aerospike.client.AerospikeException;
+import com.aerospike.client.Bin;
+import com.aerospike.client.Host;
+import com.aerospike.client.IAerospikeClient;
+import com.aerospike.client.Info;
+import com.aerospike.client.Key;
+import com.aerospike.client.Record;
+import com.aerospike.client.ResultCode;
+import com.aerospike.client.admin.Role;
+import com.aerospike.client.cluster.Node;
+import com.aerospike.client.operation.StringOperation;
+import com.aerospike.client.operation.StringPolicy;
+import com.aerospike.client.policy.AdminPolicy;
+import com.aerospike.client.policy.ClientPolicy;
+import com.aerospike.test.sync.TestSync;
+
+/**
+ * Integration tests for string operations applied to bins protected by a
+ * server-side masking rule.
+ *
+ * Each test exercises one privilege boundary:
+ * The test bootstraps two extra users (one privileged reader, one
+ * unprivileged user) and connects an additional client per role. The whole
+ * class is skipped when security is disabled, no admin credentials are
+ * supplied, or the cluster is older than 8.1.3 (where masking + string ops
+ * are jointly supported).
+ */
+public class TestStringMasking extends TestSync {
+ private static final String MASKED_BIN = "pii";
+ private static final String UNMASKED_BIN = "public";
+ private static final String INITIAL_VALUE = "hello world";
+ private static final String INITIAL_PUBLIC = "visible data";
+ private static final String MASK_FUNCTION = "redact";
+
+ private static final String PRIV_USER = "stringops_reader";
+ private static final String UNPRIV_USER = "stringops_user";
+ private static final String USER_PASSWORD = "stringops_pw1!";
+
+ private static final Key KEY = new Key(args.namespace, args.set, "stringmask-key");
+ private static final StringPolicy POLICY = StringPolicy.Default;
+
+ private static boolean enabled;
+ private static IAerospikeClient privClient;
+ private static IAerospikeClient unprivClient;
+
+ @BeforeClass
+ public static void setupUsersAndRule() {
+ Assume.assumeTrue("Skipping: server version < 8.1.3 (string ops + masking)",
+ args.serverVersion.isGreaterOrEqual(8, 1, 3, 0));
+ Assume.assumeTrue("Skipping: admin credentials not provided",
+ args.user != null && !args.user.isEmpty()
+ && args.password != null && !args.password.isEmpty());
+
+ // Probe the cluster for security; bail out cleanly if it isn't enabled.
+ try {
+ client.queryRoles(new AdminPolicy());
+ }
+ catch (AerospikeException e) {
+ if (e.getResultCode() == ResultCode.SECURITY_NOT_ENABLED
+ || e.getResultCode() == ResultCode.SECURITY_NOT_SUPPORTED
+ || e.getResultCode() == ResultCode.NOT_AUTHENTICATED) {
+ Assume.assumeTrue("Skipping: security not enabled on cluster", false);
+ }
+ throw e;
+ }
+
+ dropUserQuiet(PRIV_USER);
+ dropUserQuiet(UNPRIV_USER);
+
+ AdminPolicy ap = new AdminPolicy();
+ client.createUser(ap, PRIV_USER, USER_PASSWORD,
+ Arrays.asList(Role.ReadWrite, Role.ReadMasked));
+ client.createUser(ap, UNPRIV_USER, USER_PASSWORD,
+ Arrays.asList(Role.ReadWrite));
+
+ privClient = newClient(PRIV_USER);
+ unprivClient = newClient(UNPRIV_USER);
+
+ applyMaskRule(MASKED_BIN, MASK_FUNCTION, null);
+ enabled = true;
+ }
+
+ @AfterClass
+ public static void tearDown() {
+ if (!enabled) {
+ return;
+ }
+ removeMaskRule(MASKED_BIN);
+
+ try {
+ AdminPolicy ap = new AdminPolicy();
+ dropUserQuiet(PRIV_USER);
+ dropUserQuiet(UNPRIV_USER);
+ // If queryRoles fires (it can race role propagation), let close still run.
+ client.queryRoles(ap);
+ }
+ catch (Exception ignored) {
+ }
+ finally {
+ closeQuiet(privClient);
+ closeQuiet(unprivClient);
+ }
+ }
+
+ @Before
+ public void resetRecord() {
+ client.delete(null, KEY);
+ client.put(null, KEY,
+ new Bin(MASKED_BIN, INITIAL_VALUE),
+ new Bin(UNMASKED_BIN, INITIAL_PUBLIC));
+ }
+
+ //=================================================================
+ // Read ops: privilege gates which value the caller observes
+ //=================================================================
+
+ @Test
+ public void readMaskedSeesRealValue_strlen() {
+ Record r = privClient.operate(null, KEY, StringOperation.strlen(MASKED_BIN));
+ assertEquals(INITIAL_VALUE.length(), r.getLong(MASKED_BIN));
+ }
+
+ @Test
+ public void readMaskedSeesRealValue_substr() {
+ Record r = privClient.operate(null, KEY, StringOperation.substr(MASKED_BIN, 0, 5));
+ assertEquals("hello", r.getString(MASKED_BIN));
+ }
+
+ @Test
+ public void unprivilegedSeesMaskedSubstring() {
+ Record r = unprivClient.operate(null, KEY, StringOperation.substr(MASKED_BIN, 0, 5));
+ // A full-redact rule should never let the underlying characters leak.
+ String value = r.getString(MASKED_BIN);
+ assertEquals(5, value.length());
+ assertNotEquals("hello", value);
+ }
+
+ @Test
+ public void unprivilegedFindOnMaskedBinDoesNotLocateRealContent() {
+ Record r = unprivClient.operate(null, KEY, StringOperation.find(MASKED_BIN, "world"));
+ assertEquals(-1L, r.getLong(MASKED_BIN));
+ }
+
+ @Test
+ public void unprivilegedContainsOnMaskedBinIsFalse() {
+ Record r = unprivClient.operate(null, KEY, StringOperation.contains(MASKED_BIN, "hello"));
+ assertFalse(r.getBoolean(MASKED_BIN));
+ }
+
+ @Test
+ public void unprivilegedStartsEndsOnMaskedBinAreFalse() {
+ Record sw = unprivClient.operate(null, KEY, StringOperation.startsWith(MASKED_BIN, "hello"));
+ Record ew = unprivClient.operate(null, KEY, StringOperation.endsWith(MASKED_BIN, "world"));
+ assertFalse(sw.getBoolean(MASKED_BIN));
+ assertFalse(ew.getBoolean(MASKED_BIN));
+ }
+
+ @Test
+ public void unprivilegedRegexCompareOnMaskedBinDoesNotMatchReal() {
+ Record r = unprivClient.operate(null, KEY, StringOperation.regexCompare(MASKED_BIN, "hello.*"));
+ assertFalse(r.getBoolean(MASKED_BIN));
+ }
+
+ @Test
+ public void strlenIsUnaffectedByRedaction() {
+ // Redact preserves length, so both clients agree on strlen/byteLength.
+ Record priv = privClient.operate(null, KEY, StringOperation.byteLength(MASKED_BIN));
+ Record unp = unprivClient.operate(null, KEY, StringOperation.byteLength(MASKED_BIN));
+ assertEquals(INITIAL_VALUE.length(), priv.getLong(MASKED_BIN));
+ assertEquals(INITIAL_VALUE.length(), unp.getLong(MASKED_BIN));
+ }
+
+ //=================================================================
+ // Read ops on the unmasked bin — both users see the real data
+ //=================================================================
+
+ @Test
+ public void unmaskedBinIsTransparentToBothUsers() {
+ Record priv = privClient.operate(null, KEY, StringOperation.strlen(UNMASKED_BIN));
+ Record unp = unprivClient.operate(null, KEY, StringOperation.strlen(UNMASKED_BIN));
+ assertEquals(INITIAL_PUBLIC.length(), priv.getLong(UNMASKED_BIN));
+ assertEquals(INITIAL_PUBLIC.length(), unp.getLong(UNMASKED_BIN));
+ }
+
+ //=================================================================
+ // Modify ops: blocked without write-masked
+ //=================================================================
+
+ @Test
+ public void writeMaskedRequired_upper() {
+ assertRoleViolation(() -> unprivClient.operate(null, KEY,
+ StringOperation.upper(POLICY, MASKED_BIN)));
+ }
+
+ @Test
+ public void writeMaskedRequired_insert() {
+ assertRoleViolation(() -> unprivClient.operate(null, KEY,
+ StringOperation.insert(POLICY, MASKED_BIN, 5, " beautiful")));
+ }
+
+ @Test
+ public void writeMaskedRequired_concat() {
+ assertRoleViolation(() -> unprivClient.operate(null, KEY,
+ StringOperation.concat(POLICY, MASKED_BIN, "!")));
+ }
+
+ @Test
+ public void writeMaskedRequired_replace() {
+ assertRoleViolation(() -> unprivClient.operate(null, KEY,
+ StringOperation.replace(POLICY, MASKED_BIN, "world", "earth")));
+ }
+
+ @Test
+ public void writeMaskedRequired_trim() {
+ client.put(null, KEY, new Bin(MASKED_BIN, " padded "));
+ assertRoleViolation(() -> unprivClient.operate(null, KEY,
+ StringOperation.trim(POLICY, MASKED_BIN)));
+ }
+
+ @Test
+ public void writeMaskedRequired_padStart() {
+ assertRoleViolation(() -> unprivClient.operate(null, KEY,
+ StringOperation.padStart(POLICY, MASKED_BIN, 20, "*")));
+ }
+
+ @Test
+ public void writeMaskedRequired_regexReplace() {
+ assertRoleViolation(() -> unprivClient.operate(null, KEY,
+ StringOperation.regexReplace(POLICY, MASKED_BIN, "[0-9]+", "NUM", 0)));
+ }
+
+ //=================================================================
+ // Read-masked still cannot modify; admin still can.
+ //=================================================================
+
+ @Test
+ public void readMaskedCannotModify() {
+ assertRoleViolation(() -> privClient.operate(null, KEY,
+ StringOperation.upper(POLICY, MASKED_BIN)));
+ }
+
+ @Test
+ public void adminModifyOnMaskedBinSucceeds() {
+ client.operate(null, KEY, StringOperation.upper(POLICY, MASKED_BIN));
+ Record r = client.get(null, KEY);
+ assertEquals("HELLO WORLD", r.getString(MASKED_BIN));
+ }
+
+ //=================================================================
+ // Modify on unmasked bin succeeds for unprivileged user.
+ //=================================================================
+
+ @Test
+ public void unprivilegedCanModifyUnmaskedBin() {
+ unprivClient.operate(null, KEY, StringOperation.upper(POLICY, UNMASKED_BIN));
+ Record r = client.get(null, KEY);
+ assertEquals("VISIBLE DATA", r.getString(UNMASKED_BIN));
+ // The masked bin is left untouched.
+ assertEquals(INITIAL_VALUE, r.getString(MASKED_BIN));
+ }
+
+ //=================================================================
+ // Constant-mask variant: unprivileged sees a fixed string
+ //=================================================================
+
+ @Test
+ public void constantMaskIsObservedByUnprivilegedRead() {
+ final String constBin = "secret";
+ final String constValue = "HIDDEN";
+ final String real = "real secret data";
+ final Key key = new Key(args.namespace, args.set, "stringmask-const");
+
+ applyMaskRule(constBin, "constant", "value=" + constValue);
+ try {
+ client.delete(null, key);
+ client.put(null, key, new Bin(constBin, real));
+
+ Record priv = privClient.operate(null, key, StringOperation.strlen(constBin));
+ Record unp = unprivClient.operate(null, key, StringOperation.strlen(constBin));
+ assertEquals(real.length(), priv.getLong(constBin));
+ assertEquals(constValue.length(), unp.getLong(constBin));
+
+ Record privSub = privClient.operate(null, key, StringOperation.substr(constBin, 0, 4));
+ Record unpSub = unprivClient.operate(null, key, StringOperation.substr(constBin, 0, 4));
+ assertEquals("real", privSub.getString(constBin));
+ assertEquals("HIDD", unpSub.getString(constBin));
+ }
+ finally {
+ client.delete(null, key);
+ removeMaskRule(constBin);
+ }
+ }
+
+ //=================================================================
+ // Helpers
+ //=================================================================
+
+ private interface OperateCall {
+ void run();
+ }
+
+ private static void assertRoleViolation(OperateCall call) {
+ try {
+ call.run();
+ fail("Expected ROLE_VIOLATION");
+ }
+ catch (AerospikeException e) {
+ assertEquals(ResultCode.ROLE_VIOLATION, e.getResultCode());
+ }
+ }
+
+ private static IAerospikeClient newClient(String user) {
+ ClientPolicy p = new ClientPolicy();
+ args.setClientPolicy(p);
+ p.user = user;
+ p.password = USER_PASSWORD;
+ return new AerospikeClient(p, Host.parseHosts(args.host, args.port));
+ }
+
+ private static void closeQuiet(IAerospikeClient c) {
+ if (c != null) {
+ try { c.close(); } catch (Exception ignored) {}
+ }
+ }
+
+ private static void dropUserQuiet(String user) {
+ try {
+ client.dropUser(new AdminPolicy(), user);
+ }
+ catch (AerospikeException ignored) {
+ // User did not exist; nothing to do.
+ }
+ }
+
+ /**
+ * Apply a masking rule via info command. Format:
+ * {@code masking:namespace=NS;set=SET;bin=BIN;type=string;function=FN[;extra]}
+ */
+ private static void applyMaskRule(String bin, String function, String extra) {
+ StringBuilder cmd = new StringBuilder("masking:namespace=")
+ .append(args.namespace)
+ .append(";set=").append(args.set)
+ .append(";bin=").append(bin)
+ .append(";type=string;function=").append(function);
+ if (extra != null && !extra.isEmpty()) {
+ cmd.append(';').append(extra);
+ }
+ infoOnAllNodes(cmd.toString());
+ }
+
+ private static void removeMaskRule(String bin) {
+ String cmd = "masking:namespace=" + args.namespace
+ + ";set=" + args.set
+ + ";bin=" + bin
+ + ";type=string;function=remove";
+ infoOnAllNodes(cmd);
+ }
+
+ private static void infoOnAllNodes(String cmd) {
+ List
+ *
+ *
+ *