diff --git a/dev/src/field-value.ts b/dev/src/field-value.ts index eb054067c..8aa1d03f2 100644 --- a/dev/src/field-value.ts +++ b/dev/src/field-value.ts @@ -88,7 +88,7 @@ export class FieldValue implements firestore.FieldValue { /** * Returns a special value that can be used with set(), create() or update() - * that tells the server to increment the the field's current value by the + * that tells the server to increment the field's current value by the * given value. * * If either current field value or the operand uses floating point @@ -122,6 +122,76 @@ export class FieldValue implements firestore.FieldValue { return new NumericIncrementTransform(n); } + /** + * Returns a special value that can be used with set(), create() or update() + * that tells the server to set the field to the numeric minimum of the + * field's current and the given value. + * + * If the current field value is not of type 'number', or if the field does + * not yet exist, the transformation will set the field to the given value. + * + * If the existing value and the operand are equivalent, then the field does + * not change. For example, `0`, `0.0`, and `-0.0` are all equivalent. If the + * operand is `NaN` then the result is always `NaN`. + * + * @param {number} n The value to compare to the exiting field value. + * @return {FieldValue} The FieldValue for use in a call to set(), create() or + * update(). + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.update( + * 'counter', Firestore.FieldValue.minimum(1) + * ).then(() => { + * return documentRef.get(); + * }).then(doc => { + * // doc.get('counter') is the minimum of either the existing value or 1 + * }); + * ``` + */ + static minimum(n: number): FieldValue { + // eslint-disable-next-line prefer-rest-params + validateMinNumberOfArguments('FieldValue.minimum', arguments, 1); + return new NumericMinimumTransform(n); + } + + /** + * Returns a special value that can be used with set(), create() or update() + * that tells the server to set the field to the numeric maximum of the + * field's current and the given value. + * + * If the current field value is not of type 'number', or if the field does + * not yet exist, the transformation will set the field to the given value. + * + * If the existing value and the operand are equivalent, then the field does + * not change. For example, `0`, `0.0`, and `-0.0` are all equivalent. If the + * operand is `NaN` then the result is always `NaN`. + * + * @param {number} n The value to compare to the exiting field value. + * @return {FieldValue} The FieldValue for use in a call to set(), create() or + * update(). + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.update( + * 'counter', Firestore.FieldValue.maximum(1) + * ).then(() => { + * return documentRef.get(); + * }).then(doc => { + * // doc.get('counter') is the maximum of either the existing value or 1 + * }); + * ``` + */ + static maximum(n: number): FieldValue { + // eslint-disable-next-line prefer-rest-params + validateMinNumberOfArguments('FieldValue.maximum', arguments, 1); + return new NumericMaximumTransform(n); + } + /** * Returns a special value that can be used with set(), create() or update() * that tells the server to union the given elements with any array value that @@ -366,13 +436,12 @@ class ServerTimestampTransform extends FieldTransform { } /** - * Increments a field value on the backend. - * + * Base class of numeric field transforms. * @private * @internal */ -class NumericIncrementTransform extends FieldTransform { - constructor(private readonly operand: number) { +abstract class NumericFieldTransform extends FieldTransform { + constructor(protected readonly operand: number) { super(); } @@ -396,12 +465,24 @@ class NumericIncrementTransform extends FieldTransform { return true; } - get methodName(): string { - return 'FieldValue.increment'; + validate(): void { + validateNumber(this.methodName + '()', this.operand); } +} - validate(): void { - validateNumber('FieldValue.increment()', this.operand); +/** + * Increments a field value on the backend. + * + * @private + * @internal + */ +class NumericIncrementTransform extends NumericFieldTransform { + constructor(operand: number) { + super(operand); + } + + get methodName(): string { + return 'FieldValue.increment'; } toProto( @@ -421,6 +502,70 @@ class NumericIncrementTransform extends FieldTransform { } } +/** + * Sets a field to the minimum of existing or operand. + * + * @private + * @internal + */ +class NumericMinimumTransform extends NumericFieldTransform { + constructor(operand: number) { + super(operand); + } + + get methodName(): string { + return 'FieldValue.minimum'; + } + + toProto( + serializer: Serializer, + fieldPath: FieldPath + ): api.DocumentTransform.IFieldTransform { + const encodedOperand = serializer.encodeValue(this.operand)!; + return {fieldPath: fieldPath.formattedName, minimum: encodedOperand}; + } + + isEqual(other: firestore.FieldValue): boolean { + return ( + this === other || + (other instanceof NumericMinimumTransform && + this.operand === other.operand) + ); + } +} + +/** + * Sets a field to the maximum of existing or operand. + * + * @private + * @internal + */ +class NumericMaximumTransform extends NumericFieldTransform { + constructor(operand: number) { + super(operand); + } + + get methodName(): string { + return 'FieldValue.maximum'; + } + + toProto( + serializer: Serializer, + fieldPath: FieldPath + ): api.DocumentTransform.IFieldTransform { + const encodedOperand = serializer.encodeValue(this.operand)!; + return {fieldPath: fieldPath.formattedName, maximum: encodedOperand}; + } + + isEqual(other: firestore.FieldValue): boolean { + return ( + this === other || + (other instanceof NumericMaximumTransform && + this.operand === other.operand) + ); + } +} + /** * Transforms an array value via a union operation. * diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index c2d63491e..69b25d590 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -633,6 +633,66 @@ describe('DocumentReference class', () => { }); }); + it('supports minimum()', () => { + const baseData = {min: 2}; + const updateData = {min: FieldValue.minimum(1)}; + const expectedData = {min: 1}; + + const ref = randomCol.doc('doc'); + return ref + .set(baseData) + .then(() => ref.update(updateData)) + .then(() => ref.get()) + .then(doc => { + expect(doc.data()).to.deep.equal(expectedData); + }); + }); + + it('supports minimum() with set() with merge', () => { + const baseData = {min: 2}; + const updateData = {min: FieldValue.minimum(1)}; + const expectedData = {min: 1}; + + const ref = randomCol.doc('doc'); + return ref + .set(baseData) + .then(() => ref.set(updateData, {merge: true})) + .then(() => ref.get()) + .then(doc => { + expect(doc.data()).to.deep.equal(expectedData); + }); + }); + + it('supports maximum()', () => { + const baseData = {max: 1}; + const updateData = {max: FieldValue.maximum(2)}; + const expectedData = {max: 2}; + + const ref = randomCol.doc('doc'); + return ref + .set(baseData) + .then(() => ref.update(updateData)) + .then(() => ref.get()) + .then(doc => { + expect(doc.data()).to.deep.equal(expectedData); + }); + }); + + it('supports maximum() with set() with merge', () => { + const baseData = {max: 1}; + const updateData = {max: FieldValue.maximum(2)}; + const expectedData = {max: 2}; + + const ref = randomCol.doc('doc'); + return ref + .set(baseData) + .then(() => ref.set(updateData, {merge: true})) + .then(() => ref.get()) + .then(doc => { + expect(doc.data()).to.deep.equal(expectedData); + }); + }); + it('supports arrayUnion()', () => { const baseObject = { a: [], @@ -6006,6 +6066,8 @@ describe('Types test', () => { readonly nested: { innerNested: { innerNestedNum: number; + innerNestedMin: number; + innerNestedMax: number; }; innerArr: number[]; timestamp: Timestamp; @@ -6029,6 +6091,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: 2, + innerNestedMin: 2, + innerNestedMax: 0, }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6074,6 +6138,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6112,6 +6178,10 @@ describe('Types test', () => { innerNested: { // @ts-expect-error Should fail to transpile. innerNestedNum: 'string', + // @ts-expect-error Should fail to transpile. + innerNestedMin: 'string', + // @ts-expect-error Should fail to transpile. + innerNestedMax: 'string', }, // @ts-expect-error Should fail to transpile. innerArr: null, @@ -6160,6 +6230,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6176,6 +6248,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, timestamp: FieldValue.serverTimestamp(), }, @@ -6214,6 +6288,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6252,6 +6328,10 @@ describe('Types test', () => { innerNested: { // @ts-expect-error Should fail to transpile. innerNestedNum: 'string', + // @ts-expect-error Should fail to transpile. + innerNestedMin: 'string', + // @ts-expect-error Should fail to transpile. + innerNestedMax: 'string', }, // @ts-expect-error Should fail to transpile. innerArr: null, @@ -6303,6 +6383,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6319,6 +6401,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6336,6 +6420,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, timestamp: FieldValue.serverTimestamp(), }, @@ -6353,6 +6439,10 @@ describe('Types test', () => { innerNested: { // @ts-expect-error Should fail to transpile. innerNestedNum: 'string', + // @ts-expect-error Should fail to transpile. + innerNestedMin: 'string', + // @ts-expect-error Should fail to transpile. + innerNestedMax: 'string', }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6372,6 +6462,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: 2, + innerNestedMin: 2, + innerNestedMax: 0, }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6388,6 +6480,8 @@ describe('Types test', () => { // @ts-expect-error Should fail to transpile. nonexistent: 'string', innerNestedNum: 2, + innerNestedMin: 2, + innerNestedMax: 0, }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6692,6 +6786,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: 2, + innerNestedMin: 2, + innerNestedMax: 0, }, innerArr: [], timestamp: FieldValue.serverTimestamp(), @@ -6710,6 +6806,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6723,6 +6821,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6740,6 +6840,8 @@ describe('Types test', () => { outerArr: [], nested: { 'innerNested.innerNestedNum': FieldValue.increment(1), + 'innerNested.innerNestedMin': FieldValue.minimum(1), + 'innerNested.innerNestedMax': FieldValue.maximum(1), innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), }, @@ -6757,6 +6859,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6770,6 +6874,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), @@ -6789,6 +6895,8 @@ describe('Types test', () => { nested: { innerNested: { innerNestedNum: FieldValue.increment(1), + innerNestedMin: FieldValue.minimum(1), + innerNestedMax: FieldValue.maximum(1), }, innerArr: FieldValue.arrayUnion(2), timestamp: FieldValue.serverTimestamp(), diff --git a/dev/test/field-value.ts b/dev/test/field-value.ts index c19e8c3c8..1d94a1c5d 100644 --- a/dev/test/field-value.ts +++ b/dev/test/field-value.ts @@ -23,6 +23,8 @@ import { document, incrementTransform, InvalidApiUsage, + minimumTransform, + maximumTransform, requestEquals, response, serverTimestamp, @@ -189,6 +191,114 @@ describe('FieldValue.increment()', () => { genericFieldValueTests('FieldValue.increment', FieldValue.increment(42)); }); +describe('FieldValue.minimum()', () => { + it('requires one argument', () => { + expect(() => (FieldValue as InvalidApiUsage).minimum()).to.throw( + 'Function "FieldValue.minimum()" requires at least 1 argument.' + ); + }); + + it('validates that operand is number', () => { + return createInstance().then(firestore => { + expect(() => { + return firestore.doc('collectionId/documentId').set({ + foo: FieldValue.minimum('foo' as InvalidApiUsage), + }); + }).to.throw( + 'Value for argument "FieldValue.minimum()" is not a valid number' + ); + }); + }); + + it('supports isEqual()', () => { + const arrayUnionA = FieldValue.minimum(13.37); + const arrayUnionB = FieldValue.minimum(13.37); + const arrayUnionC = FieldValue.minimum(42); + expect(arrayUnionA.isEqual(arrayUnionB)).to.be.true; + expect(arrayUnionC.isEqual(arrayUnionB)).to.be.false; + }); + + it('can be used with set()', () => { + const overrides: ApiOverride = { + commit: request => { + const expectedRequest = set({ + document: document('documentId', 'foo', 'bar'), + transforms: [ + minimumTransform('field', 42), + minimumTransform('map.field', 13.37), + ], + }); + requestEquals(request, expectedRequest); + return response(writeResult(1)); + }, + }; + + return createInstance(overrides).then(firestore => { + return firestore.doc('collectionId/documentId').set({ + foo: 'bar', + field: FieldValue.minimum(42), + map: {field: FieldValue.minimum(13.37)}, + }); + }); + }); + + genericFieldValueTests('FieldValue.minimum', FieldValue.minimum(42)); +}); + +describe('FieldValue.maximum()', () => { + it('requires one argument', () => { + expect(() => (FieldValue as InvalidApiUsage).maximum()).to.throw( + 'Function "FieldValue.maximum()" requires at least 1 argument.' + ); + }); + + it('validates that operand is number', () => { + return createInstance().then(firestore => { + expect(() => { + return firestore.doc('collectionId/documentId').set({ + foo: FieldValue.maximum('foo' as InvalidApiUsage), + }); + }).to.throw( + 'Value for argument "FieldValue.maximum()" is not a valid number' + ); + }); + }); + + it('supports isEqual()', () => { + const arrayUnionA = FieldValue.maximum(13.37); + const arrayUnionB = FieldValue.maximum(13.37); + const arrayUnionC = FieldValue.maximum(42); + expect(arrayUnionA.isEqual(arrayUnionB)).to.be.true; + expect(arrayUnionC.isEqual(arrayUnionB)).to.be.false; + }); + + it('can be used with set()', () => { + const overrides: ApiOverride = { + commit: request => { + const expectedRequest = set({ + document: document('documentId', 'foo', 'bar'), + transforms: [ + maximumTransform('field', 42), + maximumTransform('map.field', 13.37), + ], + }); + requestEquals(request, expectedRequest); + return response(writeResult(1)); + }, + }; + + return createInstance(overrides).then(firestore => { + return firestore.doc('collectionId/documentId').set({ + foo: 'bar', + field: FieldValue.maximum(42), + map: {field: FieldValue.maximum(13.37)}, + }); + }); + }); + + genericFieldValueTests('FieldValue.maximum', FieldValue.maximum(42)); +}); + describe('FieldValue.arrayRemove()', () => { it('requires one argument', () => { expect(() => FieldValue.arrayRemove()).to.throw( diff --git a/dev/test/util/helpers.ts b/dev/test/util/helpers.ts index 7cae5305f..5eeca8fef 100644 --- a/dev/test/util/helpers.ts +++ b/dev/test/util/helpers.ts @@ -271,6 +271,26 @@ export function incrementTransform( }; } +export function minimumTransform( + field: string, + n: number +): api.DocumentTransform.IFieldTransform { + return { + fieldPath: field, + minimum: Number.isInteger(n) ? {integerValue: n} : {doubleValue: n}, + }; +} + +export function maximumTransform( + field: string, + n: number +): api.DocumentTransform.IFieldTransform { + return { + fieldPath: field, + maximum: Number.isInteger(n) ? {integerValue: n} : {doubleValue: n}, + }; +} + export function arrayTransform( field: string, transform: 'appendMissingElements' | 'removeAllFromArray', diff --git a/types/firestore.d.ts b/types/firestore.d.ts index 92d8477ed..cd62e1b5a 100644 --- a/types/firestore.d.ts +++ b/types/firestore.d.ts @@ -2550,6 +2550,68 @@ declare namespace FirebaseFirestore { */ static increment(n: number): FieldValue; + /** + * Returns a special value that can be used with set(), create() or update() + * that tells the server to set the field to the numeric minimum of the + * field's current and the given value. + * + * If the current field value is not of type 'number', or if the field does + * not yet exist, the transformation will set the field to the given value. + * + * If the existing value and the operand are equivalent, then the field does + * not change. For example, `0`, `0.0`, and `-0.0` are all equivalent. If the + * operand is `NaN` then the result is always `NaN`. + * + * @param {number} n The value to compare to the exiting field value. + * @return {FieldValue} The FieldValue for use in a call to set(), create() or + * update(). + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.update( + * 'counter', Firestore.FieldValue.minimum(1) + * ).then(() => { + * return documentRef.get(); + * }).then(doc => { + * // doc.get('counter') is the minimum of either the existing value or 1 + * }); + * ``` + */ + static minimum(n: number): FieldValue; + + /** + * Returns a special value that can be used with set(), create() or update() + * that tells the server to set the field to the numeric maximum of the + * field's current and the given value. + * + * If the current field value is not of type 'number', or if the field does + * not yet exist, the transformation will set the field to the given value. + * + * If the existing value and the operand are equivalent, then the field does + * not change. For example, `0`, `0.0`, and `-0.0` are all equivalent. If the + * operand is `NaN` then the result is always `NaN`. + * + * @param {number} n The value to compare to the exiting field value. + * @return {FieldValue} The FieldValue for use in a call to set(), create() or + * update(). + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.update( + * 'counter', Firestore.FieldValue.maximum(1) + * ).then(() => { + * return documentRef.get(); + * }).then(doc => { + * // doc.get('counter') is the maximum of either the existing value or 1 + * }); + * ``` + */ + static maximum(n: number): FieldValue; + /** * Returns a special value that can be used with set(), create() or update() * that tells the server to union the given elements with any array value