UNPKG

@google-cloud/firestore

Version:
291 lines 12.4 kB
"use strict"; /** * Copyright 2024 Google LLC. All Rights Reserved. * * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.AggregateQuery = void 0; const assert = require("assert"); const deepEqual = require("fast-deep-equal"); const aggregate_1 = require("../aggregate"); const timestamp_1 = require("../timestamp"); const util_1 = require("../util"); const query_profile_1 = require("../query-profile"); const logger_1 = require("../logger"); const aggregate_query_snapshot_1 = require("./aggregate-query-snapshot"); const stream_1 = require("stream"); const trace_util_1 = require("../telemetry/trace-util"); /** * A query that calculates aggregations over an underlying query. */ class AggregateQuery { /** * @internal * @param _query The query whose aggregations will be calculated by this * object. * @param _aggregates The aggregations that will be performed by this query. */ constructor( // eslint-disable-next-line @typescript-eslint/no-explicit-any _query, _aggregates) { this._query = _query; this._aggregates = _aggregates; this.clientAliasToServerAliasMap = {}; this.serverAliasToClientAliasMap = {}; // Client-side aliases may be too long and exceed the 1500-byte string size limit. // Such long strings do not need to be transferred over the wire either. // The client maps the user's alias to a short form alias and send that to the server. let aggregationNum = 0; for (const clientAlias in this._aggregates) { if (Object.prototype.hasOwnProperty.call(this._aggregates, clientAlias)) { const serverAlias = `aggregate_${aggregationNum++}`; this.clientAliasToServerAliasMap[clientAlias] = serverAlias; this.serverAliasToClientAliasMap[serverAlias] = clientAlias; } } } /** The query whose aggregations will be calculated by this object. */ get query() { return this._query; } /** * Executes this query. * * @return A promise that will be resolved with the results of the query. */ async get() { return this._query._firestore._traceUtil.startActiveSpan(trace_util_1.SPAN_NAME_AGGREGATION_QUERY_GET, async () => { const { result } = await this._get(); return result; }); } /** * Internal get() method that accepts an optional transaction options and * returns a snapshot with transaction and explain metadata. * * @private * @internal * @param transactionOrReadTime A transaction ID, options to start a new * transaction, or timestamp to use as read time. */ async _get(transactionOrReadTime) { const response = await this._getResponse(transactionOrReadTime); if (!response.result) { throw new Error('No AggregateQuery results'); } return response; } /** * Internal get() method that accepts an optional transaction id, and returns * transaction metadata. * * @private * @internal * @param transactionOrReadTime A transaction ID, options to start a new * transaction, or timestamp to use as read time. */ _getResponse(transactionOrReadTime, explainOptions) { // Capture the error stack to preserve stack tracing across async calls. const stack = Error().stack; return new Promise((resolve, reject) => { const output = {}; const stream = this._stream(transactionOrReadTime, explainOptions); stream.on('error', err => { reject((0, util_1.wrapError)(err, stack)); }); stream.on('data', (data) => { if (data.transaction) { output.transaction = data.transaction; } if (data.explainMetrics) { output.explainMetrics = data.explainMetrics; } if (data.result) { output.result = data.result; } }); stream.on('end', () => { stream.destroy(); resolve(output); }); }); } /** * Internal streaming method that accepts an optional transaction ID. * * BEWARE: If `transactionOrReadTime` is `ITransactionOptions`, then the first * response in the stream will be a transaction response. * * @private * @internal * @param transactionOrReadTime A transaction ID, options to start a new * transaction, or timestamp to use as read time. * @param explainOptions Options to use for explaining the query (if any). * @returns A stream of document results optionally preceded by a transaction response. */ _stream(transactionOrReadTime, explainOptions) { const tag = (0, util_1.requestTag)(); const firestore = this._query.firestore; const stream = new stream_1.Transform({ objectMode: true, transform: (proto, enc, callback) => { var _a; const output = {}; // Proto comes with zero-length buffer by default if ((_a = proto.transaction) === null || _a === void 0 ? void 0 : _a.length) { output.transaction = proto.transaction; } if (proto.explainMetrics) { output.explainMetrics = query_profile_1.ExplainMetrics._fromProto(proto.explainMetrics, firestore._serializer); } if (proto.result) { const readTime = timestamp_1.Timestamp.fromProto(proto.readTime); const data = this.decodeResult(proto.result); output.result = new aggregate_query_snapshot_1.AggregateQuerySnapshot(this, readTime, data); } callback(undefined, output); }, }); firestore .initializeIfNeeded(tag) .then(async () => { // `toProto()` might throw an exception. We rely on the behavior of an // async function to convert this exception into the rejected Promise we // catch below. const request = this.toProto(transactionOrReadTime, explainOptions); const backendStream = await firestore.requestStream('runAggregationQuery', /* bidirectional= */ false, request, tag); stream.on('close', () => { backendStream.resume(); backendStream.end(); }); backendStream.on('error', err => { // TODO(group-by) When group-by queries are supported for aggregates // consider implementing retries if the stream is making progress // receiving results for groups. See the use of lastReceivedDocument // in the retry strategy for runQuery. // Also note that explain queries should not be retried. backendStream.unpipe(stream); (0, logger_1.logger)('AggregateQuery._stream', tag, 'AggregateQuery failed with stream error:', err); this._query._firestore._traceUtil .currentSpan() .addEvent(`${trace_util_1.SPAN_NAME_RUN_AGGREGATION_QUERY}: Error.`, { 'error.message': err.message, }); stream.destroy(err); }); backendStream.resume(); backendStream.pipe(stream); }) .catch(e => stream.destroy(e)); return stream; } /** * Internal method to decode values within result. * @private */ decodeResult(proto) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = {}; const fields = proto.aggregateFields; if (fields) { const serializer = this._query.firestore._serializer; for (const prop of Object.keys(fields)) { const alias = this.serverAliasToClientAliasMap[prop]; assert(alias !== null && alias !== undefined, `'${prop}' not present in server-client alias mapping.`); if (this._aggregates[alias] === undefined) { throw new Error(`Unexpected alias [${prop}] in result aggregate result`); } data[alias] = serializer.decodeValue(fields[prop]); } } return data; } /** * Internal method for serializing a query to its RunAggregationQuery proto * representation with an optional transaction id. * * @private * @internal * @returns Serialized JSON for the query. */ toProto(transactionOrReadTime, explainOptions) { const queryProto = this._query.toProto(); const runQueryRequest = { parent: queryProto.parent, structuredAggregationQuery: { structuredQuery: queryProto.structuredQuery, aggregations: (0, util_1.mapToArray)(this._aggregates, (aggregate, clientAlias) => { const serverAlias = this.clientAliasToServerAliasMap[clientAlias]; assert(serverAlias !== null && serverAlias !== undefined, `'${clientAlias}' not present in client-server alias mapping.`); return new aggregate_1.Aggregate(serverAlias, aggregate.aggregateType, aggregate._field).toProto(); }), }, }; if (transactionOrReadTime instanceof Uint8Array) { runQueryRequest.transaction = transactionOrReadTime; } else if (transactionOrReadTime instanceof timestamp_1.Timestamp) { runQueryRequest.readTime = transactionOrReadTime.toProto().timestampValue; } else if (transactionOrReadTime) { runQueryRequest.newTransaction = transactionOrReadTime; } if (explainOptions) { runQueryRequest.explainOptions = explainOptions; } return runQueryRequest; } /** * Compares this object with the given object for equality. * * This object is considered "equal" to the other object if and only if * `other` performs the same aggregations as this `AggregateQuery` and * the underlying Query of `other` compares equal to that of this object * using `Query.isEqual()`. * * @param other The object to compare to this object for equality. * @return `true` if this object is "equal" to the given object, as * defined above, or `false` otherwise. */ isEqual(other) { if (this === other) { return true; } if (!(other instanceof AggregateQuery)) { return false; } if (!this.query.isEqual(other.query)) { return false; } return deepEqual(this._aggregates, other._aggregates); } /** * Plans and optionally executes this query. Returns a Promise that will be * resolved with the planner information, statistics from the query * execution (if any), and the query results (if any). * * @return A Promise that will be resolved with the planner information, * statistics from the query execution (if any), and the query results (if any). */ async explain(options) { const { result, explainMetrics } = await this._getResponse(undefined, options || {}); if (!explainMetrics) { throw new Error('No explain results'); } return new query_profile_1.ExplainResults(explainMetrics, result || null); } } exports.AggregateQuery = AggregateQuery; //# sourceMappingURL=aggregate-query.js.map