Jackie-Jiang commented on code in PR #18979:
URL: https://github.com/apache/pinot/pull/18979#discussion_r4009790253
##########
pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java:
##########
@@ -611,6 +634,409 @@ public static Object jsonExtractObject(@Nullable Object
object) {
return null;
}
+ /// Extract a scalar (or scalar-array) value from a JSON document and coerce
it to `resultsType`.
+ ///
+ /// Scalar-function counterpart of the `jsonExtractScalar` transform
(`JsonExtractScalarTransformFunction` in
+ /// pinot-core), so that `json_extract_scalar(...)` resolves in the
multi-stage engine and in ad-hoc scalar
+ /// contexts. `resultsType` is a Pinot [DataType] name, optionally suffixed
with `_ARRAY` for a multi-value
+ /// result. Supported types are
`INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/BOOLEAN/TIMESTAMP/STRING/JSON/BYTES` and
+ /// the `INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/BOOLEAN/TIMESTAMP/STRING` array
variants.
+ ///
+ /// The document may be a `String`, a UTF-8 encoded `byte[]` (BYTES columns)
or an already-parsed container.
+ /// Coercion mirrors the transform exactly: `BOOLEAN` is returned as its
stored `INT` (0/1), `TIMESTAMP` as
+ /// epoch millis (numeric values as-is, strings via ISO-8601), `BIG_DECIMAL`
/ `STRING` / `JSON` / `LONG`
+ /// use a BigDecimal-preserving parser. The 3-argument form throws on an
unresolved single-value path. The
+ /// 4-argument form returns `defaultValue` (including SQL `NULL`). A
multi-value path yields an empty
+ /// array when unresolved or when the resolved value is not a JSON array,
but a `null` element inside a
+ /// resolved array still throws unless a default is supplied. A malformed
JSON document is treated as
+ /// unresolved. An illegal JSONPath (for example `$[`) is rejected, matching
transform init.
+ @ScalarFunction
+ public static Object jsonExtractScalar(Object jsonInput, String jsonPath,
String resultsType) {
+ return jsonExtractScalarInternal(jsonInput, jsonPath, resultsType, null,
false);
+ }
+
+ /// See [#jsonExtractScalar(Object, String, String)]. `defaultValue` is
returned (coerced to `resultsType`)
+ /// when the path resolves to `null` or the document is malformed. An
explicit SQL `NULL` default returns
+ /// Java `null` rather than throwing.
+ @ScalarFunction(nullableParameters = true)
+ public static Object jsonExtractScalar(@Nullable Object jsonInput, String
jsonPath, String resultsType,
+ @Nullable Object defaultValue) {
+ return jsonExtractScalarInternal(jsonInput, jsonPath, resultsType,
defaultValue, true);
+ }
+
+ @Nullable
+ private static Object jsonExtractScalarInternal(@Nullable Object jsonInput,
String jsonPath, String resultsType,
+ @Nullable Object defaultValue, boolean hasDefault) {
+ String type = resultsType.toUpperCase();
+ boolean isSingleValue = !type.endsWith("_ARRAY");
+ DataType dataType;
+ try {
+ dataType = DataType.valueOf(isSingleValue ? type : type.substring(0,
type.length() - 6));
+ } catch (IllegalArgumentException e) {
+ throw new
IllegalArgumentException(unsupportedResultsTypeMessage(resultsType));
+ }
+ // BIG_DECIMAL / STRING / JSON / LONG must read floats as BigDecimal so
values above 2^53 stay exact.
+ boolean useBigDecimal = dataType == DataType.BIG_DECIMAL || dataType ==
DataType.STRING
+ || dataType == DataType.JSON || dataType == DataType.LONG;
+ // Compile the path before touching the document. `$[` must fail the
query, not become a default,
+ // matching JsonExtractScalarTransformFunction#init.
+ JsonPath compiledPath = JsonPathCache.INSTANCE.getOrCompute(jsonPath);
+ if (isSingleValue) {
+ Object value = readJsonPathValue(jsonInput, compiledPath, useBigDecimal);
+ if (value == null) {
+ if (!hasDefault) {
+ throw new IllegalArgumentException(
+ "Cannot resolve JSON path on some records. Consider setting a
default value.");
+ }
+ if (defaultValue == null) {
+ return null;
+ }
+ return coerceScalar(defaultValue, dataType, true);
+ }
+ return coerceScalar(value, dataType, false);
+ }
+ return coerceScalarArray(readJsonPathArray(jsonInput, compiledPath,
useBigDecimal), dataType, defaultValue,
+ hasDefault);
+ }
+
+ private static DocumentContext parseJsonDocument(Object jsonInput,
ParseContext parseContext) {
+ if (jsonInput instanceof String) {
+ return parseContext.parse((String) jsonInput);
+ }
+ if (jsonInput instanceof byte[]) {
+ // BYTES columns carry the raw UTF-8 document; parse(Object) would treat
the array as already parsed.
+ return parseContext.parseUtf8((byte[]) jsonInput);
+ }
+ return parseContext.parse(jsonInput);
+ }
+
+ @Nullable
+ private static Object readJsonPathValue(@Nullable Object jsonInput, JsonPath
jsonPath, boolean useBigDecimal) {
+ DocumentContext document = parseJsonDocumentOrNull(jsonInput,
useBigDecimal);
+ if (document == null) {
+ return null;
+ }
+ return document.read(jsonPath);
+ }
+
+ @Nullable
+ private static Object[] readJsonPathArray(@Nullable Object jsonInput,
JsonPath jsonPath, boolean useBigDecimal) {
+ DocumentContext document = parseJsonDocumentOrNull(jsonInput,
useBigDecimal);
+ if (document == null) {
+ return null;
+ }
+ return jsonArrayOrUnresolved(document.read(jsonPath));
+ }
+
+ /// Parses the document only. A bad document is unresolved (null). Path
compile already happened
+ /// in [jsonExtractScalarInternal], so a bad path is not swallowed here.
+ @Nullable
+ private static DocumentContext parseJsonDocumentOrNull(@Nullable Object
jsonInput, boolean useBigDecimal) {
+ if (jsonInput == null) {
+ return null;
+ }
+ try {
+ return parseJsonDocument(jsonInput, useBigDecimal ?
PARSE_CONTEXT_WITH_BIG_DECIMAL : PARSE_CONTEXT);
+ } catch (Exception e) {
+ // Malformed JSON (e.g. a plain-text row) is treated as unresolved,
mirroring the transform which swallows
+ // per-row parse errors; the caller then applies the default or throws.
+ return null;
+ }
+ }
+
+ /// Transform casts the JsonPath result to `List`. A scalar or object is
therefore unresolved and
+ /// becomes an empty array. Do not wrap a non-list in a one-element array.
+ @Nullable
+ private static Object[] jsonArrayOrUnresolved(@Nullable Object value) {
+ if (value instanceof List) {
Review Comment:
[P2] Preserve decoded Object[] inputs in scalar extraction
The new List-only check rejects real `Object[]` arrays, a supported input
representation produced by Pinot's ingestion path. With `columnJson =
'{"v":[1,2]}'`, the ingestion expression
`jsonExtractScalar(jsonPathArray(columnJson, '$.v'), '$', 'INT_ARRAY')` now
returns `[]` instead of `[1,2]`: `jsonPathArray` returns Object[], the
evaluator passes it unchanged, and Jayway's root path returns that same array.
Existing JsonFunctionsTest coverage documents JSONRecordExtractor's conversion
of collections into Object[].
There is a related traversal regression: `jsonExtractScalar(Map.of("v", new
Object[]{1,2}), "$.v[0]", "LONG", -1)` now returns `-1`, because LONG selects
the plain BigDecimal Jackson provider, which does not recognize Object[]. INT
still returns `1`.
Please retain Object[] support in both the result-array check and the
BigDecimal parser's array handling, while continuing to reject actual
scalar/object values. Add decoded-container and nested-ingestion regression
coverage.
##########
pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java:
##########
@@ -611,6 +634,409 @@ public static Object jsonExtractObject(@Nullable Object
object) {
return null;
}
+ /// Extract a scalar (or scalar-array) value from a JSON document and coerce
it to `resultsType`.
+ ///
+ /// Scalar-function counterpart of the `jsonExtractScalar` transform
(`JsonExtractScalarTransformFunction` in
+ /// pinot-core), so that `json_extract_scalar(...)` resolves in the
multi-stage engine and in ad-hoc scalar
+ /// contexts. `resultsType` is a Pinot [DataType] name, optionally suffixed
with `_ARRAY` for a multi-value
+ /// result. Supported types are
`INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/BOOLEAN/TIMESTAMP/STRING/JSON/BYTES` and
+ /// the `INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/BOOLEAN/TIMESTAMP/STRING` array
variants.
+ ///
+ /// The document may be a `String`, a UTF-8 encoded `byte[]` (BYTES columns)
or an already-parsed container.
+ /// Coercion mirrors the transform exactly: `BOOLEAN` is returned as its
stored `INT` (0/1), `TIMESTAMP` as
+ /// epoch millis (numeric values as-is, strings via ISO-8601), `BIG_DECIMAL`
/ `STRING` / `JSON` / `LONG`
+ /// use a BigDecimal-preserving parser. The 3-argument form throws on an
unresolved single-value path. The
+ /// 4-argument form returns `defaultValue` (including SQL `NULL`). A
multi-value path yields an empty
+ /// array when unresolved or when the resolved value is not a JSON array,
but a `null` element inside a
+ /// resolved array still throws unless a default is supplied. A malformed
JSON document is treated as
+ /// unresolved. An illegal JSONPath (for example `$[`) is rejected, matching
transform init.
+ @ScalarFunction
+ public static Object jsonExtractScalar(Object jsonInput, String jsonPath,
String resultsType) {
+ return jsonExtractScalarInternal(jsonInput, jsonPath, resultsType, null,
false);
+ }
+
+ /// See [#jsonExtractScalar(Object, String, String)]. `defaultValue` is
returned (coerced to `resultsType`)
+ /// when the path resolves to `null` or the document is malformed. An
explicit SQL `NULL` default returns
+ /// Java `null` rather than throwing.
+ @ScalarFunction(nullableParameters = true)
+ public static Object jsonExtractScalar(@Nullable Object jsonInput, String
jsonPath, String resultsType,
+ @Nullable Object defaultValue) {
+ return jsonExtractScalarInternal(jsonInput, jsonPath, resultsType,
defaultValue, true);
+ }
+
+ @Nullable
+ private static Object jsonExtractScalarInternal(@Nullable Object jsonInput,
String jsonPath, String resultsType,
+ @Nullable Object defaultValue, boolean hasDefault) {
+ String type = resultsType.toUpperCase();
+ boolean isSingleValue = !type.endsWith("_ARRAY");
+ DataType dataType;
+ try {
+ dataType = DataType.valueOf(isSingleValue ? type : type.substring(0,
type.length() - 6));
+ } catch (IllegalArgumentException e) {
+ throw new
IllegalArgumentException(unsupportedResultsTypeMessage(resultsType));
+ }
+ // BIG_DECIMAL / STRING / JSON / LONG must read floats as BigDecimal so
values above 2^53 stay exact.
+ boolean useBigDecimal = dataType == DataType.BIG_DECIMAL || dataType ==
DataType.STRING
+ || dataType == DataType.JSON || dataType == DataType.LONG;
+ // Compile the path before touching the document. `$[` must fail the
query, not become a default,
+ // matching JsonExtractScalarTransformFunction#init.
+ JsonPath compiledPath = JsonPathCache.INSTANCE.getOrCompute(jsonPath);
+ if (isSingleValue) {
+ Object value = readJsonPathValue(jsonInput, compiledPath, useBigDecimal);
+ if (value == null) {
+ if (!hasDefault) {
+ throw new IllegalArgumentException(
+ "Cannot resolve JSON path on some records. Consider setting a
default value.");
+ }
+ if (defaultValue == null) {
+ return null;
+ }
+ return coerceScalar(defaultValue, dataType, true);
+ }
+ return coerceScalar(value, dataType, false);
+ }
+ return coerceScalarArray(readJsonPathArray(jsonInput, compiledPath,
useBigDecimal), dataType, defaultValue,
+ hasDefault);
+ }
+
+ private static DocumentContext parseJsonDocument(Object jsonInput,
ParseContext parseContext) {
+ if (jsonInput instanceof String) {
+ return parseContext.parse((String) jsonInput);
+ }
+ if (jsonInput instanceof byte[]) {
+ // BYTES columns carry the raw UTF-8 document; parse(Object) would treat
the array as already parsed.
+ return parseContext.parseUtf8((byte[]) jsonInput);
+ }
+ return parseContext.parse(jsonInput);
+ }
+
+ @Nullable
+ private static Object readJsonPathValue(@Nullable Object jsonInput, JsonPath
jsonPath, boolean useBigDecimal) {
+ DocumentContext document = parseJsonDocumentOrNull(jsonInput,
useBigDecimal);
+ if (document == null) {
+ return null;
+ }
+ return document.read(jsonPath);
+ }
+
+ @Nullable
+ private static Object[] readJsonPathArray(@Nullable Object jsonInput,
JsonPath jsonPath, boolean useBigDecimal) {
+ DocumentContext document = parseJsonDocumentOrNull(jsonInput,
useBigDecimal);
+ if (document == null) {
+ return null;
+ }
+ return jsonArrayOrUnresolved(document.read(jsonPath));
+ }
+
+ /// Parses the document only. A bad document is unresolved (null). Path
compile already happened
+ /// in [jsonExtractScalarInternal], so a bad path is not swallowed here.
+ @Nullable
+ private static DocumentContext parseJsonDocumentOrNull(@Nullable Object
jsonInput, boolean useBigDecimal) {
+ if (jsonInput == null) {
+ return null;
+ }
+ try {
+ return parseJsonDocument(jsonInput, useBigDecimal ?
PARSE_CONTEXT_WITH_BIG_DECIMAL : PARSE_CONTEXT);
+ } catch (Exception e) {
+ // Malformed JSON (e.g. a plain-text row) is treated as unresolved,
mirroring the transform which swallows
+ // per-row parse errors; the caller then applies the default or throws.
+ return null;
+ }
+ }
+
+ /// Transform casts the JsonPath result to `List`. A scalar or object is
therefore unresolved and
+ /// becomes an empty array. Do not wrap a non-list in a one-element array.
+ @Nullable
+ private static Object[] jsonArrayOrUnresolved(@Nullable Object value) {
+ if (value instanceof List) {
+ return ((List<?>) value).toArray();
+ }
+ return null;
+ }
+
+ private static Object coerceScalar(Object value, DataType dataType, boolean
isDefault) {
+ switch (dataType) {
+ case INT:
+ return coerceToInt(value, false);
+ case BOOLEAN:
+ return coerceToInt(value, true);
+ case LONG:
+ return coerceToLong(value, false);
+ case TIMESTAMP:
+ return coerceToLong(value, true);
+ case FLOAT:
+ return coerceToFloat(value);
+ case DOUBLE:
+ return coerceToDouble(value);
+ case BIG_DECIMAL:
+ return coerceToBigDecimal(value);
+ case STRING:
+ case JSON:
+ return coerceToString(value);
+ case BYTES:
+ return isDefault ? coerceDefaultToBytes(value) :
coerceExtractedToBytes(value);
+ default:
+ throw new
IllegalArgumentException(unsupportedResultsTypeMessage(dataType.name()));
+ }
+ }
+
+ private static Object coerceScalarArray(@Nullable Object[] array, DataType
dataType, @Nullable Object defaultValue,
+ boolean hasDefault) {
+ switch (dataType) {
+ case INT:
+ return toIntArray(array, defaultValue, hasDefault, false);
+ case BOOLEAN:
+ return toIntArray(array, defaultValue, hasDefault, true);
+ case LONG:
+ return toLongArray(array, defaultValue, hasDefault, false);
+ case TIMESTAMP:
+ return toLongArray(array, defaultValue, hasDefault, true);
+ case FLOAT:
+ return toFloatArray(array, defaultValue, hasDefault);
+ case DOUBLE:
+ return toDoubleArray(array, defaultValue, hasDefault);
+ case BIG_DECIMAL:
+ return toBigDecimalArray(array, defaultValue, hasDefault);
+ case STRING:
+ return toStringArray(array, defaultValue, hasDefault);
+ default:
+ throw new
IllegalArgumentException(unsupportedResultsTypeMessage(dataType.name() +
"_ARRAY"));
+ }
+ }
+
+ /// Resolve a single array element: pass through a non-null element,
substitute the default when the
+ /// element is `null` and a default was supplied, or throw when a null
element has no default.
+ /// An explicit SQL `NULL` default returns `null` so the caller can write a
type placeholder.
+ @Nullable
+ private static Object resolveArrayElement(@Nullable Object element,
@Nullable Object defaultValue,
+ boolean hasDefault) {
+ if (element != null) {
+ return element;
+ }
+ if (!hasDefault) {
+ throw new IllegalArgumentException(
+ "At least one of the resolved JSON arrays include nulls, which is
not supported in Pinot. "
+ + "Consider setting a default value as the fourth argument of
json_extract_scalar.");
+ }
+ return defaultValue;
+ }
+
+ private static int[] toIntArray(@Nullable Object[] array, @Nullable Object
defaultValue, boolean hasDefault,
+ boolean isBoolean) {
+ if (array == null) {
+ return new int[0];
+ }
+ int[] values = new int[array.length];
+ for (int i = 0; i < array.length; i++) {
+ Object resolved = resolveArrayElement(array[i], defaultValue,
hasDefault);
+ values[i] = resolved == null ? 0 : coerceToInt(resolved, isBoolean);
+ }
+ return values;
+ }
+
+ private static long[] toLongArray(@Nullable Object[] array, @Nullable Object
defaultValue, boolean hasDefault,
+ boolean isTimestamp) {
+ if (array == null) {
+ return new long[0];
+ }
+ long[] values = new long[array.length];
+ for (int i = 0; i < array.length; i++) {
+ Object resolved = resolveArrayElement(array[i], defaultValue,
hasDefault);
+ values[i] = resolved == null ? 0L : coerceToLong(resolved, isTimestamp);
+ }
+ return values;
+ }
+
+ private static float[] toFloatArray(@Nullable Object[] array, @Nullable
Object defaultValue, boolean hasDefault) {
+ if (array == null) {
+ return new float[0];
+ }
+ float[] values = new float[array.length];
+ for (int i = 0; i < array.length; i++) {
+ Object resolved = resolveArrayElement(array[i], defaultValue,
hasDefault);
+ values[i] = resolved == null ? 0f : coerceToFloat(resolved);
+ }
+ return values;
+ }
+
+ private static double[] toDoubleArray(@Nullable Object[] array, @Nullable
Object defaultValue, boolean hasDefault) {
+ if (array == null) {
+ return new double[0];
+ }
+ double[] values = new double[array.length];
+ for (int i = 0; i < array.length; i++) {
+ Object resolved = resolveArrayElement(array[i], defaultValue,
hasDefault);
+ values[i] = resolved == null ? 0d : coerceToDouble(resolved);
+ }
+ return values;
+ }
+
+ private static BigDecimal[] toBigDecimalArray(@Nullable Object[] array,
@Nullable Object defaultValue,
+ boolean hasDefault) {
+ if (array == null) {
+ return new BigDecimal[0];
+ }
+ BigDecimal[] values = new BigDecimal[array.length];
+ for (int i = 0; i < array.length; i++) {
+ Object resolved = resolveArrayElement(array[i], defaultValue,
hasDefault);
+ values[i] = resolved == null ? BigDecimal.ZERO :
coerceToBigDecimal(resolved);
+ }
+ return values;
+ }
+
+ private static String[] toStringArray(@Nullable Object[] array, @Nullable
Object defaultValue, boolean hasDefault) {
+ if (array == null) {
+ return new String[0];
+ }
+ String[] values = new String[array.length];
+ for (int i = 0; i < array.length; i++) {
+ Object resolved = resolveArrayElement(array[i], defaultValue,
hasDefault);
+ values[i] = resolved == null ? "" : coerceToString(resolved);
+ }
+ return values;
+ }
+
+ /// SQL BYTES defaults are hex literals or raw `byte[]`. Do not
Base64-decode them.
+ private static byte[] coerceDefaultToBytes(Object value) {
+ if (value instanceof byte[]) {
+ return (byte[]) value;
+ }
+ return BytesUtils.toBytes(value.toString());
+ }
+
+ /// Coerces a JsonPath result to stored `INT`. When `isBoolean` is true,
follows Pinot's numeric
+ /// BOOLEAN convention (any non-zero `Number` is true; `"true"` / `"TRUE"` /
`"1"` via
+ /// [BooleanUtils#toInt(String)]).
+ public static int coerceToInt(Object value, boolean isBoolean) {
+ if (isBoolean) {
+ if (value instanceof Boolean) {
+ return (Boolean) value ? 1 : 0;
+ }
+ // For BOOLEAN result, follow Pinot's numeric convention: any non-zero
number is true.
+ if (value instanceof Number) {
+ return ((Number) value).doubleValue() != 0 ? 1 : 0;
+ }
+ // String fallback: BooleanUtils.toInt accepts "true" / "TRUE" / "1".
+ return BooleanUtils.toInt(value.toString());
+ }
+ if (value instanceof Number) {
+ return ((Number) value).intValue();
+ }
+ if (value instanceof Boolean) {
+ return (Boolean) value ? 1 : 0;
+ }
+ return Integer.parseInt(value.toString());
+ }
+
+ /// Coerces a JsonPath result to stored `LONG`. When `isTimestamp` is true,
numeric values are
+ /// epoch millis and strings go through [TimestampUtils#toMillisSinceEpoch].
Otherwise string
+ /// numbers use [JsonNumberUtils#parseJsonLong] (exact decimal, truncate
toward zero, reject
+ /// overflow). Unquoted JSON numbers arrive as [Number]; floats should
already be [BigDecimal]
+ /// from the LONG parse context so values above `2^53` stay exact.
+ public static long coerceToLong(Object value, boolean isTimestamp) {
+ if (value instanceof Number) {
+ return longFromJsonNumber((Number) value);
+ }
+ if (isTimestamp) {
+ return TimestampUtils.toMillisSinceEpoch(value.toString());
+ }
+ if (value instanceof Boolean) {
+ return (Boolean) value ? 1L : 0L;
+ }
+ try {
+ return JsonNumberUtils.parseJsonLong(value.toString());
+ } catch (NumberFormatException e) {
+ throw new NumberFormatException("For input string: \"" + value + "\"");
+ }
+ }
+
+ /// Converts a JsonPath [Number] to long without saturating.
`Double.longValue()` and
+ /// `BigInteger.longValue()` wrap or clamp values outside the long range.
+ private static long longFromJsonNumber(Number number) {
+ if (number instanceof BigInteger) {
+ try {
+ return ((BigInteger) number).longValueExact();
+ } catch (ArithmeticException e) {
+ throw new NumberFormatException("For input string: \"" + number +
"\"");
+ }
+ }
+ if (number instanceof BigDecimal) {
+ try {
+ return ((BigDecimal) number).setScale(0,
RoundingMode.DOWN).longValueExact();
+ } catch (ArithmeticException e) {
+ throw new NumberFormatException("For input string: \"" + number +
"\"");
+ }
+ }
+ if (number instanceof Double || number instanceof Float) {
+ try {
+ return BigDecimal.valueOf(number.doubleValue()).setScale(0,
RoundingMode.DOWN).longValueExact();
Review Comment:
[P1] Preserve the numeric value of already-parsed doubles
`BigDecimal.valueOf(double)` uses the double's shortest decimal spelling,
which can represent a different integer from the exact binary value. For
example, `jsonExtractScalar(Map.of("v", 100000000000000032d), "$.v", "LONG")`
returns `100000000000000030`, although the supplied double represents
`100000000000000032` exactly. Passing `Map.of("v", (double) Long.MIN_VALUE)`
now throws: the binary value is exactly `-9223372036854775808`, but its decimal
spelling becomes `-9223372036854776000`.
The previous guarded cast preserved these valid values. This branch remains
reachable for already-parsed containers and unquoted TIMESTAMP floats. Please
preserve the range-checked cast for materialized floating-point values, or
construct BigDecimal from their exact binary value. Add regression coverage for
parsed Double inputs above 2^53 and at Long.MIN_VALUE.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]