diff --git a/src/entity.ts b/src/entity.ts index c12e4ee9c..f8dc509b2 100644 --- a/src/entity.ts +++ b/src/entity.ts @@ -1452,8 +1452,29 @@ export interface EntityProto { excludeFromIndexes?: boolean; } +/* + * This is the interface the user would provide transform operations in before + * they are converted to the google.datastore.v1.IPropertyTransform + * interface. + * + */ +export type PropertyTransform = { + property: string; + setToServerValue: boolean; + increment: any; + maximum: any; + minimum: any; + appendMissingElements: any[]; + removeAllFromArray: any[]; +}; + +interface EntityWithTransforms { + transforms?: PropertyTransform[]; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type Entity = any; +// TODO: Call out this interface change +export type Entity = any & EntityWithTransforms; export type Entities = Entity | Entity[]; interface KeyProtoPathElement extends google.datastore.v1.Key.IPathElement { diff --git a/src/index.ts b/src/index.ts index 20cad99c4..849387b31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,9 +37,16 @@ import { ServiceError, } from 'google-gax'; import * as is from 'is'; -import {Transform, pipeline} from 'stream'; +import {pipeline, Transform} from 'stream'; -import {entity, Entities, Entity, EntityProto, ValueProto} from './entity'; +import { + entity, + Entities, + Entity, + EntityProto, + ValueProto, + PropertyTransform, +} from './entity'; import {AggregateField} from './aggregate'; import Key = entity.Key; export {Entity, Key, AggregateField}; @@ -70,6 +77,10 @@ import {AggregateQuery} from './aggregate'; import {SaveEntity} from './interfaces/save'; import {extendExcludeFromIndexes} from './utils/entity/extendExcludeFromIndexes'; import {buildEntityProto} from './utils/entity/buildEntityProto'; +import IValue = google.datastore.v1.IValue; +import IEntity = google.datastore.v1.IEntity; +import ServerValue = google.datastore.v1.PropertyTransform.ServerValue; +import {buildPropertyTransforms} from './utils/entity/buildPropertyTransforms'; const {grpc} = new GrpcClient(); @@ -1098,7 +1109,8 @@ class Datastore extends DatastoreRequest { entities .map(DatastoreRequest.prepareEntityObject_) .forEach((entityObject: Entity, index: number) => { - const mutation: Mutation = {}; + const mutation: google.datastore.v1.IMutation = {}; + let method = 'upsert'; if (entityObject.method) { @@ -1120,7 +1132,15 @@ class Datastore extends DatastoreRequest { entityProto.key = entity.keyToKeyProto(entityObject.key); - mutation[method] = entityProto; + mutation[method as 'upsert' | 'update' | 'insert' | 'delete'] = + entityProto as IEntity; + + // We built the entityProto, now we should add the data transforms: + if (entityObject.transforms) { + mutation.propertyTransforms = buildPropertyTransforms( + entityObject.transforms + ); + } mutations.push(mutation); }); diff --git a/src/utils/entity/buildPropertyTransforms.ts b/src/utils/entity/buildPropertyTransforms.ts new file mode 100644 index 000000000..90c0b25ad --- /dev/null +++ b/src/utils/entity/buildPropertyTransforms.ts @@ -0,0 +1,57 @@ +// Copyright 2025 Google LLC +// +// 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. + +import {entity, PropertyTransform} from '../../entity'; +import {google} from '../../../protos/protos'; +import IValue = google.datastore.v1.IValue; +import ServerValue = google.datastore.v1.PropertyTransform.ServerValue; + +export function buildPropertyTransforms(transforms: PropertyTransform[]) { + const propertyTransforms: google.datastore.v1.IPropertyTransform[] = []; + transforms.forEach((transform: PropertyTransform) => { + const property = transform.property; + if (transform.setToServerValue) { + propertyTransforms.push({ + property, + setToServerValue: ServerValue.REQUEST_TIME, + }); + } + ['increment', 'maximum', 'minimum'].forEach(type => { + const castedType = type as 'increment' | 'maximum' | 'minimum'; + if (transform[castedType]) { + propertyTransforms.push({ + property, + [castedType]: entity.encodeValue( + transform[castedType], + property + ) as IValue, + }); + } + }); + ['appendMissingElements', 'removeAllFromArray'].forEach(type => { + const castedType = type as 'appendMissingElements' | 'removeAllFromArray'; + if (transform[castedType]) { + propertyTransforms.push({ + property, + [castedType]: { + values: transform[castedType].map(element => { + return entity.encodeValue(element, property) as IValue; + }), + }, + }); + } + }); + }); + return propertyTransforms; +} diff --git a/system-test/datastore.ts b/system-test/datastore.ts index c8eeecebb..69617bf83 100644 --- a/system-test/datastore.ts +++ b/system-test/datastore.ts @@ -26,6 +26,7 @@ import {Entities, entity, Entity} from '../src/entity'; import {Query, RunQueryInfo, ExecutionStats} from '../src/query'; import KEY_SYMBOL = entity.KEY_SYMBOL; import {transactionExpiredError} from '../src/request'; +const sinon = require('sinon'); const async = require('async'); @@ -3295,6 +3296,221 @@ async.each( assert.strictEqual(entity, undefined); }); }); + describe('Datastore mode data transforms', () => { + it('should perform a basic data transform', async () => { + const key = datastore.key(['Post', 'post1']); + const requestSpy = sinon.spy(datastore.request_); + datastore.request_ = requestSpy; + const result = await datastore.save({ + key: key, + data: { + name: 'test', + p1: 3, + p2: 4, + p3: 5, + a1: [3, 4, 5], + }, + transforms: [ + { + property: 'p1', + setToServerValue: true, + }, + { + property: 'p2', + increment: 4, + }, + { + property: 'p3', + maximum: 9, + }, + { + property: 'p2', + minimum: 6, + }, + { + property: 'a1', + appendMissingElements: [5, 6], + }, + { + property: 'a1', + removeAllFromArray: [3], + }, + ], + }); + // Clean the data from the server first before comparing: + result.forEach(serverResult => { + delete serverResult['indexUpdates']; + serverResult.mutationResults?.forEach(mutationResult => { + delete mutationResult['updateTime']; + delete mutationResult['createTime']; + delete mutationResult['version']; + mutationResult.transformResults?.forEach(transformResult => { + delete transformResult['timestampValue']; + }); + }); + }); + // Now the data should have fixed values. + // Do a comparison against the expected result. + assert.deepStrictEqual(result, [ + { + mutationResults: [ + { + transformResults: [ + { + meaning: 0, + excludeFromIndexes: false, + valueType: 'timestampValue', + }, + { + meaning: 0, + excludeFromIndexes: false, + integerValue: '8', + valueType: 'integerValue', + }, + { + meaning: 0, + excludeFromIndexes: false, + integerValue: '9', + valueType: 'integerValue', + }, + { + meaning: 0, + excludeFromIndexes: false, + integerValue: '6', + valueType: 'integerValue', + }, + { + meaning: 0, + excludeFromIndexes: false, + nullValue: 'NULL_VALUE', + valueType: 'nullValue', + }, + { + meaning: 0, + excludeFromIndexes: false, + nullValue: 'NULL_VALUE', + valueType: 'nullValue', + }, + ], + key: null, + conflictDetected: false, + }, + ], + commitTime: null, + }, + ]); + // Now check the value that was actually saved to the server: + const [entity] = await datastore.get(key); + const parsedResult = JSON.parse(JSON.stringify(entity)); + delete parsedResult['p1']; // This is a timestamp so we can't consistently test this. + assert.deepStrictEqual(parsedResult, { + name: 'test', + a1: [4, 5, 6], + p2: 6, + p3: 9, + }); + delete requestSpy.args[0][0].reqOpts.mutations[0].upsert.key + .partitionId['namespaceId']; + assert.deepStrictEqual(requestSpy.args[0][0], { + client: 'DatastoreClient', + method: 'commit', + reqOpts: { + mutations: [ + { + upsert: { + key: { + path: [ + { + kind: 'Post', + name: 'post1', + }, + ], + partitionId: {}, + }, + properties: { + name: { + stringValue: 'test', + }, + p1: { + integerValue: '3', + }, + p2: { + integerValue: '4', + }, + p3: { + integerValue: '5', + }, + a1: { + arrayValue: { + values: [ + { + integerValue: '3', + }, + { + integerValue: '4', + }, + { + integerValue: '5', + }, + ], + }, + }, + }, + }, + propertyTransforms: [ + { + property: 'p1', + setToServerValue: 1, + }, + { + property: 'p2', + increment: { + integerValue: '4', + }, + }, + { + property: 'p3', + maximum: { + integerValue: '9', + }, + }, + { + property: 'p2', + minimum: { + integerValue: '6', + }, + }, + { + property: 'a1', + appendMissingElements: { + values: [ + { + integerValue: '5', + }, + { + integerValue: '6', + }, + ], + }, + }, + { + property: 'a1', + removeAllFromArray: { + values: [ + { + integerValue: '3', + }, + ], + }, + }, + ], + }, + ], + }, + gaxOpts: {}, + }); + }); + }); }); } );