diff --git a/packages/firestore/__tests__/vector.test.ts b/packages/firestore/__tests__/vector.test.ts new file mode 100644 index 0000000000..ecf8f574f7 --- /dev/null +++ b/packages/firestore/__tests__/vector.test.ts @@ -0,0 +1,35 @@ +/* eslint-env jest */ +import { describe, it, expect } from '@jest/globals'; + +describe('firestore() VectorValue', function () { + const { default: FirestoreVectorValue } = require('../lib/FirestoreVectorValue'); + const serialize = require('../lib/utils/serialize'); + const { getTypeMapName } = require('../lib/utils/typemap'); + + it('constructs and validates values', function () { + const v = new FirestoreVectorValue([0, 1.5, -2]); + expect(v.values).toEqual([0, 1.5, -2]); + expect(v.isEqual(new FirestoreVectorValue([0, 1.5, -2]))).toBe(true); + expect(v.isEqual(new FirestoreVectorValue([0, 1.5]))).toBe(false); + }); + + it('serializes to type map and parses back', function () { + const v = new FirestoreVectorValue([0.1, 0.2, 0.3]); + const typed = serialize.generateNativeData(v, false); + // [INT_VECTOR, [0.1,0.2,0.3]] + expect(Array.isArray(typed)).toBe(true); + expect(getTypeMapName(typed[0])).toBe('vector'); + const parsed = serialize.parseNativeData(null, typed); + expect(parsed instanceof FirestoreVectorValue).toBe(true); + expect(parsed.values).toEqual([0.1, 0.2, 0.3]); + }); + + it('serializes inside objects and arrays', function () { + const v = new FirestoreVectorValue([1, 2, 3]); + const map = serialize.buildNativeMap({ a: v }, false); + expect(getTypeMapName(map.a[0])).toBe('vector'); + + const arr = serialize.buildNativeArray([v], false); + expect(getTypeMapName(arr[0][0])).toBe('vector'); + }); +}); diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreSerialize.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreSerialize.java index 35a9f0727f..f92f9ddb11 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreSerialize.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreSerialize.java @@ -71,6 +71,7 @@ public class ReactNativeFirebaseFirestoreSerialize { private static final int INT_OBJECT = 16; private static final int INT_INTEGER = 17; private static final int INT_NEGATIVE_ZERO = 18; + private static final int INT_VECTOR = 19; private static final int INT_UNKNOWN = -999; // Keys @@ -404,6 +405,36 @@ private static WritableArray buildTypeMap(Object value) { return typeArray; } + // VectorValue – detect via reflection to avoid compile-time dependency on newer SDKs + try { + Class vectorClass = Class.forName("com.google.firebase.firestore.VectorValue"); + if (vectorClass.isInstance(value)) { + typeArray.pushInt(INT_VECTOR); + WritableArray valuesArray = Arguments.createArray(); + try { + double[] doubles = (double[]) vectorClass.getMethod("getValues").invoke(value); + if (doubles != null) { + for (double d : doubles) valuesArray.pushDouble(d); + } + } catch (Exception ignored) { + try { + Object result = vectorClass.getMethod("values").invoke(value); + if (result instanceof double[]) { + for (double d : (double[]) result) valuesArray.pushDouble(d); + } else if (result instanceof List) { + for (Object o : (List) result) valuesArray.pushDouble(((Number) o).doubleValue()); + } + } catch (Exception ignored2) { + // leave empty if not accessible + } + } + typeArray.pushArray(valuesArray); + return typeArray; + } + } catch (ClassNotFoundException e) { + // Older SDK without VectorValue – fall through + } + Log.w(TAG, "Unknown object of type " + value.getClass()); typeArray.pushInt(INT_UNKNOWN); @@ -520,6 +551,26 @@ static Object parseTypeMap(FirebaseFirestore firestore, ReadableArray typeArray) } case INT_OBJECT: return parseReadableMap(firestore, typeArray.getMap(1)); + case INT_VECTOR: + try { + Class vectorClass = Class.forName("com.google.firebase.firestore.VectorValue"); + ReadableArray vals = typeArray.getArray(1); + if (vals == null) return null; + double[] doubles = new double[vals.size()]; + for (int i = 0; i < vals.size(); i++) doubles[i] = vals.getDouble(i); + try { + // Prefer static factory if available + return vectorClass.getMethod("from", double[].class).invoke(null, (Object) doubles); + } catch (Exception noFactory) { + try { + return vectorClass.getConstructor(double[].class).newInstance((Object) doubles); + } catch (Exception noCtor) { + return null; + } + } + } catch (ClassNotFoundException e) { + return null; + } case INT_UNKNOWN: default: return null; diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m index 33f20e0a57..2d9dcf3082 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m @@ -58,6 +58,7 @@ @implementation RNFBFirestoreSerialize INT_OBJECT, INT_INTEGER, INT_NEGATIVE_ZERO, + INT_VECTOR, INT_UNKNOWN = -999, }; @@ -358,6 +359,24 @@ + (NSArray *)buildTypeMap:(id)value { return typeArray; } + // VectorValue (FIRVectorValue) – detect reflectively to avoid hard dependency on symbol + Class vectorClass = NSClassFromString(@"FIRVectorValue"); + if (vectorClass != nil && [value isKindOfClass:vectorClass]) { + typeArray[0] = @(INT_VECTOR); + NSArray *values = nil; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + if ([value respondsToSelector:@selector(values)]) { + values = [value performSelector:@selector(values)]; + } +#pragma clang diagnostic pop + if (values == nil) { + values = @[]; + } + typeArray[1] = values; + return typeArray; + } + typeArray[0] = @(INT_UNKNOWN); return typeArray; } @@ -466,6 +485,23 @@ + (id)parseTypeMap:(FIRFirestore *)firestore typeMap:(NSArray *)typeMap { } case INT_OBJECT: return [self parseNSDictionary:firestore dictionary:typeMap[1]]; + case INT_VECTOR: { + NSArray *values = typeMap[1]; + Class vectorClass = NSClassFromString(@"FIRVectorValue"); + if (vectorClass != nil) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + if ([vectorClass respondsToSelector:@selector(vectorWithValues:)]) { + return [vectorClass performSelector:@selector(vectorWithValues:) withObject:values]; + } + id instance = [vectorClass alloc]; + if ([instance respondsToSelector:@selector(initWithValues:)]) { + return [instance performSelector:@selector(initWithValues:) withObject:values]; + } +#pragma clang diagnostic pop + } + return nil; + } case INT_UNKNOWN: default: return nil; diff --git a/packages/firestore/lib/FirestoreStatics.js b/packages/firestore/lib/FirestoreStatics.js index 19f921160b..c081f5324f 100644 --- a/packages/firestore/lib/FirestoreStatics.js +++ b/packages/firestore/lib/FirestoreStatics.js @@ -23,6 +23,7 @@ import FirestoreFieldValue from './FirestoreFieldValue'; import FirestoreGeoPoint from './FirestoreGeoPoint'; import FirestoreTimestamp from './FirestoreTimestamp'; import { Filter } from './FirestoreFilter'; +import FirestoreVectorValue from './FirestoreVectorValue'; export default { Blob: FirestoreBlob, FieldPath: FirestoreFieldPath, @@ -30,6 +31,10 @@ export default { GeoPoint: FirestoreGeoPoint, Timestamp: createDeprecationProxy(FirestoreTimestamp), Filter: createDeprecationProxy(Filter), + VectorValue: FirestoreVectorValue, + vector(values) { + return new FirestoreVectorValue(values); + }, CACHE_SIZE_UNLIMITED: -1, diff --git a/packages/firestore/lib/FirestoreVectorValue.js b/packages/firestore/lib/FirestoreVectorValue.js new file mode 100644 index 0000000000..1f677c827d --- /dev/null +++ b/packages/firestore/lib/FirestoreVectorValue.js @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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. + * + */ + +import { isArray, isNumber } from '@react-native-firebase/app/lib/common'; + +export default class FirestoreVectorValue { + constructor(values) { + if (values === undefined) { + this._values = []; + return; + } + + if (!isArray(values)) { + throw new Error( + "firebase.firestore.VectorValue(values?) 'values' expected an array of numbers or undefined.", + ); + } + + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (!isNumber(v)) { + throw new Error( + `firebase.firestore.VectorValue(values?) 'values[${i}]' expected a number value.`, + ); + } + } + + // Store a shallow copy to ensure immutability semantics for the input array + this._values = values.slice(); + } + + get values() { + return this._values.slice(); + } + + isEqual(other) { + if (!(other instanceof FirestoreVectorValue)) { + throw new Error( + "firebase.firestore.VectorValue.isEqual(*) 'other' expected a VectorValue instance.", + ); + } + + const a = this._values; + const b = other._values; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + // Use strict equality; Firestore numbers allow NaN/Infinity – equality semantics match JS + if (a[i] !== b[i]) return false; + } + return true; + } + + toJSON() { + return { values: this._values.slice() }; + } + + toString() { + return `FirestoreVectorValue(values=[${this._values.join(', ')}])`; + } +} diff --git a/packages/firestore/lib/index.d.ts b/packages/firestore/lib/index.d.ts index 2fe76bc3c5..30dbd40e93 100644 --- a/packages/firestore/lib/index.d.ts +++ b/packages/firestore/lib/index.d.ts @@ -246,7 +246,8 @@ export namespace FirebaseFirestoreTypes { | FieldPath | FieldValue | DocumentReference - | CollectionReference; + | CollectionReference + | VectorValue; /** * A `DocumentReference` refers to a document location in a Firestore database and can be used to write, read, or listen @@ -2104,6 +2105,16 @@ export namespace FirebaseFirestoreTypes { */ Filter: typeof Filter; + /** + * Returns the `VectorValue` class. + */ + VectorValue: typeof VectorValue; + + /** + * Creates a new VectorValue from the provided numbers. + */ + vector(values?: number[]): VectorValue; + /** * Used to set the cache size to unlimited when passing to `cacheSizeBytes` in * `firebase.firestore().settings()`. @@ -2368,6 +2379,24 @@ export namespace FirebaseFirestoreTypes { [P in keyof T]: SetValue | FieldValue; // allow FieldValue in place of values } : T; + + /** + * An immutable object representing a vector in Firestore. The vector is a numeric array. + */ + export class VectorValue { + constructor(values?: number[]); + /** The numeric values of this VectorValue. */ + readonly values: number[]; + /** Returns true if this `VectorValue` is equal to the provided one. */ + isEqual(other: VectorValue): boolean; + /** Returns a JSON-serializable representation of this VectorValue. */ + toJSON(): { values: number[] }; + } + + /** + * Creates a new VectorValue from the provided numbers. + */ + export function vector(values?: number[]): VectorValue; } declare const defaultExport: ReactNativeFirebase.FirebaseModuleWithStaticsAndApp< diff --git a/packages/firestore/lib/modular/VectorValue.d.ts b/packages/firestore/lib/modular/VectorValue.d.ts new file mode 100644 index 0000000000..d7f0452657 --- /dev/null +++ b/packages/firestore/lib/modular/VectorValue.d.ts @@ -0,0 +1,13 @@ +/** + * A `VectorValue` represents a vector in Firestore. The vector is a numeric array. + * @param values - The numeric values of the vector. + * @returns A new VectorValue instance. + */ +export declare class VectorValue { + readonly values: number[]; + constructor(values?: number[]); + isEqual(other: VectorValue): boolean; + toJSON(): { values: number[] }; +} + +export declare function vector(values?: number[]): VectorValue; diff --git a/packages/firestore/lib/modular/VectorValue.js b/packages/firestore/lib/modular/VectorValue.js new file mode 100644 index 0000000000..de953c2959 --- /dev/null +++ b/packages/firestore/lib/modular/VectorValue.js @@ -0,0 +1,11 @@ +import FirestoreVectorValue from '../FirestoreVectorValue'; + +export const VectorValue = FirestoreVectorValue; + +/** + * @param {number[]=} values + * @returns {VectorValue} + */ +export function vector(values) { + return new VectorValue(values); +} diff --git a/packages/firestore/lib/modular/index.d.ts b/packages/firestore/lib/modular/index.d.ts index 0dff797a08..035f50982b 100644 --- a/packages/firestore/lib/modular/index.d.ts +++ b/packages/firestore/lib/modular/index.d.ts @@ -785,3 +785,4 @@ export * from './FieldPath'; export * from './FieldValue'; export * from './GeoPoint'; export * from './Timestamp'; +export * from './VectorValue'; diff --git a/packages/firestore/lib/modular/index.js b/packages/firestore/lib/modular/index.js index 141bdc8404..7e74edc658 100644 --- a/packages/firestore/lib/modular/index.js +++ b/packages/firestore/lib/modular/index.js @@ -407,4 +407,5 @@ export * from './FieldPath'; export * from './FieldValue'; export * from './GeoPoint'; export * from './Timestamp'; +export * from './VectorValue'; export { Filter } from '../FirestoreFilter'; diff --git a/packages/firestore/lib/utils/serialize.js b/packages/firestore/lib/utils/serialize.js index 123a28febf..71048c1f27 100644 --- a/packages/firestore/lib/utils/serialize.js +++ b/packages/firestore/lib/utils/serialize.js @@ -32,6 +32,7 @@ import FirestorePath from '../FirestorePath'; import FirestoreTimestamp from '../FirestoreTimestamp'; import { getTypeMapInt, getTypeMapName } from './typemap'; import { Bytes } from '../modular/Bytes'; +import FirestoreVectorValue from '../FirestoreVectorValue'; // To avoid React Native require cycle warnings let FirestoreDocumentReference = null; @@ -162,7 +163,7 @@ export function generateNativeData(value, ignoreUndefined) { } if (isObject(value)) { - if (value instanceof FirestoreDocumentReference) { + if (FirestoreDocumentReference && value instanceof FirestoreDocumentReference) { return getTypeMapInt('reference', value.path); } @@ -185,10 +186,14 @@ export function generateNativeData(value, ignoreUndefined) { return getTypeMapInt('blob', value.toBase64()); } - if (value instanceof FirestoreFieldValue) { + if (FirestoreFieldValue && value instanceof FirestoreFieldValue) { return getTypeMapInt('fieldvalue', [value._type, value._elements]); } + if (value instanceof FirestoreVectorValue) { + return getTypeMapInt('vector', value.values); + } + return getTypeMapInt('object', buildNativeMap(value, ignoreUndefined)); } @@ -279,6 +284,8 @@ export function parseNativeData(firestore, nativeArray) { return new FirestoreTimestamp(value[0], value[1]); case 'blob': return Bytes.fromBase64String(value); + case 'vector': + return new FirestoreVectorValue(value); default: // eslint-disable-next-line no-console console.warn(`Unknown data type received from native channel: ${type}`); diff --git a/packages/firestore/lib/utils/typemap.js b/packages/firestore/lib/utils/typemap.js index 03173426f7..5bc2c3295e 100644 --- a/packages/firestore/lib/utils/typemap.js +++ b/packages/firestore/lib/utils/typemap.js @@ -37,6 +37,7 @@ const MAP = { object: 16, integer: 17, negativeZero: 18, + vector: 19, unknown: -999, }; diff --git a/packages/firestore/lib/web/convert.js b/packages/firestore/lib/web/convert.js index 0ab2e674a4..14e5db389f 100644 --- a/packages/firestore/lib/web/convert.js +++ b/packages/firestore/lib/web/convert.js @@ -10,6 +10,7 @@ import { deleteField, arrayUnion, arrayRemove, + VectorValue, } from '@react-native-firebase/app/lib/internal/web/firebaseFirestore'; const INT_NAN = 0; @@ -31,6 +32,7 @@ const INT_FIELDVALUE = 15; const INT_OBJECT = 16; const INT_INTEGER = 17; const INT_NEGATIVE_ZERO = 18; +const INT_VECTOR = 19; const INT_UNKNOWN = -999; const TYPE = 'type'; @@ -175,6 +177,13 @@ export function buildTypeMap(value) { return out; } + if (value instanceof VectorValue) { + out.push(INT_VECTOR); + // Web VectorValue should expose its numeric array. Fallback to empty if not present. + out.push(value.values ?? []); + return out; + } + if (typeof value === 'object') { out.push(INT_OBJECT); out.push(objectToWriteable(value)); @@ -253,6 +262,8 @@ export function parseTypeMap(firestore, typedArray) { } case INT_OBJECT: return readableToObject(firestore, typedArray[1]); + case INT_VECTOR: + return new VectorValue(typedArray[1]); case INT_UNKNOWN: default: return null;