diff --git a/packages/firestore/__tests__/firestore.test.ts b/packages/firestore/__tests__/firestore.test.ts index 29e6bf2172..f2760b017a 100644 --- a/packages/firestore/__tests__/firestore.test.ts +++ b/packages/firestore/__tests__/firestore.test.ts @@ -79,6 +79,8 @@ import firestore, { enablePersistentCacheIndexAutoCreation, onSnapshotsInSync, documentId, + vector, + VectorValue, } from '../lib'; const COLLECTION = 'firestore'; @@ -746,6 +748,14 @@ describe('Firestore', function () { it('`documentId` is properly exposed to end user', function () { expect(documentId).toBeDefined(); }); + + it('`VectorValue` is properly exposed to end user', function () { + expect(VectorValue).toBeDefined(); + }); + + it('`vector()` is properly exposed to end user', function () { + expect(vector).toBeDefined(); + }); }); describe('test `console.warn` is called for RNFB v8 API & not called for v9 API', function () { @@ -1437,4 +1447,37 @@ describe('Firestore', function () { }); }); }); + + describe('VectorValue (unit serializer)', function () { + it('constructs and validates values', function () { + const v = vector([0, 1.5, -2]); + expect(v.toArray()).toEqual([0, 1.5, -2]); + expect(v.isEqual(vector([0, 1.5, -2]))).toBe(true); + expect(v.isEqual(vector([0, 1.5]))).toBe(false); + }); + + it('serializes to type map and parses back', function () { + const serialize = require('../lib/utils/serialize'); + const { getTypeMapName } = require('../lib/utils/typemap'); + + const v = vector([0.1, 0.2, 0.3]); + const typed = serialize.generateNativeData(v, false); + expect(Array.isArray(typed)).toBe(true); + expect(getTypeMapName(typed[0])).toBe('vector'); + const parsed = serialize.parseNativeData(null, typed); + expect(parsed.toArray()).toEqual([0.1, 0.2, 0.3]); + }); + + it('serializes inside objects and arrays', function () { + const serialize = require('../lib/utils/serialize'); + const { getTypeMapName } = require('../lib/utils/typemap'); + + const v = vector([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..490eba46e4 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 @@ -40,6 +40,7 @@ import com.google.firebase.firestore.MetadataChanges; import com.google.firebase.firestore.QuerySnapshot; import com.google.firebase.firestore.SnapshotMetadata; +import com.google.firebase.firestore.VectorValue; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -71,6 +72,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 +406,12 @@ private static WritableArray buildTypeMap(Object value) { return typeArray; } + if (value instanceof VectorValue) { + typeArray.pushInt(INT_VECTOR); + typeArray.pushArray(Arguments.fromArray(((VectorValue) value).toArray())); + return typeArray; + } + Log.w(TAG, "Unknown object of type " + value.getClass()); typeArray.pushInt(INT_UNKNOWN); @@ -520,6 +528,12 @@ static Object parseTypeMap(FirebaseFirestore firestore, ReadableArray typeArray) } case INT_OBJECT: return parseReadableMap(firestore, typeArray.getMap(1)); + case INT_VECTOR: + ReadableArray vals = typeArray.getArray(1); + int length = vals != null ? vals.size() : 0; + double[] doubles = new double[length]; + for (int i = 0; i < length; i++) doubles[i] = vals.getDouble(i); + return FieldValue.vector(doubles); case INT_UNKNOWN: default: return null; diff --git a/packages/firestore/e2e/VectorValue.e2e.js b/packages/firestore/e2e/VectorValue.e2e.js new file mode 100644 index 0000000000..eeb2e495dd --- /dev/null +++ b/packages/firestore/e2e/VectorValue.e2e.js @@ -0,0 +1,84 @@ +/* + * 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. + * + */ + +const COLLECTION = 'firestore'; + +describe('firestore.VectorValue', function () { + describe('modular', function () { + function ref(id) { + const { doc, getFirestore } = firestoreModular; + return doc(getFirestore(), `${COLLECTION}/vector_${id}`); + } + + it('writes and reads a vector', async function () { + const { setDoc, getDoc, vector } = firestoreModular; + + const r = ref('basic'); + await setDoc(r, { embedding: vector([0.12, 0.34, 0.56]) }); + + const snap = await getDoc(r); + const v = snap.get('embedding'); + should.exist(v); + v.toArray().should.eql([0.12, 0.34, 0.56]); + }); + + it('supports vectors in nested structures', async function () { + const { setDoc, getDoc, vector } = firestoreModular; + + const r = ref('nested'); + await setDoc(r, { + a: { b: vector([1, 2, 3]) }, + }); + + const snap = await getDoc(r); + snap.get('a').b.toArray().should.eql([1, 2, 3]); + }); + + it('updates a vector field', async function () { + const { setDoc, getDoc, updateDoc, vector } = firestoreModular; + + const r = ref('update'); + await setDoc(r, { x: 1 }); + await updateDoc(r, { embedding: vector([9, 8, 7]) }); + + const snap = await getDoc(r); + snap.get('embedding').toArray().should.eql([9, 8, 7]); + }); + + it('batch writes a vector', async function () { + const { getFirestore, writeBatch, getDoc, vector } = firestoreModular; + const r = ref('batch'); + const b = writeBatch(getFirestore()); + b.set(r, { embedding: vector([0.1, 0.2]) }); + await b.commit(); + + const snap = await getDoc(r); + snap.get('embedding').toArray().should.eql([0.1, 0.2]); + }); + + it('transaction writes a vector', async function () { + const { getFirestore, runTransaction, getDoc, vector } = firestoreModular; + const r = ref('transaction'); + await runTransaction(getFirestore(), async tx => { + tx.set(r, { embedding: vector([3.14, 2.72]) }); + }); + + const snap = await getDoc(r); + snap.get('embedding').toArray().should.eql([3.14, 2.72]); + }); + }); +}); diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m index 33f20e0a57..70dd25e085 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreSerialize.m @@ -16,8 +16,10 @@ * */ -#import "RNFBFirestoreSerialize.h" +#import "FirebaseFirestore/FIRVectorValue.h" + #import "RNFBFirestoreCommon.h" +#import "RNFBFirestoreSerialize.h" #import "RNFBPreferences.h" @implementation RNFBFirestoreSerialize @@ -58,6 +60,7 @@ @implementation RNFBFirestoreSerialize INT_OBJECT, INT_INTEGER, INT_NEGATIVE_ZERO, + INT_VECTOR, INT_UNKNOWN = -999, }; @@ -358,6 +361,14 @@ + (NSArray *)buildTypeMap:(id)value { return typeArray; } + // Vector + if ([value isKindOfClass:[FIRVectorValue class]]) { + FIRVectorValue *vector = (FIRVectorValue *)value; + typeArray[0] = @(INT_VECTOR); + typeArray[1] = vector.array; + return typeArray; + } + typeArray[0] = @(INT_UNKNOWN); return typeArray; } @@ -466,6 +477,8 @@ + (id)parseTypeMap:(FIRFirestore *)firestore typeMap:(NSArray *)typeMap { } case INT_OBJECT: return [self parseNSDictionary:firestore dictionary:typeMap[1]]; + case INT_VECTOR: + return [FIRFieldValue vectorWithArray:typeMap[1]]; 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..f309f56517 --- /dev/null +++ b/packages/firestore/lib/FirestoreVectorValue.js @@ -0,0 +1,75 @@ +/* + * 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(); + } + + static fromJSON(json) { + parsedVector = JSON.parse(json); + return new FirestoreVectorValue(parsedVector.vectorValues); + } + + 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; + } + + toArray() { + return this._values.slice(); + } + + toJSON() { + return { vectorValues: this._values.slice() }; + } +} diff --git a/packages/firestore/lib/modular/VectorValue.d.ts b/packages/firestore/lib/modular/VectorValue.d.ts new file mode 100644 index 0000000000..d847dcee77 --- /dev/null +++ b/packages/firestore/lib/modular/VectorValue.d.ts @@ -0,0 +1,30 @@ +/** + * Represents a vector type in Firestore documents. Create an instance with vector(). + */ +export declare class VectorValue { + // Note the values array and constructor are not public APIs. + + /** + * Builds a VectorValue instance from a JSON object created by VectorValue.toJSON(). + * + * @param json a JSON object represention of a VectorValue instance. + */ + static fromJSON(json: object): VectorValue; + + /** + * Returns true if the two VectorValue values have the same raw number arrays, returns false otherwise. + */ + isEqual(other: VectorValue): boolean; + + /** + * Returns a copy of the raw number array form of the vector. + */ + toArray(): number[]; + + /** + * Returns a JSON-serializable representation of this VectorValue instance. + */ + 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..92a647fc95 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; @@ -189,6 +190,10 @@ export function generateNativeData(value, ignoreUndefined) { return getTypeMapInt('fieldvalue', [value._type, value._elements]); } + if (value instanceof FirestoreVectorValue) { + return getTypeMapInt('vector', value.toArray()); + } + 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..ce0765d951 100644 --- a/packages/firestore/lib/web/convert.js +++ b/packages/firestore/lib/web/convert.js @@ -10,6 +10,8 @@ import { deleteField, arrayUnion, arrayRemove, + vector, + VectorValue, } from '@react-native-firebase/app/lib/internal/web/firebaseFirestore'; const INT_NAN = 0; @@ -31,6 +33,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 +178,12 @@ export function buildTypeMap(value) { return out; } + if (value instanceof VectorValue) { + out.push(INT_VECTOR); + out.push(value.toArray()); + 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 vector(typedArray[1]); case INT_UNKNOWN: default: return null; diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index 4a38ffa0cf..fb50e48ce1 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -63,6 +63,8 @@ import firestore, { Bytes, GeoPoint, Timestamp, + VectorValue, + vector, } from '.'; console.log(firestore().app); @@ -488,6 +490,15 @@ console.log(timestamp1.isEqual(timestamp2)); console.log(timestamp1.toJSON()); console.log(timestamp1.toString()); +// VectorValue +const v1 = vector([3.14159]); +const v2 = vector([1.618]); +const v3 = VectorValue.fromJSON(v2.toJSON()); +console.log(v1.toArray()); +console.log(v2.toJSON()); +console.log(v2.isEqual(v1)); +console.log(v3.isEqual(v2)); + // Aggregate functions const sumField = sum('age'); const avgField = average('age');