From 82f279f179207f09088d863fb1857e27386fe661 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Tue, 5 May 2026 12:19:47 -0700 Subject: [PATCH 01/19] Changes for string operations --- .../src/com/aerospike/client/Operation.java | 5 +- .../com/aerospike/client/exp/StringExp.java | 499 +++++++++++++++ .../client/operation/StringNumericType.java | 37 ++ .../client/operation/StringOperation.java | 523 ++++++++++++++++ .../client/operation/StringPolicy.java | 52 ++ .../client/operation/StringRegexFlags.java | 55 ++ .../client/operation/StringWriteFlags.java | 38 ++ test/src/com/aerospike/test/SuiteSync.java | 4 + .../test/sync/basic/TestOperateString.java | 571 ++++++++++++++++++ .../test/sync/basic/TestStringMasking.java | 407 +++++++++++++ 10 files changed, 2190 insertions(+), 1 deletion(-) create mode 100644 client/src/com/aerospike/client/exp/StringExp.java create mode 100644 client/src/com/aerospike/client/operation/StringNumericType.java create mode 100644 client/src/com/aerospike/client/operation/StringOperation.java create mode 100644 client/src/com/aerospike/client/operation/StringPolicy.java create mode 100644 client/src/com/aerospike/client/operation/StringRegexFlags.java create mode 100644 client/src/com/aerospike/client/operation/StringWriteFlags.java create mode 100644 test/src/com/aerospike/test/sync/basic/TestOperateString.java create mode 100644 test/src/com/aerospike/test/sync/basic/TestStringMasking.java diff --git a/client/src/com/aerospike/client/Operation.java b/client/src/com/aerospike/client/Operation.java index 56400cb7e..977b0ea59 100644 --- a/client/src/com/aerospike/client/Operation.java +++ b/client/src/com/aerospike/client/Operation.java @@ -111,7 +111,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/exp/StringExp.java b/client/src/com/aerospike/client/exp/StringExp.java new file mode 100644 index 000000000..0b23a670e --- /dev/null +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -0,0 +1,499 @@ +/* + * 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. See {@link com.aerospike.client.exp.Exp}. + *

+ * The string source argument in these methods is any expression that yields a + * string: a bin reference (e.g. {@link Exp#stringBin(String)}), a string literal + * ({@link Exp#val(String)}), or a nested string expression. Expressions that + * modify a string value return the modified string; the bin is not changed. + *

+ * String expressions require server version 8.1.3 or later. + */ +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 B64_ENCODE = 16; + private static final int REGEX_COMPARE = 17; + + // 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; + + //----------------------------------------------------------------- + // Read expressions + //----------------------------------------------------------------- + + /** + * Create expression that returns the codepoint length of the source string. + */ + public static Exp strlen(Exp src) { + byte[] bytes = Pack.pack(STRLEN); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns the substring from {@code start} to the end of the source. + */ + 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 {@code length} codepoints starting at {@code start}. + */ + public static Exp substr(Exp start, Exp length, Exp src) { + byte[] bytes = Pack.pack(SUBSTR, start, length); + return addRead(src, bytes, Exp.Type.STRING); + } + + /** + * Create expression that returns the character at {@code index} as a 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 match of + * {@code needle} in the source, or -1 if not found. + */ + 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}, or -1 if not found. + */ + 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 returns 1 if the source contains {@code needle}, 0 otherwise. + */ + public static Exp contains(Exp needle, Exp src) { + byte[] bytes = Pack.pack(CONTAINS, needle); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns 1 if the source begins with {@code prefix}, 0 otherwise. + */ + public static Exp startsWith(Exp prefix, Exp src) { + byte[] bytes = Pack.pack(STARTS_WITH, prefix); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns 1 if the source ends with {@code suffix}, 0 otherwise. + */ + public static Exp endsWith(Exp suffix, Exp src) { + byte[] bytes = Pack.pack(ENDS_WITH, suffix); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that parses the source as int64. + */ + public static Exp toInteger(Exp src) { + byte[] bytes = Pack.pack(TO_INTEGER); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that parses the source as a 64-bit float. + */ + 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 the source. + */ + public static Exp byteLength(Exp src) { + byte[] bytes = Pack.pack(BYTE_LENGTH); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns 1 if the source parses as a number, 0 otherwise. + */ + public static Exp isNumeric(Exp src) { + byte[] bytes = Pack.pack(IS_NUMERIC); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns 1 if the source parses as a number of the requested + * {@link com.aerospike.client.operation.StringNumericType}, 0 otherwise. + */ + public static Exp isNumeric(int numericType, Exp src) { + byte[] bytes = Pack.pack(IS_NUMERIC, numericType); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns 1 if every cased character in the source is uppercase. + */ + public static Exp isUpper(Exp src) { + byte[] bytes = Pack.pack(IS_UPPER); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns 1 if every cased character in the source is lowercase. + */ + public static Exp isLower(Exp src) { + byte[] bytes = Pack.pack(IS_LOWER); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns the UTF-8 bytes of the source as a blob. + */ + public static Exp toBlob(Exp src) { + byte[] bytes = Pack.pack(TO_BLOB); + return addRead(src, bytes, Exp.Type.BLOB); + } + + /** + * Create expression that splits the source by Unicode codepoint and returns a list of strings. + */ + public static Exp split(Exp src) { + byte[] bytes = Pack.pack(SPLIT); + return addRead(src, bytes, Exp.Type.LIST); + } + + /** + * Create expression that splits the source by {@code separator} and returns a list of strings. + */ + 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 the source and returns a blob. + */ + public static Exp b64Decode(Exp src) { + byte[] bytes = Pack.pack(B64_DECODE); + return addRead(src, bytes, Exp.Type.BLOB); + } + + /** + * Create expression that base64-encodes a blob source as a string. + */ + public static Exp b64Encode(Exp src) { + byte[] bytes = Pack.pack(B64_ENCODE); + return addRead(src, bytes, Exp.Type.STRING); + } + + /** + * Create expression that returns 1 if {@code pattern} (ICU regex) matches the source, 0 otherwise. + */ + public static Exp regexCompare(Exp pattern, Exp src) { + byte[] bytes = Pack.pack(REGEX_COMPARE, pattern); + return addRead(src, bytes, Exp.Type.INT); + } + + /** + * Create expression that returns 1 if {@code pattern} (ICU regex) matches the source under + * {@link StringRegexFlags}, 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.INT); + } + + //----------------------------------------------------------------- + // Modify expressions + //----------------------------------------------------------------- + + /** + * Create expression that inserts {@code value} at codepoint {@code index} of the source + * and returns the resulting 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 characters starting at codepoint {@code index} with + * {@code value} and returns the resulting 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 the source + * and returns the resulting string. Single-string callers can wrap their value in a + * 1-element list via {@link Exp#val(java.util.List)}. + */ + 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 removes characters from codepoint {@code start} to the end of + * the source and returns the resulting string. + */ + public static Exp snip(StringPolicy policy, Exp start, Exp src) { + byte[] bytes = Pack.pack(SNIP, start, policy.flags); + return addModify(src, bytes); + } + + /** + * Create expression that removes characters from codepoint {@code start} (inclusive) to + * {@code end} (exclusive) and returns the resulting 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} with {@code replacement} + * and returns the resulting 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} with {@code replacement} + * and returns the resulting 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 the source uppercased. + */ + public static Exp upper(StringPolicy policy, Exp src) { + byte[] bytes = Pack.pack(UPPER, policy.flags); + return addModify(src, bytes); + } + + /** + * Create expression that returns the source lowercased. + */ + public static Exp lower(StringPolicy policy, Exp src) { + byte[] bytes = Pack.pack(LOWER, policy.flags); + return addModify(src, bytes); + } + + /** + * Create expression that returns the source case-folded (locale-independent lowercase). + */ + public static Exp caseFold(StringPolicy policy, Exp src) { + byte[] bytes = Pack.pack(CASE_FOLD, policy.flags); + return addModify(src, bytes); + } + + /** + * Create expression that returns the source normalized to Unicode NFC form. + */ + public static Exp normalizeNFC(StringPolicy policy, Exp src) { + byte[] bytes = Pack.pack(NORMALIZE_NFC, policy.flags); + return addModify(src, bytes); + } + + /** + * Create expression that returns the source with whitespace removed from the start. + */ + public static Exp trimStart(StringPolicy policy, Exp src) { + byte[] bytes = Pack.pack(TRIM_START, policy.flags); + return addModify(src, bytes); + } + + /** + * Create expression that returns the source with whitespace removed from the end. + */ + public static Exp trimEnd(StringPolicy policy, Exp src) { + byte[] bytes = Pack.pack(TRIM_END, policy.flags); + return addModify(src, bytes); + } + + /** + * Create expression that returns the source with whitespace removed from both ends. + */ + public static Exp trim(StringPolicy policy, Exp src) { + byte[] bytes = Pack.pack(TRIM, policy.flags); + return addModify(src, bytes); + } + + /** + * Create expression that pads the start of the source to {@code targetLength} codepoints + * using {@code padString}. + */ + 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 pads the end of the source to {@code targetLength} codepoints + * using {@code padString}. + */ + 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 the source repeated {@code count} times. + */ + 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) with + * {@code replacement} and returns the resulting string. Use + * {@link StringRegexFlags#GLOBAL} to replace all matches. + */ + public static Exp regexReplace( + StringPolicy policy, + Exp pattern, + Exp replacement, + int regexFlags, + Exp src + ) { + byte[] bytes = packRegexReplace(pattern, replacement, regexFlags, policy.flags); + 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. + */ + public static Exp toString(Exp src) { + byte[] bytes = emptyArray(); + 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); + } + + // [cmd, [needle, repl], flags] — needle/replacement nested inside a 2-element list. + private static byte[] packReplace(int command, Exp needle, Exp replacement, int flags) { + Packer packer = new Packer(); + for (int i = 0; i < 2; i++) { + packer.packArrayBegin(3); + packer.packInt(command); + packer.packArrayBegin(2); + needle.pack(packer); + replacement.pack(packer); + packer.packInt(flags); + if (i == 0) packer.createBuffer(); + } + return packer.getBuffer(); + } + + // [REGEX_REPLACE, [pattern, repl], regexFlags, flags] + private static byte[] packRegexReplace(Exp pattern, Exp replacement, int regexFlags, int flags) { + Packer packer = new Packer(); + for (int i = 0; i < 2; i++) { + packer.packArrayBegin(4); + packer.packInt(REGEX_REPLACE); + packer.packArrayBegin(2); + pattern.pack(packer); + replacement.pack(packer); + packer.packInt(regexFlags); + packer.packInt(flags); + if (i == 0) packer.createBuffer(); + } + return packer.getBuffer(); + } + + private static byte[] emptyArray() { + Packer packer = new Packer(); + for (int i = 0; i < 2; i++) { + packer.packArrayBegin(0); + if (i == 0) packer.createBuffer(); + } + return packer.getBuffer(); + } +} diff --git a/client/src/com/aerospike/client/operation/StringNumericType.java b/client/src/com/aerospike/client/operation/StringNumericType.java new file mode 100644 index 000000000..d85626811 --- /dev/null +++ b/client/src/com/aerospike/client/operation/StringNumericType.java @@ -0,0 +1,37 @@ +/* + * 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; + +/** + * Numeric type filter for {@link StringOperation#isNumeric}. + */ +public final class StringNumericType { + /** + * Match either an integer or a floating-point number. + */ + public static final int ANY = 0; + + /** + * Match only integers. + */ + public static final int INT = 1; + + /** + * Match only floating-point numbers. + */ + public static final int FLOAT = 2; +} diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java new file mode 100644 index 000000000..56336e91c --- /dev/null +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -0,0 +1,523 @@ +/* + * 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; + +import java.util.ArrayList; +import java.util.List; + +import com.aerospike.client.Operation; +import com.aerospike.client.Value; +import com.aerospike.client.util.Pack; +import com.aerospike.client.util.Packer; + +/** + * String operations. Create string operations used by the client operate command. + *

+ * Index orientation is left-to-right with codepoint addressing. Negative indexes + * count from the end of the string ({@code -1} = last character). Out-of-bounds + * indexes are clamped to the valid range; no error is returned. + *

+ * String operations require server version 8.1.3 or later. Operations on string + * items nested in lists/maps are not currently supported by the server. + */ +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 B64_ENCODE = 16; + private static final int REGEX_COMPARE = 17; + + // 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; + + //----------------------------------------------------------------- + // Read operations + //----------------------------------------------------------------- + + /** + * Create string {@code strlen} operation. + * Server returns the number of unicode codepoints in the string bin (int64). + */ + public static Operation strlen(String binName) { + byte[] bytes = Pack.pack(STRLEN); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code substr} operation that reads from {@code start} to the end of the string. + * Negative indexes count from the end. + */ + public static Operation substr(String binName, int start) { + byte[] bytes = Pack.pack(SUBSTR, start); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code substr} operation that reads {@code length} codepoints starting at {@code start}. + * Negative indexes count from the end of the string. + */ + public static Operation substr(String binName, int start, int length) { + byte[] bytes = Pack.pack(SUBSTR, start, length); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code charAt} operation. Server returns the character at {@code index} as a string. + * Negative indexes count from the end of the string. + */ + public static Operation charAt(String binName, int index) { + byte[] bytes = Pack.pack(CHAR_AT, index); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code find} operation. Server returns the codepoint index of the first + * occurrence of {@code needle}, or -1 if not found. + */ + public static Operation find(String binName, String needle) { + byte[] bytes = Pack.pack(FIND, Value.get(needle)); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code find} operation. Server returns the codepoint index of the + * {@code occurrence}-th match of {@code needle} (1 = first match), or -1 if not found. + */ + public static Operation find(String binName, String needle, int occurrence) { + byte[] bytes = packCmdValueInt(FIND, Value.get(needle), occurrence); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code contains} operation. Server returns 1 if the bin contains + * {@code needle} as a substring, 0 otherwise. + */ + public static Operation contains(String binName, String needle) { + byte[] bytes = Pack.pack(CONTAINS, Value.get(needle)); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code startsWith} operation. Server returns 1 if the bin begins with + * {@code prefix}, 0 otherwise. + */ + public static Operation startsWith(String binName, String prefix) { + byte[] bytes = Pack.pack(STARTS_WITH, Value.get(prefix)); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code endsWith} operation. Server returns 1 if the bin ends with + * {@code suffix}, 0 otherwise. + */ + public static Operation endsWith(String binName, String suffix) { + byte[] bytes = Pack.pack(ENDS_WITH, Value.get(suffix)); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code toInteger} operation. Server parses the string as an int64. + * Returns AEROSPIKE_ERR_PARAMETER if the bin cannot be parsed as an integer. + */ + public static Operation toInteger(String binName) { + byte[] bytes = Pack.pack(TO_INTEGER); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code toDouble} operation. Server parses the string as a 64-bit float. + * Returns AEROSPIKE_ERR_PARAMETER if the bin cannot be parsed as a double. + */ + public static Operation toDouble(String binName) { + byte[] bytes = Pack.pack(TO_DOUBLE); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code byteLength} operation. Server returns the UTF-8 byte length + * of the string (int64). + */ + public static Operation byteLength(String binName) { + byte[] bytes = Pack.pack(BYTE_LENGTH); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code isNumeric} operation. Server returns 1 if the bin contains a valid + * integer or float, 0 otherwise. + */ + public static Operation isNumeric(String binName) { + byte[] bytes = Pack.pack(IS_NUMERIC); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code isNumeric} operation that filters by {@code numericType} + * (see {@link StringNumericType}). + */ + public static Operation isNumeric(String binName, int numericType) { + byte[] bytes = Pack.pack(IS_NUMERIC, numericType); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code isUpper} operation. Server returns 1 if every cased character + * in the bin is uppercase, 0 otherwise. + */ + public static Operation isUpper(String binName) { + byte[] bytes = Pack.pack(IS_UPPER); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code isLower} operation. Server returns 1 if every cased character + * in the bin is lowercase, 0 otherwise. + */ + public static Operation isLower(String binName) { + byte[] bytes = Pack.pack(IS_LOWER); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code toBlob} operation. Server returns the UTF-8 bytes of the string + * as a blob. + */ + public static Operation toBlob(String binName) { + byte[] bytes = Pack.pack(TO_BLOB); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code split} operation that splits by Unicode codepoint + * (each codepoint becomes its own list element). + */ + public static Operation split(String binName) { + byte[] bytes = Pack.pack(SPLIT); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code split} operation that splits by {@code separator}. + * Server returns a list of strings. + */ + public static Operation split(String binName, String separator) { + byte[] bytes = Pack.pack(SPLIT, Value.get(separator)); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code b64Decode} operation. Server base64-decodes the string and + * returns a blob. + */ + public static Operation b64Decode(String binName) { + byte[] bytes = Pack.pack(B64_DECODE); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code b64Encode} operation. Bin must be a blob; server returns the + * base64-encoded string. Returns AEROSPIKE_ERR_INCOMPATIBLE_TYPE if the bin is a string. + */ + public static Operation b64Encode(String binName) { + byte[] bytes = Pack.pack(B64_ENCODE); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code regexCompare} operation. Server matches {@code pattern} (ICU + * regex syntax) against the bin and returns 1 on match, 0 otherwise. + */ + public static Operation regexCompare(String binName, String pattern) { + byte[] bytes = Pack.pack(REGEX_COMPARE, Value.get(pattern)); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + /** + * Create string {@code regexCompare} operation with {@link StringRegexFlags}. + */ + public static Operation regexCompare(String binName, String pattern, int regexFlags) { + byte[] bytes = packCmdValueInt(REGEX_COMPARE, Value.get(pattern), regexFlags); + return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + } + + //----------------------------------------------------------------- + // Modify operations + //----------------------------------------------------------------- + + /** + * Create string {@code insert} operation that inserts {@code value} at codepoint + * {@code index}. Negative indexes count from the end of the string. + */ + public static Operation insert(StringPolicy policy, String binName, int index, String value) { + byte[] bytes = Pack.pack(INSERT, index, Value.get(value), policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code overwrite} operation that overwrites characters starting at + * codepoint {@code index} with {@code value}. + */ + public static Operation overwrite(StringPolicy policy, String binName, int index, String value) { + byte[] bytes = Pack.pack(OVERWRITE, index, Value.get(value), policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code concat} operation that appends {@code value} to the bin. + */ + public static Operation concat(StringPolicy policy, String binName, String value) { + List list = new ArrayList(1); + list.add(Value.get(value)); + byte[] bytes = Pack.pack(CONCAT, list, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code concat} operation that appends every element of {@code values} + * to the bin in order. + */ + public static Operation concat(StringPolicy policy, String binName, List values) { + List list = toValueList(values); + byte[] bytes = Pack.pack(CONCAT, list, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code snip} operation that removes characters starting at codepoint + * {@code start} through the end of the string. + */ + public static Operation snip(StringPolicy policy, String binName, int start) { + byte[] bytes = Pack.pack(SNIP, start, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code snip} operation that removes characters from codepoint + * {@code start} (inclusive) to {@code end} (exclusive). + */ + public static Operation snip(StringPolicy policy, String binName, int start, int end) { + byte[] bytes = Pack.pack(SNIP, start, end, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code replace} operation that replaces the first occurrence of + * {@code needle} with {@code replacement}. + */ + public static Operation replace(StringPolicy policy, String binName, String needle, String replacement) { + List list = pair(needle, replacement); + byte[] bytes = Pack.pack(REPLACE, list, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code replaceAll} operation that replaces every occurrence of + * {@code needle} with {@code replacement}. + */ + public static Operation replaceAll(StringPolicy policy, String binName, String needle, String replacement) { + List list = pair(needle, replacement); + byte[] bytes = Pack.pack(REPLACE_ALL, list, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code upper} operation that uppercases the bin in place. + */ + public static Operation upper(StringPolicy policy, String binName) { + byte[] bytes = Pack.pack(UPPER, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code lower} operation that lowercases the bin in place. + */ + public static Operation lower(StringPolicy policy, String binName) { + byte[] bytes = Pack.pack(LOWER, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code caseFold} operation. Server applies a locale-independent case + * fold (lowercase) to the bin. + */ + public static Operation caseFold(StringPolicy policy, String binName) { + byte[] bytes = Pack.pack(CASE_FOLD, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code normalizeNFC} operation. Server normalizes the bin to Unicode + * NFC form. + */ + public static Operation normalizeNFC(StringPolicy policy, String binName) { + byte[] bytes = Pack.pack(NORMALIZE_NFC, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code trimStart} operation that removes whitespace from the start + * of the bin. + */ + public static Operation trimStart(StringPolicy policy, String binName) { + byte[] bytes = Pack.pack(TRIM_START, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code trimEnd} operation that removes whitespace from the end of + * the bin. + */ + public static Operation trimEnd(StringPolicy policy, String binName) { + byte[] bytes = Pack.pack(TRIM_END, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code trim} operation that removes whitespace from both ends of + * the bin. + */ + public static Operation trim(StringPolicy policy, String binName) { + byte[] bytes = Pack.pack(TRIM, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code padStart} operation. Server prepends {@code padString} repeatedly + * until the bin reaches {@code targetLength} codepoints. No-op if already at or above + * target length. + */ + public static Operation padStart(StringPolicy policy, String binName, int targetLength, String padString) { + byte[] bytes = Pack.pack(PAD_START, targetLength, Value.get(padString), policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code padEnd} operation. Server appends {@code padString} repeatedly + * until the bin reaches {@code targetLength} codepoints. No-op if already at or above + * target length. + */ + public static Operation padEnd(StringPolicy policy, String binName, int targetLength, String padString) { + byte[] bytes = Pack.pack(PAD_END, targetLength, Value.get(padString), policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code repeat} operation that repeats the bin contents {@code count} + * times. + */ + public static Operation repeat(StringPolicy policy, String binName, int count) { + byte[] bytes = Pack.pack(REPEAT, count, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + /** + * Create string {@code regexReplace} operation that replaces the first match of + * {@code pattern} with {@code replacement}. Use {@link StringRegexFlags#GLOBAL} + * to replace all matches. + */ + public static Operation regexReplace( + StringPolicy policy, + String binName, + String pattern, + String replacement, + int regexFlags + ) { + List list = pair(pattern, replacement); + byte[] bytes = Pack.pack(REGEX_REPLACE, list, regexFlags, policy.flags); + return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + } + + //----------------------------------------------------------------- + // Type conversion + //----------------------------------------------------------------- + + /** + * Create {@code toString} operation that converts an integer, float, string, or blob + * bin to its string representation. Returns AEROSPIKE_ERR_INCOMPATIBLE_TYPE for any + * other bin type. + *

+ * The wire format for this op carries no payload; the bin is referenced solely by + * the operation header. + */ + public static Operation toString(String binName) { + return new Operation(Operation.Type.TO_STRING, binName, Value.getAsNull()); + } + + //----------------------------------------------------------------- + // Private helpers + //----------------------------------------------------------------- + + private static List pair(String a, String b) { + List list = new ArrayList(2); + list.add(Value.get(a)); + list.add(Value.get(b)); + return list; + } + + private static List toValueList(List strings) { + List list = new ArrayList(strings.size()); + for (String s : strings) { + list.add(Value.get(s)); + } + return list; + } + + private static byte[] packCmdValueInt(int command, Value value, int v) { + Packer packer = new Packer(); + for (int i = 0; i < 2; i++) { + packer.packArrayBegin(3); + packer.packInt(command); + value.pack(packer); + packer.packInt(v); + + if (i == 0) { + packer.createBuffer(); + } + } + return packer.getBuffer(); + } +} diff --git a/client/src/com/aerospike/client/operation/StringPolicy.java b/client/src/com/aerospike/client/operation/StringPolicy.java new file mode 100644 index 000000000..c3c82f861 --- /dev/null +++ b/client/src/com/aerospike/client/operation/StringPolicy.java @@ -0,0 +1,52 @@ +/* + * 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. + *

+ * 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/test/src/com/aerospike/test/SuiteSync.java b/test/src/com/aerospike/test/SuiteSync.java index 9b4c9e272..57512c085 100644 --- a/test/src/com/aerospike/test/SuiteSync.java +++ b/test/src/com/aerospike/test/SuiteSync.java @@ -47,11 +47,13 @@ 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.TestQueryRoles; 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.TestStringMasking; import com.aerospike.test.sync.basic.TestTouch; import com.aerospike.test.sync.basic.TestTxn; import com.aerospike.test.sync.basic.TestUDF; @@ -97,11 +99,13 @@ TestOperateHll.class, TestOperateList.class, TestOperateMap.class, + TestOperateString.class, TestPutGet.class, TestQueryRoles.class, TestReplace.class, TestScan.class, TestServerInfo.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..a29bbabed --- /dev/null +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -0,0 +1,571 @@ +/* + * 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.assertTrue; + +import java.util.Arrays; +import java.util.List; + +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.aerospike.client.Bin; +import com.aerospike.client.Key; +import com.aerospike.client.Operation; +import com.aerospike.client.Record; +import com.aerospike.client.operation.StringOperation; +import com.aerospike.client.operation.StringPolicy; +import com.aerospike.client.operation.StringRegexFlags; +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)); + } + + @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 containsReturnsBooleanIntegers() { + put("hello world"); + Record present = operate(StringOperation.contains(BIN, "hello")); + Record absent = operate(StringOperation.contains(BIN, "xyz")); + assertEquals(1L, present.getLong(BIN)); + assertEquals(0L, absent.getLong(BIN)); + } + + @Test + public void startsWithMatchesPrefix() { + put("Hello123World"); + assertEquals(1L, operate(StringOperation.startsWith(BIN, "Hello")).getLong(BIN)); + assertEquals(0L, operate(StringOperation.startsWith(BIN, "World")).getLong(BIN)); + } + + @Test + public void endsWithMatchesSuffix() { + put("Hello123World"); + assertEquals(1L, operate(StringOperation.endsWith(BIN, "World")).getLong(BIN)); + assertEquals(0L, operate(StringOperation.endsWith(BIN, "Hello")).getLong(BIN)); + } + + @Test + public void isUpperOnlyTrueForUppercase() { + put("HELLO"); + assertEquals(1L, operate(StringOperation.isUpper(BIN)).getLong(BIN)); + put("hello"); + assertEquals(0L, operate(StringOperation.isUpper(BIN)).getLong(BIN)); + } + + @Test + public void isLowerOnlyTrueForLowercase() { + put("hello"); + assertEquals(1L, operate(StringOperation.isLower(BIN)).getLong(BIN)); + put("HELLO"); + assertEquals(0L, operate(StringOperation.isLower(BIN)).getLong(BIN)); + } + + @Test + public void isNumericMatchesIntegerStrings() { + put("12345"); + assertEquals(1L, operate(StringOperation.isNumeric(BIN)).getLong(BIN)); + put("Hello123World"); + assertEquals(0L, operate(StringOperation.isNumeric(BIN)).getLong(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"); + assertEquals(1L, operate(StringOperation.regexCompare(BIN, "[0-9]+")).getLong(BIN)); + put("HELLO"); + assertEquals(0L, operate(StringOperation.regexCompare(BIN, "[0-9]+")).getLong(BIN)); + } + + @Test + public void regexCompareHonorsCaseInsensitiveFlag() { + put("HELLO"); + assertEquals(1L, operate(StringOperation.regexCompare( + BIN, "hello", StringRegexFlags.CASE_INSENSITIVE)).getLong(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)); + } + + @Test + public void b64EncodeOnBlobBinReturnsEncodedString() { + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, "hello".getBytes())); + Record r = operate(StringOperation.b64Encode(BIN)); + assertEquals("aGVsbG8=", r.getString(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 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 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 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 0/1. + assertEquals(15L, r.getLong("text")); + assertEquals(12345L, r.getLong("number_str")); + assertEquals(1L, r.getLong("upper_str")); + } + + @Test + public void modifyAndReadInOneOperatePipelineCommitsThenObserves() { + put(" hello world "); + + Record r = operate( + StringOperation.trim(POLICY, BIN), + StringOperation.upper(POLICY, BIN), + StringOperation.strlen(BIN)); + + // strlen runs after trim+upper so it sees the post-modification length. + assertEquals(11L, r.getLong(BIN)); + 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 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); + } + } +} 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..4948c9fc0 --- /dev/null +++ b/test/src/com/aerospike/test/sync/basic/TestStringMasking.java @@ -0,0 +1,407 @@ +/* + * 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.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")); + assertEquals(0L, r.getLong(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")); + assertEquals(0L, sw.getLong(MASKED_BIN)); + assertEquals(0L, ew.getLong(MASKED_BIN)); + } + + @Test + public void unprivilegedRegexCompareOnMaskedBinDoesNotMatchReal() { + Record r = unprivClient.operate(null, KEY, StringOperation.regexCompare(MASKED_BIN, "hello.*")); + assertEquals(0L, r.getLong(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 nodes = Arrays.asList(client.getNodes()); + for (Node node : nodes) { + Info.request(null, node, cmd); + } + // Give the rule time to propagate before exercising it. + try { Thread.sleep(500); } catch (InterruptedException ignored) {} + } + +} From 968e16167dda46e95e1d2cbefb8db0e6faec9bd3 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Wed, 6 May 2026 15:11:35 -0700 Subject: [PATCH 02/19] Updated protocol changes --- .../aerospike/client/command/OperateArgs.java | 2 + .../com/aerospike/client/exp/StringExp.java | 11 +-- .../client/operation/StringOperation.java | 95 +++++++++---------- .../test/sync/basic/TestOperateString.java | 10 +- 4 files changed, 47 insertions(+), 71 deletions(-) diff --git a/client/src/com/aerospike/client/command/OperateArgs.java b/client/src/com/aerospike/client/command/OperateArgs.java index 3a15a5150..64f9a1adc 100644 --- a/client/src/com/aerospike/client/command/OperateArgs.java +++ b/client/src/com/aerospike/client/command/OperateArgs.java @@ -54,6 +54,8 @@ public OperateArgs( // Fall through to read. case CDT_READ: case READ: + case STRING_READ: + case TO_STRING: rattr |= Command.INFO1_READ; // Read all bins if no bin is specified. diff --git a/client/src/com/aerospike/client/exp/StringExp.java b/client/src/com/aerospike/client/exp/StringExp.java index 0b23a670e..5c13d5ab2 100644 --- a/client/src/com/aerospike/client/exp/StringExp.java +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -52,8 +52,7 @@ public final class StringExp { private static final int TO_BLOB = 13; private static final int SPLIT = 14; private static final int B64_DECODE = 15; - private static final int B64_ENCODE = 16; - private static final int REGEX_COMPARE = 17; + private static final int REGEX_COMPARE = 16; // Modify ops private static final int INSERT = 50; @@ -241,14 +240,6 @@ public static Exp b64Decode(Exp src) { return addRead(src, bytes, Exp.Type.BLOB); } - /** - * Create expression that base64-encodes a blob source as a string. - */ - public static Exp b64Encode(Exp src) { - byte[] bytes = Pack.pack(B64_ENCODE); - return addRead(src, bytes, Exp.Type.STRING); - } - /** * Create expression that returns 1 if {@code pattern} (ICU regex) matches the source, 0 otherwise. */ diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index 56336e91c..893c1a1fa 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -21,6 +21,7 @@ import com.aerospike.client.Operation; import com.aerospike.client.Value; +import com.aerospike.client.command.ParticleType; import com.aerospike.client.util.Pack; import com.aerospike.client.util.Packer; @@ -52,8 +53,7 @@ public final class StringOperation { private static final int TO_BLOB = 13; private static final int SPLIT = 14; private static final int B64_DECODE = 15; - private static final int B64_ENCODE = 16; - private static final int REGEX_COMPARE = 17; + private static final int REGEX_COMPARE = 16; // Modify ops private static final int INSERT = 50; @@ -84,7 +84,7 @@ public final class StringOperation { */ public static Operation strlen(String binName) { byte[] bytes = Pack.pack(STRLEN); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -93,7 +93,7 @@ public static Operation strlen(String binName) { */ public static Operation substr(String binName, int start) { byte[] bytes = Pack.pack(SUBSTR, start); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -102,7 +102,7 @@ public static Operation substr(String binName, int start) { */ public static Operation substr(String binName, int start, int length) { byte[] bytes = Pack.pack(SUBSTR, start, length); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -111,7 +111,7 @@ public static Operation substr(String binName, int start, int length) { */ public static Operation charAt(String binName, int index) { byte[] bytes = Pack.pack(CHAR_AT, index); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -120,7 +120,7 @@ public static Operation charAt(String binName, int index) { */ public static Operation find(String binName, String needle) { byte[] bytes = Pack.pack(FIND, Value.get(needle)); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -129,7 +129,7 @@ public static Operation find(String binName, String needle) { */ public static Operation find(String binName, String needle, int occurrence) { byte[] bytes = packCmdValueInt(FIND, Value.get(needle), occurrence); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -138,7 +138,7 @@ public static Operation find(String binName, String needle, int occurrence) { */ public static Operation contains(String binName, String needle) { byte[] bytes = Pack.pack(CONTAINS, Value.get(needle)); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -147,7 +147,7 @@ public static Operation contains(String binName, String needle) { */ public static Operation startsWith(String binName, String prefix) { byte[] bytes = Pack.pack(STARTS_WITH, Value.get(prefix)); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -156,7 +156,7 @@ public static Operation startsWith(String binName, String prefix) { */ public static Operation endsWith(String binName, String suffix) { byte[] bytes = Pack.pack(ENDS_WITH, Value.get(suffix)); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -165,7 +165,7 @@ public static Operation endsWith(String binName, String suffix) { */ public static Operation toInteger(String binName) { byte[] bytes = Pack.pack(TO_INTEGER); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -174,7 +174,7 @@ public static Operation toInteger(String binName) { */ public static Operation toDouble(String binName) { byte[] bytes = Pack.pack(TO_DOUBLE); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -183,7 +183,7 @@ public static Operation toDouble(String binName) { */ public static Operation byteLength(String binName) { byte[] bytes = Pack.pack(BYTE_LENGTH); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -192,7 +192,7 @@ public static Operation byteLength(String binName) { */ public static Operation isNumeric(String binName) { byte[] bytes = Pack.pack(IS_NUMERIC); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -201,7 +201,7 @@ public static Operation isNumeric(String binName) { */ public static Operation isNumeric(String binName, int numericType) { byte[] bytes = Pack.pack(IS_NUMERIC, numericType); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -210,7 +210,7 @@ public static Operation isNumeric(String binName, int numericType) { */ public static Operation isUpper(String binName) { byte[] bytes = Pack.pack(IS_UPPER); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -219,7 +219,7 @@ public static Operation isUpper(String binName) { */ public static Operation isLower(String binName) { byte[] bytes = Pack.pack(IS_LOWER); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -228,7 +228,7 @@ public static Operation isLower(String binName) { */ public static Operation toBlob(String binName) { byte[] bytes = Pack.pack(TO_BLOB); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -237,7 +237,7 @@ public static Operation toBlob(String binName) { */ public static Operation split(String binName) { byte[] bytes = Pack.pack(SPLIT); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -246,7 +246,7 @@ public static Operation split(String binName) { */ public static Operation split(String binName, String separator) { byte[] bytes = Pack.pack(SPLIT, Value.get(separator)); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -255,16 +255,7 @@ public static Operation split(String binName, String separator) { */ public static Operation b64Decode(String binName) { byte[] bytes = Pack.pack(B64_DECODE); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); - } - - /** - * Create string {@code b64Encode} operation. Bin must be a blob; server returns the - * base64-encoded string. Returns AEROSPIKE_ERR_INCOMPATIBLE_TYPE if the bin is a string. - */ - public static Operation b64Encode(String binName) { - byte[] bytes = Pack.pack(B64_ENCODE); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -273,7 +264,7 @@ public static Operation b64Encode(String binName) { */ public static Operation regexCompare(String binName, String pattern) { byte[] bytes = Pack.pack(REGEX_COMPARE, Value.get(pattern)); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -281,7 +272,7 @@ public static Operation regexCompare(String binName, String pattern) { */ public static Operation regexCompare(String binName, String pattern, int regexFlags) { byte[] bytes = packCmdValueInt(REGEX_COMPARE, Value.get(pattern), regexFlags); - return new Operation(Operation.Type.STRING_READ, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } //----------------------------------------------------------------- @@ -294,7 +285,7 @@ public static Operation regexCompare(String binName, String pattern, int regexFl */ public static Operation insert(StringPolicy policy, String binName, int index, String value) { byte[] bytes = Pack.pack(INSERT, index, Value.get(value), policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -303,7 +294,7 @@ public static Operation insert(StringPolicy policy, String binName, int index, S */ public static Operation overwrite(StringPolicy policy, String binName, int index, String value) { byte[] bytes = Pack.pack(OVERWRITE, index, Value.get(value), policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -313,7 +304,7 @@ public static Operation concat(StringPolicy policy, String binName, String value List list = new ArrayList(1); list.add(Value.get(value)); byte[] bytes = Pack.pack(CONCAT, list, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -323,7 +314,7 @@ public static Operation concat(StringPolicy policy, String binName, String value public static Operation concat(StringPolicy policy, String binName, List values) { List list = toValueList(values); byte[] bytes = Pack.pack(CONCAT, list, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -332,7 +323,7 @@ public static Operation concat(StringPolicy policy, String binName, List */ public static Operation snip(StringPolicy policy, String binName, int start) { byte[] bytes = Pack.pack(SNIP, start, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -341,7 +332,7 @@ public static Operation snip(StringPolicy policy, String binName, int start) { */ public static Operation snip(StringPolicy policy, String binName, int start, int end) { byte[] bytes = Pack.pack(SNIP, start, end, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -351,7 +342,7 @@ public static Operation snip(StringPolicy policy, String binName, int start, int public static Operation replace(StringPolicy policy, String binName, String needle, String replacement) { List list = pair(needle, replacement); byte[] bytes = Pack.pack(REPLACE, list, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -361,7 +352,7 @@ public static Operation replace(StringPolicy policy, String binName, String need public static Operation replaceAll(StringPolicy policy, String binName, String needle, String replacement) { List list = pair(needle, replacement); byte[] bytes = Pack.pack(REPLACE_ALL, list, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -369,7 +360,7 @@ public static Operation replaceAll(StringPolicy policy, String binName, String n */ public static Operation upper(StringPolicy policy, String binName) { byte[] bytes = Pack.pack(UPPER, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -377,7 +368,7 @@ public static Operation upper(StringPolicy policy, String binName) { */ public static Operation lower(StringPolicy policy, String binName) { byte[] bytes = Pack.pack(LOWER, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -386,7 +377,7 @@ public static Operation lower(StringPolicy policy, String binName) { */ public static Operation caseFold(StringPolicy policy, String binName) { byte[] bytes = Pack.pack(CASE_FOLD, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -395,7 +386,7 @@ public static Operation caseFold(StringPolicy policy, String binName) { */ public static Operation normalizeNFC(StringPolicy policy, String binName) { byte[] bytes = Pack.pack(NORMALIZE_NFC, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -404,7 +395,7 @@ public static Operation normalizeNFC(StringPolicy policy, String binName) { */ public static Operation trimStart(StringPolicy policy, String binName) { byte[] bytes = Pack.pack(TRIM_START, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -413,7 +404,7 @@ public static Operation trimStart(StringPolicy policy, String binName) { */ public static Operation trimEnd(StringPolicy policy, String binName) { byte[] bytes = Pack.pack(TRIM_END, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -422,7 +413,7 @@ public static Operation trimEnd(StringPolicy policy, String binName) { */ public static Operation trim(StringPolicy policy, String binName) { byte[] bytes = Pack.pack(TRIM, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -432,7 +423,7 @@ public static Operation trim(StringPolicy policy, String binName) { */ public static Operation padStart(StringPolicy policy, String binName, int targetLength, String padString) { byte[] bytes = Pack.pack(PAD_START, targetLength, Value.get(padString), policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -442,7 +433,7 @@ public static Operation padStart(StringPolicy policy, String binName, int target */ public static Operation padEnd(StringPolicy policy, String binName, int targetLength, String padString) { byte[] bytes = Pack.pack(PAD_END, targetLength, Value.get(padString), policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -451,7 +442,7 @@ public static Operation padEnd(StringPolicy policy, String binName, int targetLe */ public static Operation repeat(StringPolicy policy, String binName, int count) { byte[] bytes = Pack.pack(REPEAT, count, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** @@ -468,7 +459,7 @@ public static Operation regexReplace( ) { List list = pair(pattern, replacement); byte[] bytes = Pack.pack(REGEX_REPLACE, list, regexFlags, policy.flags); - return new Operation(Operation.Type.STRING_MODIFY, binName, Value.get(bytes)); + return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } //----------------------------------------------------------------- diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index a29bbabed..8142ba979 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -55,7 +55,7 @@ public class TestOperateString extends TestSync { public static void serverVersionCheck() { Assume.assumeTrue( "Skipping: string operations require server version 8.1.3 or later", - args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); } //----------------------------------------------------------------- @@ -251,14 +251,6 @@ public void b64DecodeReturnsOriginalBlob() { assertArrayEquals("hello".getBytes(), (byte[])r.getValue(BIN)); } - @Test - public void b64EncodeOnBlobBinReturnsEncodedString() { - client.delete(null, KEY); - client.put(null, KEY, new Bin(BIN, "hello".getBytes())); - Record r = operate(StringOperation.b64Encode(BIN)); - assertEquals("aGVsbG8=", r.getString(BIN)); - } - //================================================================= // Modify operations //================================================================= From ac6e36eb1baad8c0587e93497839b40c796e6428 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Wed, 6 May 2026 16:21:18 -0700 Subject: [PATCH 03/19] Updated api's to fix failing tests --- .../client/operation/StringOperation.java | 29 +++++++------- .../test/sync/basic/TestOperateString.java | 39 ++++++++++--------- .../test/sync/basic/TestStringMasking.java | 11 +++--- 3 files changed, 41 insertions(+), 38 deletions(-) diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index 893c1a1fa..fa0f7d6c6 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -133,8 +133,8 @@ public static Operation find(String binName, String needle, int occurrence) { } /** - * Create string {@code contains} operation. Server returns 1 if the bin contains - * {@code needle} as a substring, 0 otherwise. + * Create string {@code contains} operation. Server returns true if the bin contains + * {@code needle} as a substring, false otherwise. */ public static Operation contains(String binName, String needle) { byte[] bytes = Pack.pack(CONTAINS, Value.get(needle)); @@ -142,8 +142,8 @@ public static Operation contains(String binName, String needle) { } /** - * Create string {@code startsWith} operation. Server returns 1 if the bin begins with - * {@code prefix}, 0 otherwise. + * Create string {@code startsWith} operation. Server returns true if the bin begins with + * {@code prefix}, false otherwise. */ public static Operation startsWith(String binName, String prefix) { byte[] bytes = Pack.pack(STARTS_WITH, Value.get(prefix)); @@ -151,8 +151,8 @@ public static Operation startsWith(String binName, String prefix) { } /** - * Create string {@code endsWith} operation. Server returns 1 if the bin ends with - * {@code suffix}, 0 otherwise. + * Create string {@code endsWith} operation. Server returns true if the bin ends with + * {@code suffix}, false otherwise. */ public static Operation endsWith(String binName, String suffix) { byte[] bytes = Pack.pack(ENDS_WITH, Value.get(suffix)); @@ -187,8 +187,8 @@ public static Operation byteLength(String binName) { } /** - * Create string {@code isNumeric} operation. Server returns 1 if the bin contains a valid - * integer or float, 0 otherwise. + * Create string {@code isNumeric} operation. Server returns true if the bin contains a valid + * integer or float, false otherwise. */ public static Operation isNumeric(String binName) { byte[] bytes = Pack.pack(IS_NUMERIC); @@ -205,8 +205,8 @@ public static Operation isNumeric(String binName, int numericType) { } /** - * Create string {@code isUpper} operation. Server returns 1 if every cased character - * in the bin is uppercase, 0 otherwise. + * Create string {@code isUpper} operation. Server returns true if every cased character + * in the bin is uppercase, false otherwise. */ public static Operation isUpper(String binName) { byte[] bytes = Pack.pack(IS_UPPER); @@ -214,8 +214,8 @@ public static Operation isUpper(String binName) { } /** - * Create string {@code isLower} operation. Server returns 1 if every cased character - * in the bin is lowercase, 0 otherwise. + * Create string {@code isLower} operation. Server returns true if every cased character + * in the bin is lowercase, false otherwise. */ public static Operation isLower(String binName) { byte[] bytes = Pack.pack(IS_LOWER); @@ -260,7 +260,7 @@ public static Operation b64Decode(String binName) { /** * Create string {@code regexCompare} operation. Server matches {@code pattern} (ICU - * regex syntax) against the bin and returns 1 on match, 0 otherwise. + * regex syntax) against the bin and returns true on match, false otherwise. */ public static Operation regexCompare(String binName, String pattern) { byte[] bytes = Pack.pack(REGEX_COMPARE, Value.get(pattern)); @@ -458,7 +458,8 @@ public static Operation regexReplace( int regexFlags ) { List list = pair(pattern, replacement); - byte[] bytes = Pack.pack(REGEX_REPLACE, list, regexFlags, policy.flags); + // Server's regex_replace op table accepts only [list, regexFlags]; no slot for policy flags. + byte[] bytes = Pack.pack(REGEX_REPLACE, list, regexFlags); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index 8142ba979..d1eeb68dd 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import java.util.Arrays; @@ -148,50 +149,50 @@ public void findReturnsMinusOneWhenAbsent() { } @Test - public void containsReturnsBooleanIntegers() { + public void containsReturnsBoolean() { put("hello world"); Record present = operate(StringOperation.contains(BIN, "hello")); Record absent = operate(StringOperation.contains(BIN, "xyz")); - assertEquals(1L, present.getLong(BIN)); - assertEquals(0L, absent.getLong(BIN)); + assertTrue(present.getBoolean(BIN)); + assertFalse(absent.getBoolean(BIN)); } @Test public void startsWithMatchesPrefix() { put("Hello123World"); - assertEquals(1L, operate(StringOperation.startsWith(BIN, "Hello")).getLong(BIN)); - assertEquals(0L, operate(StringOperation.startsWith(BIN, "World")).getLong(BIN)); + assertTrue(operate(StringOperation.startsWith(BIN, "Hello")).getBoolean(BIN)); + assertFalse(operate(StringOperation.startsWith(BIN, "World")).getBoolean(BIN)); } @Test public void endsWithMatchesSuffix() { put("Hello123World"); - assertEquals(1L, operate(StringOperation.endsWith(BIN, "World")).getLong(BIN)); - assertEquals(0L, operate(StringOperation.endsWith(BIN, "Hello")).getLong(BIN)); + assertTrue(operate(StringOperation.endsWith(BIN, "World")).getBoolean(BIN)); + assertFalse(operate(StringOperation.endsWith(BIN, "Hello")).getBoolean(BIN)); } @Test public void isUpperOnlyTrueForUppercase() { put("HELLO"); - assertEquals(1L, operate(StringOperation.isUpper(BIN)).getLong(BIN)); + assertTrue(operate(StringOperation.isUpper(BIN)).getBoolean(BIN)); put("hello"); - assertEquals(0L, operate(StringOperation.isUpper(BIN)).getLong(BIN)); + assertFalse(operate(StringOperation.isUpper(BIN)).getBoolean(BIN)); } @Test public void isLowerOnlyTrueForLowercase() { put("hello"); - assertEquals(1L, operate(StringOperation.isLower(BIN)).getLong(BIN)); + assertTrue(operate(StringOperation.isLower(BIN)).getBoolean(BIN)); put("HELLO"); - assertEquals(0L, operate(StringOperation.isLower(BIN)).getLong(BIN)); + assertFalse(operate(StringOperation.isLower(BIN)).getBoolean(BIN)); } @Test public void isNumericMatchesIntegerStrings() { put("12345"); - assertEquals(1L, operate(StringOperation.isNumeric(BIN)).getLong(BIN)); + assertTrue(operate(StringOperation.isNumeric(BIN)).getBoolean(BIN)); put("Hello123World"); - assertEquals(0L, operate(StringOperation.isNumeric(BIN)).getLong(BIN)); + assertFalse(operate(StringOperation.isNumeric(BIN)).getBoolean(BIN)); } @Test @@ -225,16 +226,16 @@ public void splitWithoutMatchReturnsSingletonList() { @Test public void regexCompareDistinguishesMatchVsMiss() { put("Hello123World"); - assertEquals(1L, operate(StringOperation.regexCompare(BIN, "[0-9]+")).getLong(BIN)); + assertTrue(operate(StringOperation.regexCompare(BIN, "[0-9]+")).getBoolean(BIN)); put("HELLO"); - assertEquals(0L, operate(StringOperation.regexCompare(BIN, "[0-9]+")).getLong(BIN)); + assertFalse(operate(StringOperation.regexCompare(BIN, "[0-9]+")).getBoolean(BIN)); } @Test public void regexCompareHonorsCaseInsensitiveFlag() { put("HELLO"); - assertEquals(1L, operate(StringOperation.regexCompare( - BIN, "hello", StringRegexFlags.CASE_INSENSITIVE)).getLong(BIN)); + assertTrue(operate(StringOperation.regexCompare( + BIN, "hello", StringRegexFlags.CASE_INSENSITIVE)).getBoolean(BIN)); } @Test @@ -516,10 +517,10 @@ public void readsAcrossMultipleBinsInOneOperate() { StringOperation.toInteger("number_str"), StringOperation.isUpper("upper_str")); - // strlen and toInteger return INT; isUpper returns 0/1. + // strlen and toInteger return INT; isUpper returns BOOL. assertEquals(15L, r.getLong("text")); assertEquals(12345L, r.getLong("number_str")); - assertEquals(1L, r.getLong("upper_str")); + assertTrue(r.getBoolean("upper_str")); } @Test diff --git a/test/src/com/aerospike/test/sync/basic/TestStringMasking.java b/test/src/com/aerospike/test/sync/basic/TestStringMasking.java index 4948c9fc0..8db39c740 100644 --- a/test/src/com/aerospike/test/sync/basic/TestStringMasking.java +++ b/test/src/com/aerospike/test/sync/basic/TestStringMasking.java @@ -17,6 +17,7 @@ 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; @@ -85,7 +86,7 @@ public class TestStringMasking extends TestSync { @BeforeClass public static void setupUsersAndRule() { Assume.assumeTrue("Skipping: server version < 8.1.3 (string ops + masking)", - args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); Assume.assumeTrue("Skipping: admin credentials not provided", args.user != null && !args.user.isEmpty() && args.password != null && !args.password.isEmpty()); @@ -183,21 +184,21 @@ public void unprivilegedFindOnMaskedBinDoesNotLocateRealContent() { @Test public void unprivilegedContainsOnMaskedBinIsFalse() { Record r = unprivClient.operate(null, KEY, StringOperation.contains(MASKED_BIN, "hello")); - assertEquals(0L, r.getLong(MASKED_BIN)); + 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")); - assertEquals(0L, sw.getLong(MASKED_BIN)); - assertEquals(0L, ew.getLong(MASKED_BIN)); + 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.*")); - assertEquals(0L, r.getLong(MASKED_BIN)); + assertFalse(r.getBoolean(MASKED_BIN)); } @Test From 95684583b7ee46c16f1173f3961b81553f083768 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Wed, 6 May 2026 16:48:38 -0700 Subject: [PATCH 04/19] Updated StringOperation --- .../client/operation/StringOperation.java | 176 +++++++++--------- 1 file changed, 90 insertions(+), 86 deletions(-) diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index fa0f7d6c6..6a74d681c 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -21,6 +21,7 @@ import com.aerospike.client.Operation; import com.aerospike.client.Value; +import com.aerospike.client.cdt.CTX; import com.aerospike.client.command.ParticleType; import com.aerospike.client.util.Pack; import com.aerospike.client.util.Packer; @@ -32,8 +33,9 @@ * count from the end of the string ({@code -1} = last character). Out-of-bounds * indexes are clamped to the valid range; no error is returned. *

- * String operations require server version 8.1.3 or later. Operations on string - * items nested in lists/maps are not currently supported by the server. + * 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. */ public final class StringOperation { // Read ops @@ -82,8 +84,8 @@ public final class StringOperation { * Create string {@code strlen} operation. * Server returns the number of unicode codepoints in the string bin (int64). */ - public static Operation strlen(String binName) { - byte[] bytes = Pack.pack(STRLEN); + public static Operation strlen(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(STRLEN, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -91,8 +93,8 @@ public static Operation strlen(String binName) { * Create string {@code substr} operation that reads from {@code start} to the end of the string. * Negative indexes count from the end. */ - public static Operation substr(String binName, int start) { - byte[] bytes = Pack.pack(SUBSTR, start); + public static Operation substr(String binName, int start, CTX... ctx) { + byte[] bytes = Pack.pack(SUBSTR, start, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -100,8 +102,8 @@ public static Operation substr(String binName, int start) { * Create string {@code substr} operation that reads {@code length} codepoints starting at {@code start}. * Negative indexes count from the end of the string. */ - public static Operation substr(String binName, int start, int length) { - byte[] bytes = Pack.pack(SUBSTR, start, length); + public static Operation substr(String binName, int start, int length, CTX... ctx) { + byte[] bytes = Pack.pack(SUBSTR, start, length, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -109,8 +111,8 @@ public static Operation substr(String binName, int start, int length) { * Create string {@code charAt} operation. Server returns the character at {@code index} as a string. * Negative indexes count from the end of the string. */ - public static Operation charAt(String binName, int index) { - byte[] bytes = Pack.pack(CHAR_AT, index); + public static Operation charAt(String binName, int index, CTX... ctx) { + byte[] bytes = Pack.pack(CHAR_AT, index, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -118,8 +120,8 @@ public static Operation charAt(String binName, int index) { * Create string {@code find} operation. Server returns the codepoint index of the first * occurrence of {@code needle}, or -1 if not found. */ - public static Operation find(String binName, String needle) { - byte[] bytes = Pack.pack(FIND, Value.get(needle)); + public static Operation find(String binName, String needle, CTX... ctx) { + byte[] bytes = Pack.pack(FIND, Value.get(needle), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -127,8 +129,8 @@ public static Operation find(String binName, String needle) { * Create string {@code find} operation. Server returns the codepoint index of the * {@code occurrence}-th match of {@code needle} (1 = first match), or -1 if not found. */ - public static Operation find(String binName, String needle, int occurrence) { - byte[] bytes = packCmdValueInt(FIND, Value.get(needle), occurrence); + public static Operation find(String binName, String needle, int occurrence, CTX... ctx) { + byte[] bytes = packCmdValueInt(FIND, Value.get(needle), occurrence, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -136,8 +138,8 @@ public static Operation find(String binName, String needle, int occurrence) { * Create string {@code contains} operation. Server returns true if the bin contains * {@code needle} as a substring, false otherwise. */ - public static Operation contains(String binName, String needle) { - byte[] bytes = Pack.pack(CONTAINS, Value.get(needle)); + public static Operation contains(String binName, String needle, CTX... ctx) { + byte[] bytes = Pack.pack(CONTAINS, Value.get(needle), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -145,8 +147,8 @@ public static Operation contains(String binName, String needle) { * Create string {@code startsWith} operation. Server returns true if the bin begins with * {@code prefix}, false otherwise. */ - public static Operation startsWith(String binName, String prefix) { - byte[] bytes = Pack.pack(STARTS_WITH, Value.get(prefix)); + public static Operation startsWith(String binName, String prefix, CTX... ctx) { + byte[] bytes = Pack.pack(STARTS_WITH, Value.get(prefix), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -154,8 +156,8 @@ public static Operation startsWith(String binName, String prefix) { * Create string {@code endsWith} operation. Server returns true if the bin ends with * {@code suffix}, false otherwise. */ - public static Operation endsWith(String binName, String suffix) { - byte[] bytes = Pack.pack(ENDS_WITH, Value.get(suffix)); + public static Operation endsWith(String binName, String suffix, CTX... ctx) { + byte[] bytes = Pack.pack(ENDS_WITH, Value.get(suffix), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -163,8 +165,8 @@ public static Operation endsWith(String binName, String suffix) { * Create string {@code toInteger} operation. Server parses the string as an int64. * Returns AEROSPIKE_ERR_PARAMETER if the bin cannot be parsed as an integer. */ - public static Operation toInteger(String binName) { - byte[] bytes = Pack.pack(TO_INTEGER); + public static Operation toInteger(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(TO_INTEGER, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -172,8 +174,8 @@ public static Operation toInteger(String binName) { * Create string {@code toDouble} operation. Server parses the string as a 64-bit float. * Returns AEROSPIKE_ERR_PARAMETER if the bin cannot be parsed as a double. */ - public static Operation toDouble(String binName) { - byte[] bytes = Pack.pack(TO_DOUBLE); + public static Operation toDouble(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(TO_DOUBLE, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -181,8 +183,8 @@ public static Operation toDouble(String binName) { * Create string {@code byteLength} operation. Server returns the UTF-8 byte length * of the string (int64). */ - public static Operation byteLength(String binName) { - byte[] bytes = Pack.pack(BYTE_LENGTH); + public static Operation byteLength(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(BYTE_LENGTH, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -190,8 +192,8 @@ public static Operation byteLength(String binName) { * Create string {@code isNumeric} operation. Server returns true if the bin contains a valid * integer or float, false otherwise. */ - public static Operation isNumeric(String binName) { - byte[] bytes = Pack.pack(IS_NUMERIC); + public static Operation isNumeric(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(IS_NUMERIC, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -199,8 +201,8 @@ public static Operation isNumeric(String binName) { * Create string {@code isNumeric} operation that filters by {@code numericType} * (see {@link StringNumericType}). */ - public static Operation isNumeric(String binName, int numericType) { - byte[] bytes = Pack.pack(IS_NUMERIC, numericType); + public static Operation isNumeric(String binName, int numericType, CTX... ctx) { + byte[] bytes = Pack.pack(IS_NUMERIC, numericType, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -208,8 +210,8 @@ public static Operation isNumeric(String binName, int numericType) { * Create string {@code isUpper} operation. Server returns true if every cased character * in the bin is uppercase, false otherwise. */ - public static Operation isUpper(String binName) { - byte[] bytes = Pack.pack(IS_UPPER); + public static Operation isUpper(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(IS_UPPER, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -217,8 +219,8 @@ public static Operation isUpper(String binName) { * Create string {@code isLower} operation. Server returns true if every cased character * in the bin is lowercase, false otherwise. */ - public static Operation isLower(String binName) { - byte[] bytes = Pack.pack(IS_LOWER); + public static Operation isLower(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(IS_LOWER, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -226,8 +228,8 @@ public static Operation isLower(String binName) { * Create string {@code toBlob} operation. Server returns the UTF-8 bytes of the string * as a blob. */ - public static Operation toBlob(String binName) { - byte[] bytes = Pack.pack(TO_BLOB); + public static Operation toBlob(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(TO_BLOB, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -235,8 +237,8 @@ public static Operation toBlob(String binName) { * Create string {@code split} operation that splits by Unicode codepoint * (each codepoint becomes its own list element). */ - public static Operation split(String binName) { - byte[] bytes = Pack.pack(SPLIT); + public static Operation split(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(SPLIT, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -244,8 +246,8 @@ public static Operation split(String binName) { * Create string {@code split} operation that splits by {@code separator}. * Server returns a list of strings. */ - public static Operation split(String binName, String separator) { - byte[] bytes = Pack.pack(SPLIT, Value.get(separator)); + public static Operation split(String binName, String separator, CTX... ctx) { + byte[] bytes = Pack.pack(SPLIT, Value.get(separator), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -253,8 +255,8 @@ public static Operation split(String binName, String separator) { * Create string {@code b64Decode} operation. Server base64-decodes the string and * returns a blob. */ - public static Operation b64Decode(String binName) { - byte[] bytes = Pack.pack(B64_DECODE); + public static Operation b64Decode(String binName, CTX... ctx) { + byte[] bytes = Pack.pack(B64_DECODE, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -262,16 +264,16 @@ public static Operation b64Decode(String binName) { * Create string {@code regexCompare} operation. Server matches {@code pattern} (ICU * regex syntax) against the bin and returns true on match, false otherwise. */ - public static Operation regexCompare(String binName, String pattern) { - byte[] bytes = Pack.pack(REGEX_COMPARE, Value.get(pattern)); + public static Operation regexCompare(String binName, String pattern, CTX... ctx) { + byte[] bytes = Pack.pack(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 with {@link StringRegexFlags}. */ - public static Operation regexCompare(String binName, String pattern, int regexFlags) { - byte[] bytes = packCmdValueInt(REGEX_COMPARE, Value.get(pattern), regexFlags); + public static Operation regexCompare(String binName, String pattern, int regexFlags, CTX... ctx) { + byte[] bytes = packCmdValueInt(REGEX_COMPARE, Value.get(pattern), regexFlags, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -283,8 +285,8 @@ public static Operation regexCompare(String binName, String pattern, int regexFl * Create string {@code insert} operation that inserts {@code value} at codepoint * {@code index}. Negative indexes count from the end of the string. */ - public static Operation insert(StringPolicy policy, String binName, int index, String value) { - byte[] bytes = Pack.pack(INSERT, index, Value.get(value), policy.flags); + public static Operation insert(StringPolicy policy, String binName, int index, String value, CTX... ctx) { + byte[] bytes = Pack.pack(INSERT, index, Value.get(value), policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -292,18 +294,18 @@ public static Operation insert(StringPolicy policy, String binName, int index, S * Create string {@code overwrite} operation that overwrites characters starting at * codepoint {@code index} with {@code value}. */ - public static Operation overwrite(StringPolicy policy, String binName, int index, String value) { - byte[] bytes = Pack.pack(OVERWRITE, index, Value.get(value), policy.flags); + public static Operation overwrite(StringPolicy policy, String binName, int index, String value, CTX... ctx) { + byte[] bytes = Pack.pack(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. */ - public static Operation concat(StringPolicy policy, String binName, String value) { + public static Operation concat(StringPolicy policy, String binName, String value, CTX... ctx) { List list = new ArrayList(1); list.add(Value.get(value)); - byte[] bytes = Pack.pack(CONCAT, list, policy.flags); + byte[] bytes = Pack.pack(CONCAT, list, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -311,9 +313,9 @@ public static Operation concat(StringPolicy policy, String binName, String value * Create string {@code concat} operation that appends every element of {@code values} * to the bin in order. */ - public static Operation concat(StringPolicy policy, String binName, List values) { + public static Operation concat(StringPolicy policy, String binName, List values, CTX... ctx) { List list = toValueList(values); - byte[] bytes = Pack.pack(CONCAT, list, policy.flags); + byte[] bytes = Pack.pack(CONCAT, list, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -321,8 +323,8 @@ public static Operation concat(StringPolicy policy, String binName, List * Create string {@code snip} operation that removes characters starting at codepoint * {@code start} through the end of the string. */ - public static Operation snip(StringPolicy policy, String binName, int start) { - byte[] bytes = Pack.pack(SNIP, start, policy.flags); + public static Operation snip(StringPolicy policy, String binName, int start, CTX... ctx) { + byte[] bytes = Pack.pack(SNIP, start, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -330,8 +332,8 @@ public static Operation snip(StringPolicy policy, String binName, int start) { * Create string {@code snip} operation that removes characters from codepoint * {@code start} (inclusive) to {@code end} (exclusive). */ - public static Operation snip(StringPolicy policy, String binName, int start, int end) { - byte[] bytes = Pack.pack(SNIP, start, end, policy.flags); + public static Operation snip(StringPolicy policy, String binName, int start, int end, CTX... ctx) { + byte[] bytes = Pack.pack(SNIP, start, end, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -339,9 +341,9 @@ public static Operation snip(StringPolicy policy, String binName, int start, int * Create string {@code replace} operation that replaces the first occurrence of * {@code needle} with {@code replacement}. */ - public static Operation replace(StringPolicy policy, String binName, String needle, String replacement) { + public static Operation replace(StringPolicy policy, String binName, String needle, String replacement, CTX... ctx) { List list = pair(needle, replacement); - byte[] bytes = Pack.pack(REPLACE, list, policy.flags); + byte[] bytes = Pack.pack(REPLACE, list, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -349,25 +351,25 @@ public static Operation replace(StringPolicy policy, String binName, String need * Create string {@code replaceAll} operation that replaces every occurrence of * {@code needle} with {@code replacement}. */ - public static Operation replaceAll(StringPolicy policy, String binName, String needle, String replacement) { + public static Operation replaceAll(StringPolicy policy, String binName, String needle, String replacement, CTX... ctx) { List list = pair(needle, replacement); - byte[] bytes = Pack.pack(REPLACE_ALL, list, policy.flags); + byte[] bytes = Pack.pack(REPLACE_ALL, list, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } /** * Create string {@code upper} operation that uppercases the bin in place. */ - public static Operation upper(StringPolicy policy, String binName) { - byte[] bytes = Pack.pack(UPPER, policy.flags); + public static Operation upper(StringPolicy policy, String binName, CTX... ctx) { + byte[] bytes = Pack.pack(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. */ - public static Operation lower(StringPolicy policy, String binName) { - byte[] bytes = Pack.pack(LOWER, policy.flags); + public static Operation lower(StringPolicy policy, String binName, CTX... ctx) { + byte[] bytes = Pack.pack(LOWER, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -375,8 +377,8 @@ public static Operation lower(StringPolicy policy, String binName) { * Create string {@code caseFold} operation. Server applies a locale-independent case * fold (lowercase) to the bin. */ - public static Operation caseFold(StringPolicy policy, String binName) { - byte[] bytes = Pack.pack(CASE_FOLD, policy.flags); + public static Operation caseFold(StringPolicy policy, String binName, CTX... ctx) { + byte[] bytes = Pack.pack(CASE_FOLD, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -384,8 +386,8 @@ public static Operation caseFold(StringPolicy policy, String binName) { * Create string {@code normalizeNFC} operation. Server normalizes the bin to Unicode * NFC form. */ - public static Operation normalizeNFC(StringPolicy policy, String binName) { - byte[] bytes = Pack.pack(NORMALIZE_NFC, policy.flags); + public static Operation normalizeNFC(StringPolicy policy, String binName, CTX... ctx) { + byte[] bytes = Pack.pack(NORMALIZE_NFC, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -393,8 +395,8 @@ public static Operation normalizeNFC(StringPolicy policy, String binName) { * Create string {@code trimStart} operation that removes whitespace from the start * of the bin. */ - public static Operation trimStart(StringPolicy policy, String binName) { - byte[] bytes = Pack.pack(TRIM_START, policy.flags); + public static Operation trimStart(StringPolicy policy, String binName, CTX... ctx) { + byte[] bytes = Pack.pack(TRIM_START, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -402,8 +404,8 @@ public static Operation trimStart(StringPolicy policy, String binName) { * Create string {@code trimEnd} operation that removes whitespace from the end of * the bin. */ - public static Operation trimEnd(StringPolicy policy, String binName) { - byte[] bytes = Pack.pack(TRIM_END, policy.flags); + public static Operation trimEnd(StringPolicy policy, String binName, CTX... ctx) { + byte[] bytes = Pack.pack(TRIM_END, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -411,8 +413,8 @@ public static Operation trimEnd(StringPolicy policy, String binName) { * Create string {@code trim} operation that removes whitespace from both ends of * the bin. */ - public static Operation trim(StringPolicy policy, String binName) { - byte[] bytes = Pack.pack(TRIM, policy.flags); + public static Operation trim(StringPolicy policy, String binName, CTX... ctx) { + byte[] bytes = Pack.pack(TRIM, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -421,8 +423,8 @@ public static Operation trim(StringPolicy policy, String binName) { * until the bin reaches {@code targetLength} codepoints. No-op if already at or above * target length. */ - public static Operation padStart(StringPolicy policy, String binName, int targetLength, String padString) { - byte[] bytes = Pack.pack(PAD_START, targetLength, Value.get(padString), policy.flags); + public static Operation padStart(StringPolicy policy, String binName, int targetLength, String padString, CTX... ctx) { + byte[] bytes = Pack.pack(PAD_START, targetLength, Value.get(padString), policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -431,8 +433,8 @@ public static Operation padStart(StringPolicy policy, String binName, int target * until the bin reaches {@code targetLength} codepoints. No-op if already at or above * target length. */ - public static Operation padEnd(StringPolicy policy, String binName, int targetLength, String padString) { - byte[] bytes = Pack.pack(PAD_END, targetLength, Value.get(padString), policy.flags); + public static Operation padEnd(StringPolicy policy, String binName, int targetLength, String padString, CTX... ctx) { + byte[] bytes = Pack.pack(PAD_END, targetLength, Value.get(padString), policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -440,8 +442,8 @@ public static Operation padEnd(StringPolicy policy, String binName, int targetLe * Create string {@code repeat} operation that repeats the bin contents {@code count} * times. */ - public static Operation repeat(StringPolicy policy, String binName, int count) { - byte[] bytes = Pack.pack(REPEAT, count, policy.flags); + public static Operation repeat(StringPolicy policy, String binName, int count, CTX... ctx) { + byte[] bytes = Pack.pack(REPEAT, count, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -455,11 +457,12 @@ public static Operation regexReplace( String binName, String pattern, String replacement, - int regexFlags + int regexFlags, + CTX... ctx ) { List list = pair(pattern, replacement); // Server's regex_replace op table accepts only [list, regexFlags]; no slot for policy flags. - byte[] bytes = Pack.pack(REGEX_REPLACE, list, regexFlags); + byte[] bytes = Pack.pack(REGEX_REPLACE, list, regexFlags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -473,7 +476,7 @@ public static Operation regexReplace( * other bin type. *

* The wire format for this op carries no payload; the bin is referenced solely by - * the operation header. + * the operation header, so {@link CTX} navigation is not supported. */ public static Operation toString(String binName) { return new Operation(Operation.Type.TO_STRING, binName, Value.getAsNull()); @@ -498,9 +501,10 @@ private static List toValueList(List strings) { return list; } - private static byte[] packCmdValueInt(int command, Value value, int v) { + private static byte[] packCmdValueInt(int command, Value value, int v, CTX[] ctx) { Packer packer = new Packer(); for (int i = 0; i < 2; i++) { + Pack.init(packer, ctx); packer.packArrayBegin(3); packer.packInt(command); value.pack(packer); From aa2ab3f6188a02b4d6374bbc1dd72c8499531eaa Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Thu, 7 May 2026 10:10:59 -0700 Subject: [PATCH 05/19] Updated javadocs for StringOperation and StringExp, pack clena-up --- .../com/aerospike/client/exp/StringExp.java | 595 +++++++++++++-- .../client/operation/StringOperation.java | 684 +++++++++++++++--- .../src/com/aerospike/client/util/Pack.java | 20 + 3 files changed, 1149 insertions(+), 150 deletions(-) diff --git a/client/src/com/aerospike/client/exp/StringExp.java b/client/src/com/aerospike/client/exp/StringExp.java index 5c13d5ab2..6b0dc1123 100644 --- a/client/src/com/aerospike/client/exp/StringExp.java +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -22,14 +22,45 @@ import com.aerospike.client.util.Packer; /** - * String expression generator. See {@link com.aerospike.client.exp.Exp}. + * 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. *

- * The string source argument in these methods is any expression that yields a - * string: a bin reference (e.g. {@link Exp#stringBin(String)}), a string literal - * ({@link Exp#val(String)}), or a nested string expression. Expressions that - * modify a string value return the modified string; the bin is not changed. + * 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 @@ -78,7 +109,22 @@ public final class StringExp { //----------------------------------------------------------------- /** - * Create expression that returns the codepoint length of the source string. + * 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); @@ -86,7 +132,18 @@ public static Exp strlen(Exp src) { } /** - * Create expression that returns the substring from {@code start} to the end of the source. + * 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); @@ -94,7 +151,18 @@ public static Exp substr(Exp start, Exp src) { } /** - * Create expression that returns {@code length} codepoints starting at {@code start}. + * Create expression that returns {@code length} codepoints of {@code src} starting + * at codepoint {@code start}. Negative indexes count from the end. + * + *
{@code
+	 * // "hello world" from 0, length 5 -> "hello"
+	 * Exp head = StringExp.substr(Exp.val(0), Exp.val(5), Exp.stringBin("text"));
+	 * }
+ * + * @param start starting codepoint index (negative counts from end) + * @param length number of codepoints to read (clamped to remaining length) + * @param src source string expression + * @return string-typed expression yielding the substring */ public static Exp substr(Exp start, Exp length, Exp src) { byte[] bytes = Pack.pack(SUBSTR, start, length); @@ -102,7 +170,17 @@ public static Exp substr(Exp start, Exp length, Exp src) { } /** - * Create expression that returns the character at {@code index} as a 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); @@ -110,8 +188,17 @@ public static Exp charAt(Exp index, Exp src) { } /** - * Create expression that returns the codepoint index of the first match of - * {@code needle} in the source, or -1 if not found. + * 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); @@ -120,7 +207,18 @@ public static Exp find(Exp needle, Exp src) { /** * Create expression that returns the codepoint index of the {@code occurrence}-th - * match of {@code needle}, or -1 if not found. + * 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); @@ -128,7 +226,18 @@ public static Exp find(Exp needle, Exp occurrence, Exp src) { } /** - * Create expression that returns 1 if the source contains {@code needle}, 0 otherwise. + * 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); @@ -136,7 +245,16 @@ public static Exp contains(Exp needle, Exp src) { } /** - * Create expression that returns 1 if the source begins with {@code prefix}, 0 otherwise. + * 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); @@ -144,7 +262,16 @@ public static Exp startsWith(Exp prefix, Exp src) { } /** - * Create expression that returns 1 if the source ends with {@code suffix}, 0 otherwise. + * 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); @@ -152,7 +279,16 @@ public static Exp endsWith(Exp suffix, Exp src) { } /** - * Create expression that parses the source as int64. + * 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); @@ -160,7 +296,16 @@ public static Exp toInteger(Exp src) { } /** - * Create expression that parses the source as a 64-bit float. + * 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); @@ -168,7 +313,17 @@ public static Exp toDouble(Exp src) { } /** - * Create expression that returns the UTF-8 byte length of the source. + * 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); @@ -176,7 +331,15 @@ public static Exp byteLength(Exp src) { } /** - * Create expression that returns 1 if the source parses as a number, 0 otherwise. + * 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); @@ -184,8 +347,18 @@ public static Exp isNumeric(Exp src) { } /** - * Create expression that returns 1 if the source parses as a number of the requested - * {@link com.aerospike.client.operation.StringNumericType}, 0 otherwise. + * 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); @@ -193,7 +366,15 @@ public static Exp isNumeric(int numericType, Exp src) { } /** - * Create expression that returns 1 if every cased character in the source is uppercase. + * 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); @@ -201,7 +382,15 @@ public static Exp isUpper(Exp src) { } /** - * Create expression that returns 1 if every cased character in the source is lowercase. + * 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); @@ -209,7 +398,15 @@ public static Exp isLower(Exp src) { } /** - * Create expression that returns the UTF-8 bytes of the source as a blob. + * 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); @@ -217,7 +414,16 @@ public static Exp toBlob(Exp src) { } /** - * Create expression that splits the source by Unicode codepoint and returns a list of strings. + * 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); @@ -225,7 +431,18 @@ public static Exp split(Exp src) { } /** - * Create expression that splits the source by {@code separator} and returns a list of strings. + * 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); @@ -233,7 +450,16 @@ public static Exp split(Exp separator, Exp src) { } /** - * Create expression that base64-decodes the source and returns a blob. + * 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); @@ -241,7 +467,17 @@ public static Exp b64Decode(Exp src) { } /** - * Create expression that returns 1 if {@code pattern} (ICU regex) matches the source, 0 otherwise. + * 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); @@ -249,8 +485,20 @@ public static Exp regexCompare(Exp pattern, Exp src) { } /** - * Create expression that returns 1 if {@code pattern} (ICU regex) matches the source under - * {@link StringRegexFlags}, 0 otherwise. + * 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); @@ -262,8 +510,21 @@ public static Exp regexCompare(Exp pattern, int regexFlags, Exp src) { //----------------------------------------------------------------- /** - * Create expression that inserts {@code value} at codepoint {@code index} of the source - * and returns the resulting string. + * 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); @@ -271,8 +532,22 @@ public static Exp insert(StringPolicy policy, Exp index, Exp value, Exp src) { } /** - * Create expression that overwrites characters starting at codepoint {@code index} with - * {@code value} and returns the resulting string. + * 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); @@ -280,9 +555,21 @@ public static Exp overwrite(StringPolicy policy, Exp index, Exp value, Exp src) } /** - * Create expression that concatenates {@code values} (a list of strings) onto the source - * and returns the resulting string. Single-string callers can wrap their value in a - * 1-element list via {@link Exp#val(java.util.List)}. + * 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); @@ -290,8 +577,20 @@ public static Exp concat(StringPolicy policy, Exp values, Exp src) { } /** - * Create expression that removes characters from codepoint {@code start} to the end of - * the source and returns the resulting string. + * Create expression that removes codepoints from {@code src} starting at codepoint + * {@code start} through the end, returning the resulting string. Does not modify + * the underlying bin. + * + *
{@code
+	 * // "hello world" snip from 5 -> "hello"
+	 * Exp out = StringExp.snip(StringPolicy.Default,
+	 *     Exp.val(5), Exp.stringBin("text"));
+	 * }
+ * + * @param policy write policy controlling NO_FAIL semantics + * @param start first codepoint to remove (inclusive) + * @param src source string expression + * @return string-typed expression yielding the modified string */ public static Exp snip(StringPolicy policy, Exp start, Exp src) { byte[] bytes = Pack.pack(SNIP, start, policy.flags); @@ -299,8 +598,21 @@ public static Exp snip(StringPolicy policy, Exp start, Exp src) { } /** - * Create expression that removes characters from codepoint {@code start} (inclusive) to - * {@code end} (exclusive) and returns the resulting string. + * 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); @@ -308,8 +620,21 @@ public static Exp snip(StringPolicy policy, Exp start, Exp end, Exp src) { } /** - * Create expression that replaces the first occurrence of {@code needle} with {@code replacement} - * and returns the resulting string. + * 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); @@ -317,8 +642,21 @@ public static Exp replace(StringPolicy policy, Exp needle, Exp replacement, Exp } /** - * Create expression that replaces every occurrence of {@code needle} with {@code replacement} - * and returns the resulting string. + * 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); @@ -326,7 +664,16 @@ public static Exp replaceAll(StringPolicy policy, Exp needle, Exp replacement, E } /** - * Create expression that returns the source uppercased. + * 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); @@ -334,7 +681,16 @@ public static Exp upper(StringPolicy policy, Exp src) { } /** - * Create expression that returns the source lowercased. + * 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); @@ -342,7 +698,17 @@ public static Exp lower(StringPolicy policy, Exp src) { } /** - * Create expression that returns the source case-folded (locale-independent lowercase). + * 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); @@ -350,7 +716,16 @@ public static Exp caseFold(StringPolicy policy, Exp src) { } /** - * Create expression that returns the source normalized to Unicode NFC form. + * 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); @@ -358,7 +733,17 @@ public static Exp normalizeNFC(StringPolicy policy, Exp src) { } /** - * Create expression that returns the source with whitespace removed from the start. + * 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); @@ -366,7 +751,17 @@ public static Exp trimStart(StringPolicy policy, Exp src) { } /** - * Create expression that returns the source with whitespace removed from the end. + * 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); @@ -374,7 +769,17 @@ public static Exp trimEnd(StringPolicy policy, Exp src) { } /** - * Create expression that returns the source with whitespace removed from both ends. + * 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); @@ -382,8 +787,21 @@ public static Exp trim(StringPolicy policy, Exp src) { } /** - * Create expression that pads the start of the source to {@code targetLength} codepoints - * using {@code padString}. + * 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); @@ -391,8 +809,21 @@ public static Exp padStart(StringPolicy policy, Exp targetLength, Exp padString, } /** - * Create expression that pads the end of the source to {@code targetLength} codepoints - * using {@code padString}. + * 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); @@ -400,7 +831,19 @@ public static Exp padEnd(StringPolicy policy, Exp targetLength, Exp padString, E } /** - * Create expression that returns the source repeated {@code count} times. + * 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); @@ -408,9 +851,24 @@ public static Exp repeat(StringPolicy policy, Exp count, Exp src) { } /** - * Create expression that replaces matches of {@code pattern} (ICU regex) with - * {@code replacement} and returns the resulting string. Use - * {@link StringRegexFlags#GLOBAL} to replace all matches. + * 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 write policy controlling NO_FAIL semantics + * @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, @@ -429,7 +887,16 @@ public static Exp regexReplace( /** * 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. + * {@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 = emptyArray(); @@ -449,6 +916,9 @@ private static Exp addModify(Exp src, byte[] bytes) { } // [cmd, [needle, repl], flags] — needle/replacement nested inside a 2-element list. + // Specialized packing method. Leaving in StringExp instead of moving to Pack since the + // structure is specific to string replace operations and doesn't fit the usual pattern + // of a command followed by a flat list of arguments. private static byte[] packReplace(int command, Exp needle, Exp replacement, int flags) { Packer packer = new Packer(); for (int i = 0; i < 2; i++) { @@ -464,6 +934,9 @@ private static byte[] packReplace(int command, Exp needle, Exp replacement, int } // [REGEX_REPLACE, [pattern, repl], regexFlags, flags] + // Specialized packing method. Leaving in StringExp instead of moving to Pack since the + // structure is specific to string replace operations and doesn't fit the usual pattern + // of a command followed by a flat list of arguments. private static byte[] packRegexReplace(Exp pattern, Exp replacement, int regexFlags, int flags) { Packer packer = new Packer(); for (int i = 0; i < 2; i++) { diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index 6a74d681c..99566424e 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -24,18 +24,30 @@ import com.aerospike.client.cdt.CTX; import com.aerospike.client.command.ParticleType; import com.aerospike.client.util.Pack; -import com.aerospike.client.util.Packer; /** - * String operations. Create string operations used by the client operate command. + * String operations. Create operations to be passed to the client {@code operate} + * command for inspecting and modifying string bins. *

* Index orientation is left-to-right with codepoint addressing. Negative indexes - * count from the end of the string ({@code -1} = last character). Out-of-bounds + * 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 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 @@ -81,8 +93,41 @@ public final class StringOperation { //----------------------------------------------------------------- /** - * Create string {@code strlen} operation. - * Server returns the number of unicode codepoints in the string bin (int64). + * 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 = Pack.pack(STRLEN, ctx); @@ -90,8 +135,19 @@ public static Operation strlen(String binName, CTX... ctx) { } /** - * Create string {@code substr} operation that reads from {@code start} to the end of the string. - * Negative indexes count from the end. + * 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 = Pack.pack(SUBSTR, start, ctx); @@ -99,8 +155,19 @@ public static Operation substr(String binName, int start, CTX... ctx) { } /** - * Create string {@code substr} operation that reads {@code length} codepoints starting at {@code start}. - * Negative indexes count from the end of the string. + * Create string {@code substr} operation that reads {@code length} codepoints + * starting at {@code start}. Negative indexes count from the end of the string. + * + *
{@code
+	 * // "hello world" -> "hello"
+	 * Record r = client.operate(null, key, StringOperation.substr("text", 0, 5));
+	 * }
+ * + * @param binName name of the string bin + * @param start starting codepoint index (negative counts from end) + * @param length number of codepoints to read (clamped to remaining length) + * @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 length, CTX... ctx) { byte[] bytes = Pack.pack(SUBSTR, start, length, ctx); @@ -108,8 +175,19 @@ public static Operation substr(String binName, int start, int length, CTX... ctx } /** - * Create string {@code charAt} operation. Server returns the character at {@code index} as a string. - * Negative indexes count from the end of the 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 = Pack.pack(CHAR_AT, index, ctx); @@ -117,8 +195,19 @@ public static Operation charAt(String binName, int index, CTX... ctx) { } /** - * Create string {@code find} operation. Server returns the codepoint index of the first - * occurrence of {@code needle}, or -1 if not found. + * 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 = Pack.pack(FIND, Value.get(needle), ctx); @@ -126,17 +215,40 @@ public static Operation find(String binName, String needle, CTX... ctx) { } /** - * Create string {@code find} operation. Server returns the codepoint index of the - * {@code occurrence}-th match of {@code needle} (1 = first match), or -1 if not found. + * 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 = packCmdValueInt(FIND, Value.get(needle), occurrence, ctx); + byte[] bytes = Pack.pack(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. Server returns true if the bin contains - * {@code needle} as a substring, false otherwise. + * 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 = Pack.pack(CONTAINS, Value.get(needle), ctx); @@ -144,8 +256,18 @@ public static Operation contains(String binName, String needle, CTX... ctx) { } /** - * Create string {@code startsWith} operation. Server returns true if the bin begins with - * {@code prefix}, false otherwise. + * 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 = Pack.pack(STARTS_WITH, Value.get(prefix), ctx); @@ -153,8 +275,18 @@ public static Operation startsWith(String binName, String prefix, CTX... ctx) { } /** - * Create string {@code endsWith} operation. Server returns true if the bin ends with - * {@code suffix}, false otherwise. + * 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 = Pack.pack(ENDS_WITH, Value.get(suffix), ctx); @@ -162,8 +294,18 @@ public static Operation endsWith(String binName, String suffix, CTX... ctx) { } /** - * Create string {@code toInteger} operation. Server parses the string as an int64. - * Returns AEROSPIKE_ERR_PARAMETER if the bin cannot be parsed as an integer. + * 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 = Pack.pack(TO_INTEGER, ctx); @@ -171,8 +313,18 @@ public static Operation toInteger(String binName, CTX... ctx) { } /** - * Create string {@code toDouble} operation. Server parses the string as a 64-bit float. - * Returns AEROSPIKE_ERR_PARAMETER if the bin cannot be parsed as a double. + * 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 = Pack.pack(TO_DOUBLE, ctx); @@ -180,8 +332,18 @@ public static Operation toDouble(String binName, CTX... ctx) { } /** - * Create string {@code byteLength} operation. Server returns the UTF-8 byte length - * of the string (int64). + * 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 = Pack.pack(BYTE_LENGTH, ctx); @@ -189,8 +351,18 @@ public static Operation byteLength(String binName, CTX... ctx) { } /** - * Create string {@code isNumeric} operation. Server returns true if the bin contains a valid - * integer or float, false otherwise. + * 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 = Pack.pack(IS_NUMERIC, ctx); @@ -199,7 +371,19 @@ public static Operation isNumeric(String binName, CTX... ctx) { /** * Create string {@code isNumeric} operation that filters by {@code numericType} - * (see {@link StringNumericType}). + * (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 = Pack.pack(IS_NUMERIC, numericType, ctx); @@ -207,8 +391,17 @@ public static Operation isNumeric(String binName, int numericType, CTX... ctx) { } /** - * Create string {@code isUpper} operation. Server returns true if every cased character - * in the bin is uppercase, false otherwise. + * 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 = Pack.pack(IS_UPPER, ctx); @@ -216,8 +409,17 @@ public static Operation isUpper(String binName, CTX... ctx) { } /** - * Create string {@code isLower} operation. Server returns true if every cased character - * in the bin is lowercase, false otherwise. + * 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 = Pack.pack(IS_LOWER, ctx); @@ -225,8 +427,18 @@ public static Operation isLower(String binName, CTX... ctx) { } /** - * Create string {@code toBlob} operation. Server returns the UTF-8 bytes of the string - * as a blob. + * 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 = Pack.pack(TO_BLOB, ctx); @@ -234,8 +446,18 @@ public static Operation toBlob(String binName, CTX... ctx) { } /** - * Create string {@code split} operation that splits by Unicode codepoint - * (each codepoint becomes its own list element). + * 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 = Pack.pack(SPLIT, ctx); @@ -243,8 +465,19 @@ public static Operation split(String binName, CTX... ctx) { } /** - * Create string {@code split} operation that splits by {@code separator}. - * Server returns a list of strings. + * 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 = Pack.pack(SPLIT, Value.get(separator), ctx); @@ -252,8 +485,18 @@ public static Operation split(String binName, String separator, CTX... ctx) { } /** - * Create string {@code b64Decode} operation. Server base64-decodes the string and - * returns a blob. + * 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 = Pack.pack(B64_DECODE, ctx); @@ -261,8 +504,19 @@ public static Operation b64Decode(String binName, CTX... ctx) { } /** - * Create string {@code regexCompare} operation. Server matches {@code pattern} (ICU - * regex syntax) against the bin and returns true on match, false otherwise. + * 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 = Pack.pack(REGEX_COMPARE, Value.get(pattern), ctx); @@ -270,10 +524,24 @@ public static Operation regexCompare(String binName, String pattern, CTX... ctx) } /** - * Create string {@code regexCompare} operation with {@link StringRegexFlags}. + * 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 = packCmdValueInt(REGEX_COMPARE, Value.get(pattern), regexFlags, ctx); + byte[] bytes = Pack.pack(REGEX_COMPARE, Value.get(pattern), regexFlags, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -282,8 +550,21 @@ public static Operation regexCompare(String binName, String pattern, int regexFl //----------------------------------------------------------------- /** - * Create string {@code insert} operation that inserts {@code value} at codepoint - * {@code index}. Negative indexes count from the end of the string. + * 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 = Pack.pack(INSERT, index, Value.get(value), policy.flags, ctx); @@ -291,8 +572,22 @@ public static Operation insert(StringPolicy policy, String binName, int index, S } /** - * Create string {@code overwrite} operation that overwrites characters starting at - * codepoint {@code index} with {@code value}. + * 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 = Pack.pack(OVERWRITE, index, Value.get(value), policy.flags, ctx); @@ -301,6 +596,18 @@ public static Operation overwrite(StringPolicy policy, String binName, int index /** * 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 list = new ArrayList(1); @@ -310,8 +617,20 @@ public static Operation concat(StringPolicy policy, String binName, String value } /** - * Create string {@code concat} operation that appends every element of {@code values} + * Create string {@code concat} operation that appends each element of {@code values} * to the bin in order. + * + *
{@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 values, CTX... ctx) { List list = toValueList(values); @@ -320,8 +639,20 @@ public static Operation concat(StringPolicy policy, String binName, List } /** - * Create string {@code snip} operation that removes characters starting at codepoint - * {@code start} through the end of the string. + * Create string {@code snip} operation that removes codepoints starting at + * codepoint {@code start} through the end of the string. + * + *
{@code
+	 * // "hello world" snip from 5 -> "hello"
+	 * client.operate(null, key,
+	 *     StringOperation.snip(StringPolicy.Default, "text", 5));
+	 * }
+ * + * @param policy write policy controlling NO_FAIL semantics + * @param binName name of the string bin + * @param start first codepoint to remove (inclusive) + * @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, CTX... ctx) { byte[] bytes = Pack.pack(SNIP, start, policy.flags, ctx); @@ -329,8 +660,21 @@ public static Operation snip(StringPolicy policy, String binName, int start, CTX } /** - * Create string {@code snip} operation that removes characters from codepoint - * {@code start} (inclusive) to {@code end} (exclusive). + * 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 = Pack.pack(SNIP, start, end, policy.flags, ctx); @@ -340,6 +684,19 @@ public static Operation snip(StringPolicy policy, String binName, int start, int /** * 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 list = pair(needle, replacement); @@ -350,6 +707,19 @@ public static Operation replace(StringPolicy policy, String binName, String need /** * Create string {@code replaceAll} operation that replaces every occurrence of * {@code needle} with {@code replacement}. + * + *
{@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 list = pair(needle, replacement); @@ -359,6 +729,16 @@ public static Operation replaceAll(StringPolicy policy, String binName, String n /** * Create string {@code upper} operation that uppercases the bin in place. + * + *
{@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 = Pack.pack(UPPER, policy.flags, ctx); @@ -367,6 +747,16 @@ public static Operation upper(StringPolicy policy, String binName, CTX... ctx) { /** * 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 = Pack.pack(LOWER, policy.flags, ctx); @@ -374,8 +764,18 @@ public static Operation lower(StringPolicy policy, String binName, CTX... ctx) { } /** - * Create string {@code caseFold} operation. Server applies a locale-independent case - * fold (lowercase) to the bin. + * 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 = Pack.pack(CASE_FOLD, policy.flags, ctx); @@ -383,8 +783,18 @@ public static Operation caseFold(StringPolicy policy, String binName, CTX... ctx } /** - * Create string {@code normalizeNFC} operation. Server normalizes the bin to Unicode - * NFC form. + * 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 = Pack.pack(NORMALIZE_NFC, policy.flags, ctx); @@ -394,6 +804,17 @@ public static Operation normalizeNFC(StringPolicy policy, String binName, CTX... /** * 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 = Pack.pack(TRIM_START, policy.flags, ctx); @@ -403,6 +824,17 @@ public static Operation trimStart(StringPolicy policy, String binName, CTX... ct /** * 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 = Pack.pack(TRIM_END, policy.flags, ctx); @@ -412,6 +844,17 @@ public static Operation trimEnd(StringPolicy policy, String binName, CTX... ctx) /** * 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 = Pack.pack(TRIM, policy.flags, ctx); @@ -419,9 +862,22 @@ public static Operation trim(StringPolicy policy, String binName, CTX... ctx) { } /** - * Create string {@code padStart} operation. Server prepends {@code padString} repeatedly - * until the bin reaches {@code targetLength} codepoints. No-op if already at or above - * target length. + * 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 = Pack.pack(PAD_START, targetLength, Value.get(padString), policy.flags, ctx); @@ -429,9 +885,22 @@ public static Operation padStart(StringPolicy policy, String binName, int target } /** - * Create string {@code padEnd} operation. Server appends {@code padString} repeatedly - * until the bin reaches {@code targetLength} codepoints. No-op if already at or above - * target length. + * 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 = Pack.pack(PAD_END, targetLength, Value.get(padString), policy.flags, ctx); @@ -441,6 +910,18 @@ public static Operation padEnd(StringPolicy policy, String binName, int targetLe /** * 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 = Pack.pack(REPEAT, count, policy.flags, ctx); @@ -449,8 +930,25 @@ public static Operation repeat(StringPolicy policy, String binName, int count, C /** * Create string {@code regexReplace} operation that replaces the first match of - * {@code pattern} with {@code replacement}. Use {@link StringRegexFlags#GLOBAL} - * to replace all matches. + * {@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, @@ -471,12 +969,35 @@ public static Operation regexReplace( //----------------------------------------------------------------- /** - * Create {@code toString} operation that converts an integer, float, string, or blob - * bin to its string representation. Returns AEROSPIKE_ERR_INCOMPATIBLE_TYPE for any - * other bin type. + * Create {@code toString} operation that converts an integer, float, string, or + * blob bin to its string representation. Returns + * {@code AEROSPIKE_ERR_INCOMPATIBLE_TYPE} for any other bin type. *

- * The wire format for this op carries no payload; the bin is referenced solely by - * the operation header, so {@link CTX} navigation is not supported. + * 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()); @@ -501,19 +1022,4 @@ private static List toValueList(List strings) { return list; } - private static byte[] packCmdValueInt(int command, Value value, int v, CTX[] ctx) { - Packer packer = new Packer(); - for (int i = 0; i < 2; i++) { - Pack.init(packer, ctx); - packer.packArrayBegin(3); - packer.packInt(command); - value.pack(packer); - packer.packInt(v); - - if (i == 0) { - packer.createBuffer(); - } - } - return packer.getBuffer(); - } } diff --git a/client/src/com/aerospike/client/util/Pack.java b/client/src/com/aerospike/client/util/Pack.java index b77152901..a532bf14c 100644 --- a/client/src/com/aerospike/client/util/Pack.java +++ b/client/src/com/aerospike/client/util/Pack.java @@ -342,6 +342,26 @@ public static byte[] pack(int command, Value value, CTX... ctx) { return packer.getBuffer(); } + 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(); From a7c101bd86de721a9f99754de2d91c95b2b557ee Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Mon, 11 May 2026 12:01:33 -0700 Subject: [PATCH 06/19] Removed policy from call since server is ignoring it. Also updated comment to contain relevant information. --- .../com/aerospike/client/exp/StringExp.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/client/src/com/aerospike/client/exp/StringExp.java b/client/src/com/aerospike/client/exp/StringExp.java index 6b0dc1123..8d3bd40e7 100644 --- a/client/src/com/aerospike/client/exp/StringExp.java +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -863,7 +863,9 @@ public static Exp repeat(StringPolicy policy, Exp count, Exp src) { * Exp.stringBin("text")); * } * - * @param policy write policy controlling NO_FAIL semantics + * @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 @@ -877,7 +879,7 @@ public static Exp regexReplace( int regexFlags, Exp src ) { - byte[] bytes = packRegexReplace(pattern, replacement, regexFlags, policy.flags); + byte[] bytes = packRegexReplace(pattern, replacement, regexFlags); return addModify(src, bytes); } @@ -933,20 +935,21 @@ private static byte[] packReplace(int command, Exp needle, Exp replacement, int return packer.getBuffer(); } - // [REGEX_REPLACE, [pattern, repl], regexFlags, flags] - // Specialized packing method. Leaving in StringExp instead of moving to Pack since the - // structure is specific to string replace operations and doesn't fit the usual pattern - // of a command followed by a flat list of arguments. - private static byte[] packRegexReplace(Exp pattern, Exp replacement, int regexFlags, int flags) { + // [REGEX_REPLACE, [pattern, repl], regexFlags] — 3 elements. + // Server's regex_replace op table accepts only [list, regexFlags]; no slot for + // policy flags (max_args=2 in particle_string.c:476). Specialized packing method + // kept in StringExp instead of moving to Pack since the structure is specific to + // string replace operations and doesn't fit the usual pattern of a command + // followed by a flat list of arguments. + private static byte[] packRegexReplace(Exp pattern, Exp replacement, int regexFlags) { Packer packer = new Packer(); for (int i = 0; i < 2; i++) { - packer.packArrayBegin(4); + packer.packArrayBegin(3); packer.packInt(REGEX_REPLACE); packer.packArrayBegin(2); pattern.pack(packer); replacement.pack(packer); packer.packInt(regexFlags); - packer.packInt(flags); if (i == 0) packer.createBuffer(); } return packer.getBuffer(); From 531e2cb0f5baf688edc6211cf56aaae1b022792a Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Wed, 20 May 2026 13:45:02 -0700 Subject: [PATCH 07/19] Added TestStringExp --- .../client/operation/StringOperation.java | 237 +++++++-- test/src/com/aerospike/test/SuiteSync.java | 2 + .../test/sync/basic/TestOperateString.java | 196 ++++++++ .../test/sync/basic/TestStringExp.java | 472 ++++++++++++++++++ 4 files changed, 866 insertions(+), 41 deletions(-) create mode 100644 test/src/com/aerospike/test/sync/basic/TestStringExp.java diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index 99566424e..b73844596 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -24,6 +24,7 @@ import com.aerospike.client.cdt.CTX; import com.aerospike.client.command.ParticleType; import com.aerospike.client.util.Pack; +import com.aerospike.client.util.Packer; /** * String operations. Create operations to be passed to the client {@code operate} @@ -130,7 +131,7 @@ public final class StringOperation { * @return read operation returning the codepoint count (int64) */ public static Operation strlen(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(STRLEN, ctx); + byte[] bytes = packStringOp(STRLEN, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -150,7 +151,7 @@ public static Operation strlen(String binName, CTX... ctx) { * @return read operation returning the substring */ public static Operation substr(String binName, int start, CTX... ctx) { - byte[] bytes = Pack.pack(SUBSTR, start, ctx); + byte[] bytes = packStringOp(SUBSTR, start, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -170,7 +171,7 @@ public static Operation substr(String binName, int start, CTX... ctx) { * @return read operation returning the substring */ public static Operation substr(String binName, int start, int length, CTX... ctx) { - byte[] bytes = Pack.pack(SUBSTR, start, length, ctx); + byte[] bytes = packStringOp(SUBSTR, start, length, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -190,7 +191,7 @@ public static Operation substr(String binName, int start, int length, CTX... ctx * @return read operation returning a single-codepoint string */ public static Operation charAt(String binName, int index, CTX... ctx) { - byte[] bytes = Pack.pack(CHAR_AT, index, ctx); + byte[] bytes = packStringOp(CHAR_AT, index, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -210,7 +211,7 @@ public static Operation charAt(String binName, int index, CTX... ctx) { * @return read operation returning the codepoint index, or -1 if absent */ public static Operation find(String binName, String needle, CTX... ctx) { - byte[] bytes = Pack.pack(FIND, Value.get(needle), ctx); + byte[] bytes = packStringOp(FIND, Value.get(needle), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -231,7 +232,7 @@ public static Operation find(String binName, String needle, CTX... ctx) { * @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 = Pack.pack(FIND, Value.get(needle), occurrence, ctx); + byte[] bytes = packStringOp(FIND, Value.get(needle), occurrence, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -251,7 +252,7 @@ public static Operation find(String binName, String needle, int occurrence, CTX. * @return read operation returning a boolean match flag */ public static Operation contains(String binName, String needle, CTX... ctx) { - byte[] bytes = Pack.pack(CONTAINS, Value.get(needle), ctx); + byte[] bytes = packStringOp(CONTAINS, Value.get(needle), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -270,7 +271,7 @@ public static Operation contains(String binName, String needle, CTX... ctx) { * @return read operation returning a boolean match flag */ public static Operation startsWith(String binName, String prefix, CTX... ctx) { - byte[] bytes = Pack.pack(STARTS_WITH, Value.get(prefix), ctx); + byte[] bytes = packStringOp(STARTS_WITH, Value.get(prefix), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -289,7 +290,7 @@ public static Operation startsWith(String binName, String prefix, CTX... ctx) { * @return read operation returning a boolean match flag */ public static Operation endsWith(String binName, String suffix, CTX... ctx) { - byte[] bytes = Pack.pack(ENDS_WITH, Value.get(suffix), ctx); + byte[] bytes = packStringOp(ENDS_WITH, Value.get(suffix), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -308,7 +309,7 @@ public static Operation endsWith(String binName, String suffix, CTX... ctx) { * @return read operation returning the parsed int64 */ public static Operation toInteger(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(TO_INTEGER, ctx); + byte[] bytes = packStringOp(TO_INTEGER, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -327,7 +328,7 @@ public static Operation toInteger(String binName, CTX... ctx) { * @return read operation returning the parsed double */ public static Operation toDouble(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(TO_DOUBLE, ctx); + byte[] bytes = packStringOp(TO_DOUBLE, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -346,7 +347,7 @@ public static Operation toDouble(String binName, CTX... ctx) { * @return read operation returning the byte length (int64) */ public static Operation byteLength(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(BYTE_LENGTH, ctx); + byte[] bytes = packStringOp(BYTE_LENGTH, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -365,7 +366,7 @@ public static Operation byteLength(String binName, CTX... ctx) { * @return read operation returning a boolean match flag */ public static Operation isNumeric(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(IS_NUMERIC, ctx); + byte[] bytes = packStringOp(IS_NUMERIC, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -386,7 +387,7 @@ public static Operation isNumeric(String binName, CTX... ctx) { * @return read operation returning a boolean match flag */ public static Operation isNumeric(String binName, int numericType, CTX... ctx) { - byte[] bytes = Pack.pack(IS_NUMERIC, numericType, ctx); + byte[] bytes = packStringOp(IS_NUMERIC, numericType, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -404,7 +405,7 @@ public static Operation isNumeric(String binName, int numericType, CTX... ctx) { * @return read operation returning a boolean match flag */ public static Operation isUpper(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(IS_UPPER, ctx); + byte[] bytes = packStringOp(IS_UPPER, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -422,7 +423,7 @@ public static Operation isUpper(String binName, CTX... ctx) { * @return read operation returning a boolean match flag */ public static Operation isLower(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(IS_LOWER, ctx); + byte[] bytes = packStringOp(IS_LOWER, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -441,7 +442,7 @@ public static Operation isLower(String binName, CTX... ctx) { * @return read operation returning a byte[] blob */ public static Operation toBlob(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(TO_BLOB, ctx); + byte[] bytes = packStringOp(TO_BLOB, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -460,7 +461,7 @@ public static Operation toBlob(String binName, CTX... ctx) { * @return read operation returning a list of single-codepoint strings */ public static Operation split(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(SPLIT, ctx); + byte[] bytes = packStringOp(SPLIT, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -480,7 +481,7 @@ public static Operation split(String binName, CTX... ctx) { * @return read operation returning a list of token strings */ public static Operation split(String binName, String separator, CTX... ctx) { - byte[] bytes = Pack.pack(SPLIT, Value.get(separator), ctx); + byte[] bytes = packStringOp(SPLIT, Value.get(separator), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -499,7 +500,7 @@ public static Operation split(String binName, String separator, CTX... ctx) { * @return read operation returning the decoded byte[] */ public static Operation b64Decode(String binName, CTX... ctx) { - byte[] bytes = Pack.pack(B64_DECODE, ctx); + byte[] bytes = packStringOp(B64_DECODE, ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -519,7 +520,7 @@ public static Operation b64Decode(String binName, CTX... ctx) { * @return read operation returning a boolean match flag */ public static Operation regexCompare(String binName, String pattern, CTX... ctx) { - byte[] bytes = Pack.pack(REGEX_COMPARE, Value.get(pattern), ctx); + byte[] bytes = packStringOp(REGEX_COMPARE, Value.get(pattern), ctx); return new Operation(Operation.Type.STRING_READ, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -541,7 +542,7 @@ public static Operation regexCompare(String binName, String pattern, CTX... ctx) * @return read operation returning a boolean match flag */ public static Operation regexCompare(String binName, String pattern, int regexFlags, CTX... ctx) { - byte[] bytes = Pack.pack(REGEX_COMPARE, Value.get(pattern), regexFlags, 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)); } @@ -567,7 +568,7 @@ public static Operation regexCompare(String binName, String pattern, int regexFl * @return modify operation */ public static Operation insert(StringPolicy policy, String binName, int index, String value, CTX... ctx) { - byte[] bytes = Pack.pack(INSERT, index, Value.get(value), policy.flags, 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)); } @@ -590,7 +591,7 @@ public static Operation insert(StringPolicy policy, String binName, int index, S * @return modify operation */ public static Operation overwrite(StringPolicy policy, String binName, int index, String value, CTX... ctx) { - byte[] bytes = Pack.pack(OVERWRITE, index, Value.get(value), policy.flags, 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)); } @@ -612,7 +613,7 @@ public static Operation overwrite(StringPolicy policy, String binName, int index public static Operation concat(StringPolicy policy, String binName, String value, CTX... ctx) { List list = new ArrayList(1); list.add(Value.get(value)); - byte[] bytes = Pack.pack(CONCAT, list, policy.flags, ctx); + byte[] bytes = packStringOp(CONCAT, list, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -634,7 +635,7 @@ public static Operation concat(StringPolicy policy, String binName, String value */ public static Operation concat(StringPolicy policy, String binName, List values, CTX... ctx) { List list = toValueList(values); - byte[] bytes = Pack.pack(CONCAT, list, policy.flags, ctx); + byte[] bytes = packStringOp(CONCAT, list, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -655,7 +656,7 @@ public static Operation concat(StringPolicy policy, String binName, List * @return modify operation */ public static Operation snip(StringPolicy policy, String binName, int start, CTX... ctx) { - byte[] bytes = Pack.pack(SNIP, start, policy.flags, ctx); + byte[] bytes = packStringOp(SNIP, start, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -677,7 +678,7 @@ public static Operation snip(StringPolicy policy, String binName, int start, CTX * @return modify operation */ public static Operation snip(StringPolicy policy, String binName, int start, int end, CTX... ctx) { - byte[] bytes = Pack.pack(SNIP, start, end, policy.flags, ctx); + byte[] bytes = packStringOp(SNIP, start, end, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -700,7 +701,7 @@ public static Operation snip(StringPolicy policy, String binName, int start, int */ public static Operation replace(StringPolicy policy, String binName, String needle, String replacement, CTX... ctx) { List list = pair(needle, replacement); - byte[] bytes = Pack.pack(REPLACE, list, policy.flags, ctx); + byte[] bytes = packStringOp(REPLACE, list, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -723,7 +724,7 @@ public static Operation replace(StringPolicy policy, String binName, String need */ public static Operation replaceAll(StringPolicy policy, String binName, String needle, String replacement, CTX... ctx) { List list = pair(needle, replacement); - byte[] bytes = Pack.pack(REPLACE_ALL, list, policy.flags, ctx); + byte[] bytes = packStringOp(REPLACE_ALL, list, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -741,7 +742,7 @@ public static Operation replaceAll(StringPolicy policy, String binName, String n * @return modify operation */ public static Operation upper(StringPolicy policy, String binName, CTX... ctx) { - byte[] bytes = Pack.pack(UPPER, policy.flags, ctx); + byte[] bytes = packStringOp(UPPER, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -759,7 +760,7 @@ public static Operation upper(StringPolicy policy, String binName, CTX... ctx) { * @return modify operation */ public static Operation lower(StringPolicy policy, String binName, CTX... ctx) { - byte[] bytes = Pack.pack(LOWER, policy.flags, ctx); + byte[] bytes = packStringOp(LOWER, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -778,7 +779,7 @@ public static Operation lower(StringPolicy policy, String binName, CTX... ctx) { * @return modify operation */ public static Operation caseFold(StringPolicy policy, String binName, CTX... ctx) { - byte[] bytes = Pack.pack(CASE_FOLD, policy.flags, ctx); + byte[] bytes = packStringOp(CASE_FOLD, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -797,7 +798,7 @@ public static Operation caseFold(StringPolicy policy, String binName, CTX... ctx * @return modify operation */ public static Operation normalizeNFC(StringPolicy policy, String binName, CTX... ctx) { - byte[] bytes = Pack.pack(NORMALIZE_NFC, policy.flags, ctx); + byte[] bytes = packStringOp(NORMALIZE_NFC, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -817,7 +818,7 @@ public static Operation normalizeNFC(StringPolicy policy, String binName, CTX... * @return modify operation */ public static Operation trimStart(StringPolicy policy, String binName, CTX... ctx) { - byte[] bytes = Pack.pack(TRIM_START, policy.flags, ctx); + byte[] bytes = packStringOp(TRIM_START, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -837,7 +838,7 @@ public static Operation trimStart(StringPolicy policy, String binName, CTX... ct * @return modify operation */ public static Operation trimEnd(StringPolicy policy, String binName, CTX... ctx) { - byte[] bytes = Pack.pack(TRIM_END, policy.flags, ctx); + byte[] bytes = packStringOp(TRIM_END, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -857,7 +858,7 @@ public static Operation trimEnd(StringPolicy policy, String binName, CTX... ctx) * @return modify operation */ public static Operation trim(StringPolicy policy, String binName, CTX... ctx) { - byte[] bytes = Pack.pack(TRIM, policy.flags, ctx); + byte[] bytes = packStringOp(TRIM, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -880,7 +881,7 @@ public static Operation trim(StringPolicy policy, String binName, CTX... ctx) { * @return modify operation */ public static Operation padStart(StringPolicy policy, String binName, int targetLength, String padString, CTX... ctx) { - byte[] bytes = Pack.pack(PAD_START, targetLength, Value.get(padString), policy.flags, 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)); } @@ -903,7 +904,7 @@ public static Operation padStart(StringPolicy policy, String binName, int target * @return modify operation */ public static Operation padEnd(StringPolicy policy, String binName, int targetLength, String padString, CTX... ctx) { - byte[] bytes = Pack.pack(PAD_END, targetLength, Value.get(padString), policy.flags, 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)); } @@ -924,7 +925,7 @@ public static Operation padEnd(StringPolicy policy, String binName, int targetLe * @return modify operation */ public static Operation repeat(StringPolicy policy, String binName, int count, CTX... ctx) { - byte[] bytes = Pack.pack(REPEAT, count, policy.flags, ctx); + byte[] bytes = packStringOp(REPEAT, count, policy.flags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -960,7 +961,7 @@ public static Operation regexReplace( ) { List list = pair(pattern, replacement); // Server's regex_replace op table accepts only [list, regexFlags]; no slot for policy flags. - byte[] bytes = Pack.pack(REGEX_REPLACE, list, regexFlags, ctx); + byte[] bytes = packStringOp(REGEX_REPLACE, list, regexFlags, ctx); return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } @@ -1022,4 +1023,158 @@ private static List toValueList(List strings) { return list; } + //----------------------------------------------------------------- + // Flat-CTX wire packer (string-op specific). + // + // When CTX is empty: emits [SUBOP, args...] — identical to Pack.pack. + // When CTX is non-empty: emits the FLAT envelope + // [0xFF, [ctx_id_1, ctx_value_1, ...], SUBOP, args...] + // where SUBOP and its args are flattened into the outer array — there + // is no nested array around them. This matches particle_string.c's + // string_state_init (line ~735), which reads the sentinel, skips the + // ctx flat-list with msgpack_sz_vec, then reads the inner op as a + // direct uint64 (no msgpack_get_list_ele_count_vec call). The CDT + // module (cdt.c:3671) does call list_ele_count for the inner op and + // therefore requires a nested layout — the shared Pack.init helper + // emits that nested form, which is why these string-op overloads exist + // as a separate path. + //----------------------------------------------------------------- + + // Outer array header + (optionally) the [0xFF, ctx_flat_list] prologue. + // innerCount = number of msgpack elements the caller will emit AFTER + // this prologue (the SUBOP integer plus any args, at the outer level). + private static void writeOuterHeader(Packer p, int innerCount, CTX[] ctx) { + boolean hasCtx = ctx != null && ctx.length > 0; + int outerSize = hasCtx ? (2 + innerCount) : innerCount; + p.packArrayBegin(outerSize); + if (hasCtx) { + p.packInt(0xFF); + p.packArrayBegin(ctx.length * 2); + for (CTX c : ctx) { + p.packInt(c.id); + if (c.value != null) { + c.value.pack(p); + } + else { + p.packByteArray(c.exp.getBytes(), 0, c.exp.getBytes().length); + } + } + } + } + + // [SUBOP] + private static byte[] packStringOp(int subop, CTX[] ctx) { + Packer p = new Packer(); + writeOuterHeader(p, 1, ctx); + p.packInt(subop); + p.createBuffer(); + writeOuterHeader(p, 1, ctx); + p.packInt(subop); + return p.getBuffer(); + } + + // [SUBOP, v1] + private static byte[] packStringOp(int subop, int v1, CTX[] ctx) { + Packer p = new Packer(); + writeOuterHeader(p, 2, ctx); + p.packInt(subop); + p.packInt(v1); + p.createBuffer(); + writeOuterHeader(p, 2, ctx); + p.packInt(subop); + p.packInt(v1); + return p.getBuffer(); + } + + // [SUBOP, v1, v2] + private static byte[] packStringOp(int subop, int v1, int v2, CTX[] ctx) { + Packer p = new Packer(); + writeOuterHeader(p, 3, ctx); + p.packInt(subop); + p.packInt(v1); + p.packInt(v2); + p.createBuffer(); + writeOuterHeader(p, 3, ctx); + p.packInt(subop); + p.packInt(v1); + p.packInt(v2); + return p.getBuffer(); + } + + // [SUBOP, v1, v2, v3] + private static byte[] packStringOp(int subop, int v1, int v2, int v3, CTX[] ctx) { + Packer p = new Packer(); + writeOuterHeader(p, 4, ctx); + p.packInt(subop); + p.packInt(v1); + p.packInt(v2); + p.packInt(v3); + p.createBuffer(); + writeOuterHeader(p, 4, ctx); + p.packInt(subop); + p.packInt(v1); + p.packInt(v2); + p.packInt(v3); + return p.getBuffer(); + } + + // [SUBOP, v1] (Value) + private static byte[] packStringOp(int subop, Value v1, CTX[] ctx) { + Packer p = new Packer(); + writeOuterHeader(p, 2, ctx); + p.packInt(subop); + v1.pack(p); + p.createBuffer(); + writeOuterHeader(p, 2, ctx); + p.packInt(subop); + v1.pack(p); + return p.getBuffer(); + } + + // [SUBOP, v1, v2] (Value, int) + private static byte[] packStringOp(int subop, Value v1, int v2, CTX[] ctx) { + Packer p = new Packer(); + writeOuterHeader(p, 3, ctx); + p.packInt(subop); + v1.pack(p); + p.packInt(v2); + p.createBuffer(); + writeOuterHeader(p, 3, ctx); + p.packInt(subop); + v1.pack(p); + p.packInt(v2); + return p.getBuffer(); + } + + // [SUBOP, v1, v2, v3] (int, Value, int) + private static byte[] packStringOp(int subop, int v1, Value v2, int v3, CTX[] ctx) { + Packer p = new Packer(); + writeOuterHeader(p, 4, ctx); + p.packInt(subop); + p.packInt(v1); + v2.pack(p); + p.packInt(v3); + p.createBuffer(); + writeOuterHeader(p, 4, ctx); + p.packInt(subop); + p.packInt(v1); + v2.pack(p); + p.packInt(v3); + return p.getBuffer(); + } + + // [SUBOP, list, v2] (List, int) + private static byte[] packStringOp(int subop, List list, int v2, CTX[] ctx) { + Packer p = new Packer(); + writeOuterHeader(p, 3, ctx); + p.packInt(subop); + p.packValueList(list); + p.packInt(v2); + p.createBuffer(); + writeOuterHeader(p, 3, ctx); + p.packInt(subop); + p.packValueList(list); + p.packInt(v2); + return p.getBuffer(); + } } diff --git a/test/src/com/aerospike/test/SuiteSync.java b/test/src/com/aerospike/test/SuiteSync.java index 57512c085..27bc32591 100644 --- a/test/src/com/aerospike/test/SuiteSync.java +++ b/test/src/com/aerospike/test/SuiteSync.java @@ -53,6 +53,7 @@ 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.TestStringMasking; import com.aerospike.test.sync.basic.TestTouch; import com.aerospike.test.sync.basic.TestTxn; @@ -105,6 +106,7 @@ TestReplace.class, TestScan.class, TestServerInfo.class, + TestStringExp.class, TestStringMasking.class, TestTouch.class, TestTxn.class, diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index d1eeb68dd..75e9ddc70 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -19,22 +19,31 @@ 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; /** @@ -561,4 +570,191 @@ public void splitResultListEntriesAreReadableStrings() { 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 values) { + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, values)); + } + + private static void putMap(Map entries) { + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, entries)); + } + + @Test + public void readOpOnStringNestedInList() { + // list = ["alpha", "beta", "hello world"]; strlen at index 2 = 11 + List list = new ArrayList(); + list.add(Value.get("alpha")); + list.add(Value.get("beta")); + list.add(Value.get("hello world")); + putList(list); + + Record r = operate(StringOperation.strlen(BIN, CTX.listIndex(2))); + assertEquals(11L, r.getLong(BIN)); + } + + @Test + public void readBooleanOpOnStringNestedInMap() { + // map = {"a": "Hello", "b": "World"}; startsWith("World","Wor") = true + Map map = new HashMap(); + map.put(Value.get("a"), Value.get("Hello")); + map.put(Value.get("b"), Value.get("World")); + putMap(map); + + Record r = operate(StringOperation.startsWith(BIN, "Wor", CTX.mapKey(Value.get("b")))); + assertTrue(r.getBoolean(BIN)); + } + + @Test + public void modifyOpOnStringNestedInList() { + // list = ["alpha", "beta", "gamma"]; upper at index 1 -> "BETA" + List list = new ArrayList(); + list.add(Value.get("alpha")); + list.add(Value.get("beta")); + list.add(Value.get("gamma")); + putList(list); + + operate(StringOperation.upper(POLICY, BIN, CTX.listIndex(1))); + + List after = client.get(null, KEY).getList(BIN); + assertEquals(Arrays.asList("alpha", "BETA", "gamma"), after); + } + + @Test + public void modifyOpOnStringNestedInMap() { + // map = {"a": "hello world", "b": "foo"}; replace at key "a" + Map map = new HashMap(); + map.put(Value.get("a"), Value.get("hello world")); + map.put(Value.get("b"), Value.get("foo")); + putMap(map); + + operate(StringOperation.replace(POLICY, BIN, "world", "earth", + CTX.mapKey(Value.get("a")))); + + Map after = client.get(null, KEY).getMap(BIN); + assertEquals("hello earth", after.get("a")); + assertEquals("foo", after.get("b")); + } + + @Test + public void modifyOpOnStringDeeplyNestedListInMap() { + // map = {"items": ["one", "two", "three"]}; upper at items[1] -> "TWO" + List inner = new ArrayList(); + inner.add(Value.get("one")); + inner.add(Value.get("two")); + inner.add(Value.get("three")); + + Map map = new HashMap(); + map.put(Value.get("items"), Value.get(inner)); + putMap(map); + + operate(StringOperation.upper(POLICY, BIN, + CTX.mapKey(Value.get("items")), CTX.listIndex(1))); + + Map after = client.get(null, KEY).getMap(BIN); + List items = (List)after.get("items"); + assertEquals(Arrays.asList("one", "TWO", "three"), items); + } + + //================================================================= + // toString op — op-type 19, no payload, no sub-op id, no CTX + // + // Spec §2.6 and §4.1: covers integer/float/string/blob -> string + // conversions, plus the INCOMPATIBLE_TYPE error path for list/map + // bins that the wire format cannot represent. + //================================================================= + + @Test + public void toStringConvertsIntegerBinToString() { + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, 42)); + Record r = operate(StringOperation.toString(BIN)); + assertEquals("42", r.getString(BIN)); + } + + @Test + public void toStringConvertsDoubleBinToString() { + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, 3.14)); + Record r = operate(StringOperation.toString(BIN)); + // Float-to-string formatting is server-side; assert it parses back. + assertEquals(3.14, Double.parseDouble(r.getString(BIN)), 0.0001); + } + + @Test + public void toStringOnStringBinIsIdentity() { + put("hello"); + Record r = operate(StringOperation.toString(BIN)); + assertEquals("hello", r.getString(BIN)); + } + + @Test + public void toStringConvertsBlobBinToString() { + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, new byte[] {'h', 'i'})); + Record r = operate(StringOperation.toString(BIN)); + // Server's blob-to-string representation is well-defined for ASCII bytes. + assertEquals("hi", r.getString(BIN)); + } + + @Test + public void toStringOnListBinReturnsIncompatibleType() { + List list = new ArrayList(); + list.add(Value.get("a")); + list.add(Value.get("b")); + putList(list); + + AerospikeException ae = assertThrows(AerospikeException.class, + () -> operate(StringOperation.toString(BIN))); + assertEquals(ResultCode.BIN_TYPE_ERROR, ae.getResultCode()); + } + + //================================================================= + // NO_FAIL flag — missing-bin path + // + // particle_string.c:926: when the target bin does not exist, the + // server returns AS_OK with no bin written if NO_FAIL is set; without + // it, the server returns AS_ERR_BIN_NOT_FOUND. This is the actual + // scope of NO_FAIL on STRING_MODIFY — the server does NOT honor it + // for wrong-type bins (incompatible-type is hard-errored at line 872 + // regardless of the flag). + //================================================================= + + @Test + public void modifyOnMissingBinWithNoFailIsNoOp() { + // Record exists but the target bin does not — exercises the bin-level + // NO_FAIL path at particle_string.c:926 (not the record-level + // KEY_NOT_FOUND path that fires when the whole record is absent). + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + StringPolicy noFail = new StringPolicy(StringWriteFlags.NO_FAIL); + operate(StringOperation.upper(noFail, BIN)); + + // BIN must not have been created; the existing bin must be intact. + Record r = client.get(null, KEY); + assertEquals(null, r.getValue(BIN)); + assertEquals("untouched", r.getString("other")); + } + + @Test + public void modifyOnMissingBinWithoutNoFailRaises() { + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + AerospikeException ae = assertThrows(AerospikeException.class, + () -> operate(StringOperation.upper(POLICY, BIN))); + assertEquals(ResultCode.BIN_NOT_FOUND, ae.getResultCode()); + } } diff --git a/test/src/com/aerospike/test/sync/basic/TestStringExp.java b/test/src/com/aerospike/test/sync/basic/TestStringExp.java new file mode 100644 index 000000000..dee1a5c6e --- /dev/null +++ b/test/src/com/aerospike/test/sync/basic/TestStringExp.java @@ -0,0 +1,472 @@ +/* + * 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.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.Bin; +import com.aerospike.client.Key; +import com.aerospike.client.Record; +import com.aerospike.client.Value; +import com.aerospike.client.cdt.ListReturnType; +import com.aerospike.client.cdt.MapReturnType; +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.ListExp; +import com.aerospike.client.exp.MapExp; +import com.aerospike.client.exp.StringExp; +import com.aerospike.client.operation.StringNumericType; +import com.aerospike.client.operation.StringPolicy; +import com.aerospike.client.operation.StringRegexFlags; +import com.aerospike.client.policy.Policy; +import com.aerospike.test.sync.TestSync; + +/** + * Integration tests for the string filter-expression builders exposed by + * {@link StringExp}. Each test puts a representative bin, builds an + * {@link Expression} that wraps a {@code StringExp.*} call, evaluates it via + * {@link ExpOperation#read} into a virtual bin, and asserts the result. + * + *

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, 2, 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, length). + 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 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)); + } + + @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 returns a singleton-list wrapping the whole string. + put("Hello123World"); + Record r2 = eval(StringExp.split(Exp.stringBin(BIN))); + assertEquals(Arrays.asList("Hello123World"), 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)); + } + + @Test + public void regexCompareLiteralSourceIgnoresBin() { + // Source can be any string-yielding expression — not only a bin reference. + put("ignored"); + Record r = eval(StringExp.regexCompare( + Exp.val("[A-Z]+"), Exp.val("HELLO"))); + assertTrue(r.getBoolean(VAR)); + } + + //================================================================= + // 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 snipRemovesFromStartAndRange() { + put("hello world"); + // One-arg form: start through end. + Record r1 = eval(StringExp.snip(POLICY, Exp.val(5), Exp.stringBin(BIN))); + assertEquals("hello", r1.getString(VAR)); + + // Two-arg form: half-open [start, end). + put("hello beautiful world"); + Record r2 = eval(StringExp.snip(POLICY, Exp.val(5), Exp.val(15), Exp.stringBin(BIN))); + assertEquals("hello world", r2.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 list = new ArrayList(); + list.add(Value.get("alpha")); + list.add(Value.get("beta")); + list.add(Value.get("hello world")); + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, list)); + + Exp nestedString = ListExp.getByIndex( + ListReturnType.VALUE, Exp.Type.STRING, Exp.val(2), Exp.listBin(BIN)); + Record r = eval(StringExp.strlen(nestedString)); + assertEquals(11L, r.getLong(VAR)); + } + + @Test + public void upperOnStringNestedInMapProjectedViaMapExp() { + Map map = new HashMap(); + map.put(Value.get("a"), Value.get("hello")); + map.put(Value.get("b"), Value.get("world")); + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, map)); + + Exp nestedString = MapExp.getByKey( + MapReturnType.VALUE, Exp.Type.STRING, Exp.val("a"), Exp.mapBin(BIN)); + Record r = eval(StringExp.upper(POLICY, nestedString)); + assertEquals("HELLO", r.getString(VAR)); + } +} From 38ec7bfc940eb528e5efca1928fa3aecf3fb4706 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Thu, 21 May 2026 10:09:23 -0700 Subject: [PATCH 08/19] Fixed test with working version of server --- .../com/aerospike/client/exp/StringExp.java | 62 ++++++++++++------- .../test/sync/basic/TestStringExp.java | 37 +++++------ 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/client/src/com/aerospike/client/exp/StringExp.java b/client/src/com/aerospike/client/exp/StringExp.java index 8d3bd40e7..547dbf0b2 100644 --- a/client/src/com/aerospike/client/exp/StringExp.java +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -241,7 +241,7 @@ public static Exp find(Exp needle, Exp occurrence, Exp src) { */ public static Exp contains(Exp needle, Exp src) { byte[] bytes = Pack.pack(CONTAINS, needle); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } /** @@ -258,7 +258,7 @@ public static Exp contains(Exp needle, Exp src) { */ public static Exp startsWith(Exp prefix, Exp src) { byte[] bytes = Pack.pack(STARTS_WITH, prefix); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } /** @@ -275,7 +275,7 @@ public static Exp startsWith(Exp prefix, Exp src) { */ public static Exp endsWith(Exp suffix, Exp src) { byte[] bytes = Pack.pack(ENDS_WITH, suffix); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } /** @@ -343,7 +343,7 @@ public static Exp byteLength(Exp src) { */ public static Exp isNumeric(Exp src) { byte[] bytes = Pack.pack(IS_NUMERIC); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } /** @@ -362,7 +362,7 @@ public static Exp isNumeric(Exp src) { */ public static Exp isNumeric(int numericType, Exp src) { byte[] bytes = Pack.pack(IS_NUMERIC, numericType); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } /** @@ -378,7 +378,7 @@ public static Exp isNumeric(int numericType, Exp src) { */ public static Exp isUpper(Exp src) { byte[] bytes = Pack.pack(IS_UPPER); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } /** @@ -394,7 +394,7 @@ public static Exp isUpper(Exp src) { */ public static Exp isLower(Exp src) { byte[] bytes = Pack.pack(IS_LOWER); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } /** @@ -481,7 +481,7 @@ public static Exp b64Decode(Exp src) { */ public static Exp regexCompare(Exp pattern, Exp src) { byte[] bytes = Pack.pack(REGEX_COMPARE, pattern); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } /** @@ -502,7 +502,7 @@ public static Exp regexCompare(Exp pattern, Exp src) { */ public static Exp regexCompare(Exp pattern, int regexFlags, Exp src) { byte[] bytes = Pack.pack(REGEX_COMPARE, pattern, regexFlags); - return addRead(src, bytes, Exp.Type.INT); + return addRead(src, bytes, Exp.Type.BOOL); } //----------------------------------------------------------------- @@ -901,7 +901,7 @@ public static Exp regexReplace( * @return string-typed expression yielding the string representation */ public static Exp toString(Exp src) { - byte[] bytes = emptyArray(); + byte[] bytes = reprPayload(); return new Exp.Module(src, bytes, Exp.Type.STRING.code, MODULE_REPR); } @@ -917,16 +917,26 @@ private static Exp addModify(Exp src, byte[] bytes) { return new Exp.Module(src, bytes, Exp.Type.STRING.code, MODULE | Exp.MODIFY); } - // [cmd, [needle, repl], flags] — needle/replacement nested inside a 2-element list. - // Specialized packing method. Leaving in StringExp instead of moving to Pack since the - // structure is specific to string replace operations and doesn't fit the usual pattern - // of a command followed by a flat list of arguments. + // 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, int, CTX[]). private static byte[] packReplace(int command, Exp needle, Exp replacement, int flags) { Packer packer = new Packer(); for (int i = 0; i < 2; i++) { packer.packArrayBegin(3); packer.packInt(command); packer.packArrayBegin(2); + packer.packInt(QUOTED); + packer.packArrayBegin(2); needle.pack(packer); replacement.pack(packer); packer.packInt(flags); @@ -935,18 +945,19 @@ private static byte[] packReplace(int command, Exp needle, Exp replacement, int return packer.getBuffer(); } - // [REGEX_REPLACE, [pattern, repl], regexFlags] — 3 elements. - // Server's regex_replace op table accepts only [list, regexFlags]; no slot for - // policy flags (max_args=2 in particle_string.c:476). Specialized packing method - // kept in StringExp instead of moving to Pack since the structure is specific to - // string replace operations and doesn't fit the usual pattern of a command - // followed by a flat list of arguments. + // [REGEX_REPLACE, [QUOTED, [pattern, repl]], regexFlags] — same QUOTED wrapping as + // packReplace; without it the expression compiler tries to interpret the + // (pattern, replacement) pair as a function call. Note: the server's regex_replace + // op table is declared with max_args=2 (particle_string.c:476), so there is no + // trailing policy-flags slot — only the regexFlags integer. private static byte[] packRegexReplace(Exp pattern, Exp replacement, int regexFlags) { Packer packer = new Packer(); for (int i = 0; i < 2; i++) { packer.packArrayBegin(3); packer.packInt(REGEX_REPLACE); packer.packArrayBegin(2); + packer.packInt(QUOTED); + packer.packArrayBegin(2); pattern.pack(packer); replacement.pack(packer); packer.packInt(regexFlags); @@ -955,10 +966,17 @@ private static byte[] packRegexReplace(Exp pattern, Exp replacement, int regexFl return packer.getBuffer(); } - private static byte[] emptyArray() { + // Single-zero payload [0] for CALL_REPR (StringExp.toString). The server's + // parse_op_call at exp.c:3244 rejects an empty list (ele_count == 0), so the + // payload must contain at least one element. The CALL_REPR dispatcher at + // exp.c:5019 ignores the sub-op id and goes straight to as_bin_to_string, so + // the value carried here is unused. The spec previously documented this as `[]`; + // the server is the source of truth — see §2.7 in the cross-client spec. + private static byte[] reprPayload() { Packer packer = new Packer(); for (int i = 0; i < 2; i++) { - packer.packArrayBegin(0); + packer.packArrayBegin(1); + packer.packInt(0); if (i == 0) packer.createBuffer(); } return packer.getBuffer(); diff --git a/test/src/com/aerospike/test/sync/basic/TestStringExp.java b/test/src/com/aerospike/test/sync/basic/TestStringExp.java index dee1a5c6e..6a67ac350 100644 --- a/test/src/com/aerospike/test/sync/basic/TestStringExp.java +++ b/test/src/com/aerospike/test/sync/basic/TestStringExp.java @@ -219,10 +219,10 @@ public void splitWithAndWithoutSeparator() { Record r1 = eval(StringExp.split(Exp.val(","), Exp.stringBin(BIN))); assertEquals(Arrays.asList("one", "two", "three"), r1.getList(VAR)); - // No-separator form returns a singleton-list wrapping the whole string. - put("Hello123World"); + // 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("Hello123World"), r2.getList(VAR)); + assertEquals(Arrays.asList("a", "b", "c"), r2.getList(VAR)); } @Test @@ -246,14 +246,13 @@ public void regexCompareWithAndWithoutCaseInsensitiveFlag() { Exp.stringBin(BIN))).getBoolean(VAR)); } - @Test - public void regexCompareLiteralSourceIgnoresBin() { - // Source can be any string-yielding expression — not only a bin reference. - put("ignored"); - Record r = eval(StringExp.regexCompare( - Exp.val("[A-Z]+"), Exp.val("HELLO"))); - assertTrue(r.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) @@ -286,16 +285,14 @@ public void concatAppendsListOfValues() { } @Test - public void snipRemovesFromStartAndRange() { - put("hello world"); - // One-arg form: start through end. - Record r1 = eval(StringExp.snip(POLICY, Exp.val(5), Exp.stringBin(BIN))); - assertEquals("hello", r1.getString(VAR)); - - // Two-arg form: half-open [start, end). + 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 r2 = eval(StringExp.snip(POLICY, Exp.val(5), Exp.val(15), Exp.stringBin(BIN))); - assertEquals("hello world", r2.getString(VAR)); + Record r = eval(StringExp.snip(POLICY, Exp.val(5), Exp.val(15), Exp.stringBin(BIN))); + assertEquals("hello world", r.getString(VAR)); } @Test From e1d55a23ca6b048171159f838d885afba4c216cf Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Thu, 21 May 2026 10:15:32 -0700 Subject: [PATCH 09/19] Added server 8.1.3 gates for string ops feature --- test/src/com/aerospike/test/sync/basic/TestOperateString.java | 2 +- test/src/com/aerospike/test/sync/basic/TestStringExp.java | 2 +- test/src/com/aerospike/test/sync/basic/TestStringMasking.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index 75e9ddc70..436b4ca77 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -65,7 +65,7 @@ public class TestOperateString extends TestSync { public static void serverVersionCheck() { Assume.assumeTrue( "Skipping: string operations require server version 8.1.3 or later", - args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); } //----------------------------------------------------------------- diff --git a/test/src/com/aerospike/test/sync/basic/TestStringExp.java b/test/src/com/aerospike/test/sync/basic/TestStringExp.java index 6a67ac350..b8528b9b9 100644 --- a/test/src/com/aerospike/test/sync/basic/TestStringExp.java +++ b/test/src/com/aerospike/test/sync/basic/TestStringExp.java @@ -75,7 +75,7 @@ public class TestStringExp extends TestSync { public static void serverVersionCheck() { Assume.assumeTrue( "Skipping: string expressions require server version 8.1.3 or later", - args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); } //----------------------------------------------------------------- diff --git a/test/src/com/aerospike/test/sync/basic/TestStringMasking.java b/test/src/com/aerospike/test/sync/basic/TestStringMasking.java index 8db39c740..6405fc0eb 100644 --- a/test/src/com/aerospike/test/sync/basic/TestStringMasking.java +++ b/test/src/com/aerospike/test/sync/basic/TestStringMasking.java @@ -86,7 +86,7 @@ public class TestStringMasking extends TestSync { @BeforeClass public static void setupUsersAndRule() { Assume.assumeTrue("Skipping: server version < 8.1.3 (string ops + masking)", - args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); + 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()); From 150c0ed1ae3f069d2cfb7b4076746e204c0de35d Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Mon, 8 Jun 2026 22:11:58 -0700 Subject: [PATCH 10/19] Added additional tests to include negative tests --- .../src/com/aerospike/client/ResultCode.java | 10 + .../com/aerospike/client/exp/StringExp.java | 20 +- .../client/operation/StringOperation.java | 16 +- test/src/com/aerospike/test/SuiteSync.java | 2 + .../test/sync/basic/TestOperateString.java | 176 +++++++++++ .../test/sync/basic/TestStringExp.java | 27 +- .../sync/basic/TestStringInvalidUtf8.java | 279 ++++++++++++++++++ 7 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 test/src/com/aerospike/test/sync/basic/TestStringInvalidUtf8.java 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/exp/StringExp.java b/client/src/com/aerospike/client/exp/StringExp.java index 547dbf0b2..22d8748b9 100644 --- a/client/src/com/aerospike/client/exp/StringExp.java +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -151,21 +151,23 @@ public static Exp substr(Exp start, Exp src) { } /** - * Create expression that returns {@code length} codepoints of {@code src} starting - * at codepoint {@code start}. Negative indexes count from the end. + * 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" from 0, length 5 -> "hello"
+	 * // "hello world" [0, 5) -> "hello"
 	 * Exp head = StringExp.substr(Exp.val(0), Exp.val(5), Exp.stringBin("text"));
 	 * }
* - * @param start starting codepoint index (negative counts from end) - * @param length number of codepoints to read (clamped to remaining length) - * @param src source string expression - * @return string-typed expression yielding the substring + * @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 length, Exp src) { - byte[] bytes = Pack.pack(SUBSTR, start, length); + public static Exp substr(Exp start, Exp end, Exp src) { + byte[] bytes = Pack.pack(SUBSTR, start, end); return addRead(src, bytes, Exp.Type.STRING); } diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index b73844596..b889274e2 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -156,22 +156,24 @@ public static Operation substr(String binName, int start, CTX... ctx) { } /** - * Create string {@code substr} operation that reads {@code length} codepoints - * starting at {@code start}. Negative indexes count from the end of the 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" -> "hello"
+	 * // "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 (negative counts from end) - * @param length number of codepoints to read (clamped to remaining length) + * @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 length, CTX... ctx) { - byte[] bytes = packStringOp(SUBSTR, start, length, ctx); + 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)); } diff --git a/test/src/com/aerospike/test/SuiteSync.java b/test/src/com/aerospike/test/SuiteSync.java index 27bc32591..52d6fefec 100644 --- a/test/src/com/aerospike/test/SuiteSync.java +++ b/test/src/com/aerospike/test/SuiteSync.java @@ -54,6 +54,7 @@ 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; @@ -107,6 +108,7 @@ TestScan.class, TestServerInfo.class, TestStringExp.class, + TestStringInvalidUtf8.class, TestStringMasking.class, TestTouch.class, TestTxn.class, diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index 436b4ca77..0ef578c39 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -115,6 +115,96 @@ public void byteLengthReturnsUtf8Bytes() { 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"); @@ -293,6 +383,19 @@ public void normalizeNFCLeavesAlreadyNormalizedStringUnchanged() { 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"); @@ -708,6 +811,22 @@ public void toStringConvertsBlobBinToString() { assertEquals("hi", r.getString(BIN)); } + @Test + public void toStringOnBlobWithInvalidUtf8RaisesOpNotApplicable() { + // {0xED, 0xA0, 0x80} is the UTF-8 encoding of U+D800 (ill-formed + // surrogate). The server's blob→string conversion validates the bytes + // via cf_str_is_valid_utf8 and rejects non-well-formed input with + // OP_NOT_APPLICABLE (mirrors the server's ToStringTest.Blob_InvalidUtf8 + // unit test). Companion to TestStringInvalidUtf8 which exercises the + // same fixture on the read/modify ops via a STRING-typed bin. + client.delete(null, KEY); + client.put(null, KEY, new Bin(BIN, + new byte[] {(byte)0xED, (byte)0xA0, (byte)0x80})); + AerospikeException ae = assertThrows(AerospikeException.class, + () -> operate(StringOperation.toString(BIN))); + assertEquals(ResultCode.OP_NOT_APPLICABLE, ae.getResultCode()); + } + @Test public void toStringOnListBinReturnsIncompatibleType() { List list = new ArrayList(); @@ -757,4 +876,61 @@ public void modifyOnMissingBinWithoutNoFailRaises() { () -> operate(StringOperation.upper(POLICY, BIN))); assertEquals(ResultCode.BIN_NOT_FOUND, ae.getResultCode()); } + + //================================================================= + // Prepare / parameter errors + // + // These exercise the server's prepare-phase validation + // (particle_string.c: find occurrence != 0, empty/negative pad + // arguments, repeat count >= 0, regex_replace pattern compile). + // All should surface as PARAMETER_ERROR; an invalid regex surfaces + // as OP_NOT_APPLICABLE per the server's ICU integration. + //================================================================= + + private static void assertParamError(Operation op) { + AerospikeException ae = assertThrows(AerospikeException.class, + () -> operate(op)); + assertEquals(ResultCode.PARAMETER_ERROR, ae.getResultCode()); + } + + @Test + public void findWithZeroOccurrenceRaisesParameter() { + put("hello"); + // 0 is reserved as "no occurrence"; the server's find prepare rejects it. + assertParamError(StringOperation.find(BIN, "x", 0)); + } + + @Test + public void padStartWithEmptyPadStringRaisesParameter() { + put("hello"); + assertParamError(StringOperation.padStart(POLICY, BIN, 10, "")); + } + + @Test + public void padEndWithEmptyPadStringRaisesParameter() { + put("hello"); + assertParamError(StringOperation.padEnd(POLICY, BIN, 10, "")); + } + + @Test + public void padStartWithNegativeTargetRaisesParameter() { + put("hello"); + assertParamError(StringOperation.padStart(POLICY, BIN, -1, "*")); + } + + @Test + public void repeatWithNegativeCountRaisesParameter() { + put("hello"); + assertParamError(StringOperation.repeat(POLICY, BIN, -1)); + } + + @Test + public void regexReplaceWithInvalidPatternRaisesParameterError() { + put("hello"); + // Unclosed character class — PCRE2 compile fails inside the op. + // Server returns PARAMETER_ERROR (the server doc table lists this row as + // "OP_NOT_APPLICABLE / error"; observed behavior on 8.1.3 is PARAMETER). + assertParamError(StringOperation.regexReplace( + POLICY, BIN, "[unclosed", "NUM", StringRegexFlags.DEFAULT)); + } } diff --git a/test/src/com/aerospike/test/sync/basic/TestStringExp.java b/test/src/com/aerospike/test/sync/basic/TestStringExp.java index b8528b9b9..49a7b097a 100644 --- a/test/src/com/aerospike/test/sync/basic/TestStringExp.java +++ b/test/src/com/aerospike/test/sync/basic/TestStringExp.java @@ -114,7 +114,7 @@ public void substrFromOffsetAndRange() { // 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, length). + // 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)); } @@ -181,6 +181,31 @@ public void byteLengthReturnsUtf8Bytes() { 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"); diff --git a/test/src/com/aerospike/test/sync/basic/TestStringInvalidUtf8.java b/test/src/com/aerospike/test/sync/basic/TestStringInvalidUtf8.java new file mode 100644 index 000000000..e43ab226b --- /dev/null +++ b/test/src/com/aerospike/test/sync/basic/TestStringInvalidUtf8.java @@ -0,0 +1,279 @@ +/* + * 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.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Assume; +import org.junit.Before; +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.ResultCode; +import com.aerospike.client.Value; +import com.aerospike.client.command.ParticleType; +import com.aerospike.client.operation.StringOperation; +import com.aerospike.client.operation.StringPolicy; +import com.aerospike.client.operation.StringRegexFlags; +import com.aerospike.test.sync.TestSync; + +/** + * Negative tests for the server's bin-UTF-8 entry gate 8.1.3. + * + *

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")); + } +} From 635a134d8aa2732d1536de1b0281186a33fe565f Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Wed, 10 Jun 2026 16:38:06 -0700 Subject: [PATCH 11/19] Added test --- .../aerospike/test/sync/basic/TestOperateString.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index 0ef578c39..bb497e81c 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -660,6 +660,17 @@ public void chainedReplaceAllAndPaddingComposeAsExpected() { 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"); From 83a0235eeb8613ee9efdacae2146c0af390dc99d Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Thu, 11 Jun 2026 11:03:28 -0700 Subject: [PATCH 12/19] Removed single arg snip --- .../com/aerospike/client/exp/StringExp.java | 21 ------------------- .../client/operation/StringOperation.java | 21 ------------------- 2 files changed, 42 deletions(-) diff --git a/client/src/com/aerospike/client/exp/StringExp.java b/client/src/com/aerospike/client/exp/StringExp.java index 22d8748b9..3d6478879 100644 --- a/client/src/com/aerospike/client/exp/StringExp.java +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -578,27 +578,6 @@ public static Exp concat(StringPolicy policy, Exp values, Exp src) { return addModify(src, bytes); } - /** - * Create expression that removes codepoints from {@code src} starting at codepoint - * {@code start} through the end, returning the resulting string. Does not modify - * the underlying bin. - * - *

{@code
-	 * // "hello world" snip from 5 -> "hello"
-	 * Exp out = StringExp.snip(StringPolicy.Default,
-	 *     Exp.val(5), Exp.stringBin("text"));
-	 * }
- * - * @param policy write policy controlling NO_FAIL semantics - * @param start first codepoint to remove (inclusive) - * @param src source string expression - * @return string-typed expression yielding the modified string - */ - public static Exp snip(StringPolicy policy, Exp start, Exp src) { - byte[] bytes = Pack.pack(SNIP, start, 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 diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index b889274e2..6dd60dbb8 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -641,27 +641,6 @@ public static Operation concat(StringPolicy policy, String binName, List return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } - /** - * Create string {@code snip} operation that removes codepoints starting at - * codepoint {@code start} through the end of the string. - * - *
{@code
-	 * // "hello world" snip from 5 -> "hello"
-	 * client.operate(null, key,
-	 *     StringOperation.snip(StringPolicy.Default, "text", 5));
-	 * }
- * - * @param policy write policy controlling NO_FAIL semantics - * @param binName name of the string bin - * @param start first codepoint to remove (inclusive) - * @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, CTX... ctx) { - byte[] bytes = packStringOp(SNIP, start, 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. From 374037b16469fa8cd43c4ea36b6537b394976adf Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Fri, 12 Jun 2026 15:28:11 -0700 Subject: [PATCH 13/19] Renamed lenght -> end for substr(ofset-to-ned form, ...) method in StringOps and StringExp --- .../src/com/aerospike/client/Operation.java | 10 ++ .../aerospike/client/command/OperateArgs.java | 9 +- client/src/com/aerospike/client/exp/Exp.java | 4 + .../com/aerospike/client/exp/StringExp.java | 46 ++++++++ .../client/operation/StringOperation.java | 50 +++++++++ .../test/sync/basic/TestOperateString.java | 101 +++++++++++++++++- .../test/sync/basic/TestStringExp.java | 30 ++++++ 7 files changed, 244 insertions(+), 6 deletions(-) diff --git a/client/src/com/aerospike/client/Operation.java b/client/src/com/aerospike/client/Operation.java index 977b0ea59..63a5b463f 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(since = "10.1.0", forRemoval = true) 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(since = "10.1.0", forRemoval = true) public static Operation prepend(Bin bin) { return new Operation(Type.PREPEND, bin.name, bin.value); } diff --git a/client/src/com/aerospike/client/command/OperateArgs.java b/client/src/com/aerospike/client/command/OperateArgs.java index 64f9a1adc..da95bb37a 100644 --- a/client/src/com/aerospike/client/command/OperateArgs.java +++ b/client/src/com/aerospike/client/command/OperateArgs.java @@ -49,13 +49,13 @@ 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: case READ: - case STRING_READ: - case TO_STRING: rattr |= Command.INFO1_READ; // Read all bins if no bin is specified. @@ -74,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 4c2e799cf..1e520f115 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(since = "10.1.0", forRemoval = true) 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 index 3d6478879..7a9f3e883 100644 --- a/client/src/com/aerospike/client/exp/StringExp.java +++ b/client/src/com/aerospike/client/exp/StringExp.java @@ -103,6 +103,8 @@ public final class StringExp { 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 @@ -578,6 +580,50 @@ public static Exp concat(StringPolicy policy, Exp values, Exp src) { 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 diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index 6dd60dbb8..7e1374899 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -88,6 +88,8 @@ public final class StringOperation { 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 @@ -641,6 +643,54 @@ public static Operation concat(StringPolicy policy, String binName, List return new Operation(Operation.Type.STRING_MODIFY, binName, new Value.BytesValue(bytes, ParticleType.STRING)); } + /** + * Create string {@code append} operation that appends {@code value} to the end of the bin. + *

+ * 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. diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index bb497e81c..c574a099c 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -592,6 +592,68 @@ public void concatAppendsListOfValues() { 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 appendOnMissingBinWithNoFailIsNoOp() { + // Mirrors the upper() missing-bin NO_FAIL test for the append op. + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + StringPolicy noFail = new StringPolicy(StringWriteFlags.NO_FAIL); + operate(StringOperation.append(noFail, BIN, "x")); + + Record r = client.get(null, KEY); + assertEquals(null, r.getValue(BIN)); + assertEquals("untouched", r.getString("other")); + } + @Test public void regexReplaceTargetsFirstMatchByDefault() { put("abc123def456"); @@ -644,8 +706,12 @@ public void modifyAndReadInOneOperatePipelineCommitsThenObserves() { StringOperation.upper(POLICY, BIN), StringOperation.strlen(BIN)); - // strlen runs after trim+upper so it sees the post-modification length. - assertEquals(11L, r.getLong(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()); } @@ -781,6 +847,37 @@ public void modifyOpOnStringDeeplyNestedListInMap() { assertEquals(Arrays.asList("one", "TWO", "three"), items); } + @Test + public void appendOnStringNestedInList() { + // list = ["alpha", "beta", "gamma"]; append "!" at index 1 -> "beta!" + List list = new ArrayList(); + list.add(Value.get("alpha")); + list.add(Value.get("beta")); + list.add(Value.get("gamma")); + putList(list); + + operate(StringOperation.append(POLICY, BIN, "!", CTX.listIndex(1))); + + List after = client.get(null, KEY).getList(BIN); + assertEquals(Arrays.asList("alpha", "beta!", "gamma"), after); + } + + @Test + public void prependOnStringNestedInMap() { + // map = {"a": "world", "b": "foo"}; prepend "hello " at key "a" + Map map = new HashMap(); + map.put(Value.get("a"), Value.get("world")); + map.put(Value.get("b"), Value.get("foo")); + putMap(map); + + operate(StringOperation.prepend(POLICY, BIN, "hello ", + CTX.mapKey(Value.get("a")))); + + Map after = client.get(null, KEY).getMap(BIN); + assertEquals("hello world", after.get("a")); + assertEquals("foo", after.get("b")); + } + //================================================================= // toString op — op-type 19, no payload, no sub-op id, no CTX // diff --git a/test/src/com/aerospike/test/sync/basic/TestStringExp.java b/test/src/com/aerospike/test/sync/basic/TestStringExp.java index 49a7b097a..a1a41ffac 100644 --- a/test/src/com/aerospike/test/sync/basic/TestStringExp.java +++ b/test/src/com/aerospike/test/sync/basic/TestStringExp.java @@ -309,6 +309,36 @@ public void concatAppendsListOfValues() { 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 From 0fc4f60bdeb72c92f9f6a68f4f7ca16225d181e1 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Wed, 17 Jun 2026 14:43:41 -0700 Subject: [PATCH 14/19] Added tests for validating `self-overlapping needle` for strings --- .../test/sync/basic/TestOperateString.java | 35 ++++++++++++++++++- .../test/sync/basic/TestStringExp.java | 16 ++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index c574a099c..1d4b8670a 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -65,7 +65,7 @@ public class TestOperateString extends TestSync { public static void serverVersionCheck() { Assume.assumeTrue( "Skipping: string operations require server version 8.1.3 or later", - args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); } //----------------------------------------------------------------- @@ -247,6 +247,28 @@ public void findReturnsMinusOneWhenAbsent() { 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"); @@ -508,6 +530,17 @@ public void replaceAllWithNoMatchLeavesBinUnchanged() { 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 "); diff --git a/test/src/com/aerospike/test/sync/basic/TestStringExp.java b/test/src/com/aerospike/test/sync/basic/TestStringExp.java index a1a41ffac..4fddad93b 100644 --- a/test/src/com/aerospike/test/sync/basic/TestStringExp.java +++ b/test/src/com/aerospike/test/sync/basic/TestStringExp.java @@ -75,7 +75,7 @@ public class TestStringExp extends TestSync { public static void serverVersionCheck() { Assume.assumeTrue( "Skipping: string expressions require server version 8.1.3 or later", - args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); } //----------------------------------------------------------------- @@ -137,6 +137,20 @@ public void findReturnsIndexOfFirstAndNthMatch() { 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"); From 4f475274bb682448fedf56e428a3deb60aa1f87b Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Thu, 18 Jun 2026 10:28:33 -0700 Subject: [PATCH 15/19] Added examples for string operations --- examples/src/com/aerospike/examples/Main.java | 2 + .../com/aerospike/examples/OperateString.java | 300 ++++++++++++++++ .../aerospike/examples/StringExpression.java | 322 ++++++++++++++++++ 3 files changed, 624 insertions(+) create mode 100644 examples/src/com/aerospike/examples/OperateString.java create mode 100644 examples/src/com/aerospike/examples/StringExpression.java 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)); + } +} From 7a80fdd2b82e13a816559130cfc2c9bbe57e5f14 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Thu, 18 Jun 2026 11:34:32 -0700 Subject: [PATCH 16/19] Added query test using String expressiolns --- .../test/sync/query/TestQueryFilterExp.java | 228 +++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java b/test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java index 043e2023d..392f4701e 100644 --- a/test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java +++ b/test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java @@ -24,6 +24,7 @@ import java.util.Map; import org.junit.AfterClass; +import org.junit.Assume; import org.junit.BeforeClass; import org.junit.Test; @@ -37,7 +38,10 @@ import com.aerospike.client.exp.Exp; import com.aerospike.client.exp.ListExp; import com.aerospike.client.exp.MapExp; +import com.aerospike.client.exp.StringExp; +import com.aerospike.client.operation.StringRegexFlags; import com.aerospike.client.policy.QueryPolicy; +import com.aerospike.client.policy.WritePolicy; import com.aerospike.client.query.Filter; import com.aerospike.client.query.IndexType; import com.aerospike.client.query.RecordSet; @@ -64,9 +68,17 @@ public static void prepare() { } } + // sendKey=true so Exp.key() can be evaluated server-side. + // The stored-key flag is set at record-create time; updates with sendKey=true + // will NOT retroactively store the key on records created without it. Delete + // any pre-existing records first so the puts below create fresh records. + WritePolicy wp = new WritePolicy(); + wp.sendKey = true; + // Write records with string keys for (int i = 1; i <= size; i++) { Key key = new Key(args.namespace, setName, keyPrefix + i); + client.delete(null, key); List list = null; Map map = null; @@ -97,10 +109,27 @@ else if (i == 3) { list = new ArrayList(0); map = new HashMap(0); } - client.put(null, key, new Bin(binName, i), new Bin("bin2", i), new Bin("listbin", list), new Bin("mapbin", map)); + + // Rotate prefixes so StringExp predicates pick deterministic subsets: + // i % 3 == 0 -> "alpha-i", == 1 -> "beta-i", == 2 -> "gamma-i". + String prefix; + switch (i % 3) { + case 0: prefix = "alpha"; break; + case 1: prefix = "beta"; break; + default: prefix = "gamma"; break; + } + String str = prefix + "-" + i; + + client.put(wp, key, new Bin(binName, i), new Bin("bin2", i), new Bin("listbin", list), new Bin("mapbin", map), new Bin("strbin", str)); } } + private static void assumeStringExpSupported() { + Assume.assumeTrue( + "Skipping: string expressions require server version 8.1.3 or later", + args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); + } + @AfterClass public static void destroy() { client.dropIndex(null, args.namespace, setName, indexName); @@ -638,4 +667,201 @@ public void queryRecordSize() { rs.close(); } } + + @Test + public void queryStringStartsWith() { + assumeStringExpSupported(); + + int begin = 1; + int end = 10; + + Statement stmt = new Statement(); + stmt.setNamespace(args.namespace); + stmt.setSetName(setName); + stmt.setFilter(Filter.range(binName, begin, end)); + + // strbin starts with "alpha" -> records 3, 6, 9. + QueryPolicy policy = new QueryPolicy(); + policy.filterExp = Exp.build( + StringExp.startsWith(Exp.val("alpha"), Exp.stringBin("strbin"))); + + RecordSet rs = client.query(policy, stmt); + + try { + int count = 0; + + while (rs.next()) { + count++; + } + assertEquals(3, count); + } + finally { + rs.close(); + } + } + + @Test + public void queryStringContains() { + assumeStringExpSupported(); + + int begin = 1; + int end = 10; + + Statement stmt = new Statement(); + stmt.setNamespace(args.namespace); + stmt.setSetName(setName); + stmt.setFilter(Filter.range(binName, begin, end)); + + // strbin contains "amma" -> gamma records 2, 5, 8. + QueryPolicy policy = new QueryPolicy(); + policy.filterExp = Exp.build( + StringExp.contains(Exp.val("amma"), Exp.stringBin("strbin"))); + + RecordSet rs = client.query(policy, stmt); + + try { + int count = 0; + + while (rs.next()) { + count++; + } + assertEquals(3, count); + } + finally { + rs.close(); + } + } + + @Test + public void queryStringEndsWith() { + assumeStringExpSupported(); + + int begin = 1; + int end = 10; + + Statement stmt = new Statement(); + stmt.setNamespace(args.namespace); + stmt.setSetName(setName); + stmt.setFilter(Filter.range(binName, begin, end)); + + // strbin ends with "-10" -> only record 10. + QueryPolicy policy = new QueryPolicy(); + policy.filterExp = Exp.build( + StringExp.endsWith(Exp.val("-10"), Exp.stringBin("strbin"))); + + RecordSet rs = client.query(policy, stmt); + + try { + int count = 0; + + while (rs.next()) { + count++; + } + assertEquals(1, count); + } + finally { + rs.close(); + } + } + + @Test + public void queryStringRegexCompare() { + assumeStringExpSupported(); + + int begin = 1; + int end = 10; + + Statement stmt = new Statement(); + stmt.setNamespace(args.namespace); + stmt.setSetName(setName); + stmt.setFilter(Filter.range(binName, begin, end)); + + // strbin matches /^beta-/ -> records 1, 4, 7, 10. + QueryPolicy policy = new QueryPolicy(); + policy.filterExp = Exp.build( + StringExp.regexCompare(Exp.val("^beta-"), Exp.stringBin("strbin"))); + + RecordSet rs = client.query(policy, stmt); + + try { + int count = 0; + + while (rs.next()) { + count++; + } + assertEquals(4, count); + } + finally { + rs.close(); + } + } + + @Test + public void queryStringContainsOnSetName() { + assumeStringExpSupported(); + + int begin = 1; + int end = 10; + + Statement stmt = new Statement(); + stmt.setNamespace(args.namespace); + stmt.setSetName(setName); + stmt.setFilter(Filter.range(binName, begin, end)); + + // setName ends with "flt" -> every record's set name contains "flt". + // Probe to determine whether the new string-ops family supports source + // expressions other than bin/CDT projections (e.g. Exp.setName(), Exp.key()). + QueryPolicy policy = new QueryPolicy(); + policy.filterExp = Exp.build( + StringExp.contains(Exp.val("flt"), Exp.setName())); + + RecordSet rs = client.query(policy, stmt); + + try { + int count = 0; + + while (rs.next()) { + count++; + } + assertEquals(10, count); + } + finally { + rs.close(); + } + } + + @Test + public void queryKeyStringRegexCompare() { + assumeStringExpSupported(); + + int begin = 1; + int end = 10; + + Statement stmt = new Statement(); + stmt.setNamespace(args.namespace); + stmt.setSetName(setName); + stmt.setFilter(Filter.range(binName, begin, end)); + + // User keys are "flt1".."flt50". Within range 1..10, /^flt3$/ matches "flt3". + QueryPolicy policy = new QueryPolicy(); + policy.filterExp = Exp.build( + StringExp.regexCompare( + Exp.val("^flt3$"), + StringRegexFlags.DEFAULT, + Exp.key(Exp.Type.STRING))); + + RecordSet rs = client.query(policy, stmt); + + try { + int count = 0; + + while (rs.next()) { + count++; + } + assertEquals(1, count); + } + finally { + rs.close(); + } + } } From d2188cfad0536ea4b29e67b6f0d9321a0cdb7ff1 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Thu, 18 Jun 2026 11:35:35 -0700 Subject: [PATCH 17/19] Updated server version filter --- test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java b/test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java index 392f4701e..dcd8b7c7a 100644 --- a/test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java +++ b/test/src/com/aerospike/test/sync/query/TestQueryFilterExp.java @@ -127,7 +127,7 @@ else if (i == 3) { private static void assumeStringExpSupported() { Assume.assumeTrue( "Skipping: string expressions require server version 8.1.3 or later", - args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); } @AfterClass From 73e17552c8e2f77afb35f345b54e857de54c0713 Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Fri, 19 Jun 2026 10:22:30 -0700 Subject: [PATCH 18/19] Updated client version since in deprecation annotation --- client/src/com/aerospike/client/Operation.java | 4 ++-- client/src/com/aerospike/client/exp/Exp.java | 2 +- .../src/com/aerospike/client/operation/StringOperation.java | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/client/src/com/aerospike/client/Operation.java b/client/src/com/aerospike/client/Operation.java index 63a5b463f..9aa93554f 100644 --- a/client/src/com/aerospike/client/Operation.java +++ b/client/src/com/aerospike/client/Operation.java @@ -55,7 +55,7 @@ public static Operation put(Bin bin) { * 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(since = "10.1.0", forRemoval = true) + @Deprecated(since = "10.4.0", forRemoval = true) public static Operation append(Bin bin) { return new Operation(Type.APPEND, bin.name, bin.value); } @@ -67,7 +67,7 @@ public static Operation append(Bin bin) { * 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(since = "10.1.0", forRemoval = true) + @Deprecated(since = "10.4.0", forRemoval = true) public static Operation prepend(Bin bin) { return new Operation(Type.PREPEND, bin.name, bin.value); } diff --git a/client/src/com/aerospike/client/exp/Exp.java b/client/src/com/aerospike/client/exp/Exp.java index 1e520f115..d461ff109 100644 --- a/client/src/com/aerospike/client/exp/Exp.java +++ b/client/src/com/aerospike/client/exp/Exp.java @@ -463,7 +463,7 @@ public static Exp digestModulo(int mod) { * 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(since = "10.1.0", forRemoval = true) + @Deprecated(since = "10.4.0", forRemoval = true) public static Exp regexCompare(String regex, int flags, Exp bin) { return new Regex(bin, regex, flags); } diff --git a/client/src/com/aerospike/client/operation/StringOperation.java b/client/src/com/aerospike/client/operation/StringOperation.java index 7e1374899..880bb4660 100644 --- a/client/src/com/aerospike/client/operation/StringOperation.java +++ b/client/src/com/aerospike/client/operation/StringOperation.java @@ -23,7 +23,6 @@ import com.aerospike.client.Value; import com.aerospike.client.cdt.CTX; import com.aerospike.client.command.ParticleType; -import com.aerospike.client.util.Pack; import com.aerospike.client.util.Packer; /** From 55bd2db91211809d90369e590eee119e64f2ad1e Mon Sep 17 00:00:00 2001 From: Mirza Karacic Date: Mon, 29 Jun 2026 15:40:14 -0700 Subject: [PATCH 19/19] Updated contracts to follow product request --- .../test/sync/basic/TestOperateString.java | 126 +++++++++++++++--- .../test/sync/basic/TestStringExp.java | 2 +- 2 files changed, 105 insertions(+), 23 deletions(-) diff --git a/test/src/com/aerospike/test/sync/basic/TestOperateString.java b/test/src/com/aerospike/test/sync/basic/TestOperateString.java index 1d4b8670a..ecb82051f 100644 --- a/test/src/com/aerospike/test/sync/basic/TestOperateString.java +++ b/test/src/com/aerospike/test/sync/basic/TestOperateString.java @@ -65,7 +65,7 @@ public class TestOperateString extends TestSync { public static void serverVersionCheck() { Assume.assumeTrue( "Skipping: string operations require server version 8.1.3 or later", - args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); } //----------------------------------------------------------------- @@ -674,16 +674,16 @@ public void prependPreservesMultibyteCodepoints() { } @Test - public void appendOnMissingBinWithNoFailIsNoOp() { - // Mirrors the upper() missing-bin NO_FAIL test for the append op. + 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")); - StringPolicy noFail = new StringPolicy(StringWriteFlags.NO_FAIL); - operate(StringOperation.append(noFail, BIN, "x")); + operate(StringOperation.append(POLICY, BIN, "x")); Record r = client.get(null, KEY); - assertEquals(null, r.getValue(BIN)); + assertEquals("x", r.getString(BIN)); assertEquals("untouched", r.getString("other")); } @@ -981,41 +981,123 @@ public void toStringOnListBinReturnsIncompatibleType() { } //================================================================= - // NO_FAIL flag — missing-bin path + // Missing-bin path // - // particle_string.c:926: when the target bin does not exist, the - // server returns AS_OK with no bin written if NO_FAIL is set; without - // it, the server returns AS_ERR_BIN_NOT_FOUND. This is the actual - // scope of NO_FAIL on STRING_MODIFY — the server does NOT honor it - // for wrong-type bins (incompatible-type is hard-errored at line 872 - // regardless of the flag). + // Behavior keys off the op, not the flag. The eight additive + // create-ops {insert, overwrite, concat, append, prepend, padStart, + // padEnd, repeat} create a missing bin from an empty string; + // transform/subtractive ops are a silent no-op (success, bin not + // created). There is no BIN_NOT_FOUND path. NO_FAIL no longer governs + // this path — it only suppresses an in-op execution failure (and still + // does not suppress BIN_TYPE_ERROR on a wrong-type bin). //================================================================= @Test - public void modifyOnMissingBinWithNoFailIsNoOp() { - // Record exists but the target bin does not — exercises the bin-level - // NO_FAIL path at particle_string.c:926 (not the record-level - // KEY_NOT_FOUND path that fires when the whole record is absent). + public void modifyOnMissingBinIsNoOp() { + // A non-create modify op (upper) on a missing bin is a silent no-op + // (success, bin not created) regardless of NO_FAIL — there is no + // BIN_NOT_FOUND path. Record exists but the target bin does not. + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + operate(StringOperation.upper(POLICY, BIN)); + + // BIN must not have been created; the existing bin must be intact. + Record r = client.get(null, KEY); + assertEquals(null, r.getValue(BIN)); + assertEquals("untouched", r.getString("other")); + } + + @Test + public void noFailDoesNotChangeMissingBinNoOp() { + // The missing-bin no-op for non-create ops is flag-independent; NO_FAIL + // neither creates the bin nor raises an error. client.delete(null, KEY); client.put(null, KEY, new Bin("other", "untouched")); StringPolicy noFail = new StringPolicy(StringWriteFlags.NO_FAIL); operate(StringOperation.upper(noFail, BIN)); - // BIN must not have been created; the existing bin must be intact. Record r = client.get(null, KEY); assertEquals(null, r.getValue(BIN)); assertEquals("untouched", r.getString("other")); } + // All eight additive ops create a missing bin from empty in server 8.1.3 + // (string ops + SERVER-97 PR 1452, which adds overwrite/repeat/padStart/ + // padEnd to the create-op set). Transform/subtractive ops still no-op. + // append is covered above in the append section. + @Test - public void modifyOnMissingBinWithoutNoFailRaises() { + public void insertOnMissingBinCreatesTheBinFromEmpty() { client.delete(null, KEY); client.put(null, KEY, new Bin("other", "untouched")); - AerospikeException ae = assertThrows(AerospikeException.class, - () -> operate(StringOperation.upper(POLICY, BIN))); - assertEquals(ResultCode.BIN_NOT_FOUND, ae.getResultCode()); + operate(StringOperation.insert(POLICY, BIN, 0, "hi")); + + assertEquals("hi", client.get(null, KEY).getString(BIN)); + } + + @Test + public void concatOnMissingBinCreatesTheBinFromEmpty() { + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + operate(StringOperation.concat(POLICY, BIN, "hi")); + + assertEquals("hi", client.get(null, KEY).getString(BIN)); + } + + @Test + public void prependOnMissingBinCreatesTheBinFromEmpty() { + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + operate(StringOperation.prepend(POLICY, BIN, "hi")); + + assertEquals("hi", client.get(null, KEY).getString(BIN)); + } + + @Test + public void overwriteOnMissingBinCreatesTheBinFromEmpty() { + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + operate(StringOperation.overwrite(POLICY, BIN, 0, "hi")); + + assertEquals("hi", client.get(null, KEY).getString(BIN)); + } + + @Test + public void padStartOnMissingBinCreatesTheBinFromEmpty() { + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + operate(StringOperation.padStart(POLICY, BIN, 5, "x")); + + assertEquals("xxxxx", client.get(null, KEY).getString(BIN)); + } + + @Test + public void padEndOnMissingBinCreatesTheBinFromEmpty() { + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + operate(StringOperation.padEnd(POLICY, BIN, 5, "x")); + + assertEquals("xxxxx", client.get(null, KEY).getString(BIN)); + } + + @Test + public void repeatOnMissingBinCreatesAnEmptyBin() { + // repeat(n) on empty = "" — the bin is created holding an empty string + // (server test: expect_string_bin(b, "")). + client.delete(null, KEY); + client.put(null, KEY, new Bin("other", "untouched")); + + operate(StringOperation.repeat(POLICY, BIN, 3)); + + assertEquals("", client.get(null, KEY).getString(BIN)); } //================================================================= diff --git a/test/src/com/aerospike/test/sync/basic/TestStringExp.java b/test/src/com/aerospike/test/sync/basic/TestStringExp.java index 4fddad93b..39c7869d5 100644 --- a/test/src/com/aerospike/test/sync/basic/TestStringExp.java +++ b/test/src/com/aerospike/test/sync/basic/TestStringExp.java @@ -75,7 +75,7 @@ public class TestStringExp extends TestSync { public static void serverVersionCheck() { Assume.assumeTrue( "Skipping: string expressions require server version 8.1.3 or later", - args.serverVersion.isGreaterOrEqual(8, 1, 2, 0)); + args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); } //-----------------------------------------------------------------