blob: 97cf60d21d0410ddb6b237cda99b3e91ba7e8e65 [file] [log] [blame]
// Copyright 2020 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:cocoon_service/cocoon_service.dart';
import 'package:github/github.dart';
import 'package:googleapis/firestore/v1.dart';
import 'package:http/http.dart';
import '../model/firestore/commit.dart';
import '../model/firestore/github_build_status.dart';
import '../model/firestore/github_gold_status.dart';
import '../model/firestore/task.dart';
import 'access_client_provider.dart';
import 'config.dart';
const String kDatabase = 'projects/${Config.flutterGcpProjectId}/databases/${Config.flutterGcpFirestoreDatabase}';
const String kDocumentParent = '$kDatabase/documents';
const String kFieldFilterOpEqual = 'EQUAL';
const String kCompositeFilterOpAnd = 'AND';
const String kQueryOrderDescending = 'DESCENDING';
const int kFilterStringSpaceSplitElements = 2;
const int kFilterStringSpaceSplitOpIndex = 1;
const Map<String, String> kRelationMapping = <String, String>{
'=': 'EQUAL',
'<': 'LESS_THAN',
'<=': 'LESS_THAN_OR_EQUAL',
'>': 'GREATER_THAN',
'>=': 'GREATER_THAN_OR_EQUAL',
};
class FirestoreService {
const FirestoreService(this.accessClientProvider);
/// AccessClientProvider for OAuth 2.0 authenticated access client
final AccessClientProvider accessClientProvider;
/// Return a [ProjectsDatabasesDocumentsResource] with an authenticated [client]
Future<ProjectsDatabasesDocumentsResource> documentResource() async {
final Client client = await accessClientProvider.createAccessClient(
scopes: const <String>[FirestoreApi.datastoreScope],
baseClient: FirestoreBaseClient(
projectId: Config.flutterGcpProjectId,
databaseId: Config.flutterGcpFirestoreDatabase,
),
);
return FirestoreApi(client).projects.databases.documents;
}
/// Gets a document based on name.
Future<Document> getDocument(
String name,
) async {
final ProjectsDatabasesDocumentsResource databasesDocumentsResource = await documentResource();
return databasesDocumentsResource.get(name);
}
/// Batch writes documents to Firestore.
///
/// It does not apply the write operations atomically and can apply them out of order.
/// Each write succeeds or fails independently.
///
/// https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents/batchWrite
Future<BatchWriteResponse> batchWriteDocuments(BatchWriteRequest request, String database) async {
final ProjectsDatabasesDocumentsResource databasesDocumentsResource = await documentResource();
return databasesDocumentsResource.batchWrite(request, database);
}
/// Writes [writes] to Firestore within a transaction.
///
/// This is an atomic operation: either all writes succeed or all writes fail.
Future<CommitResponse> writeViaTransaction(List<Write> writes) async {
final ProjectsDatabasesDocumentsResource databasesDocumentsResource = await documentResource();
final BeginTransactionRequest beginTransactionRequest =
BeginTransactionRequest(options: TransactionOptions(readWrite: ReadWrite()));
final BeginTransactionResponse beginTransactionResponse =
await databasesDocumentsResource.beginTransaction(beginTransactionRequest, kDatabase);
final CommitRequest commitRequest =
CommitRequest(transaction: beginTransactionResponse.transaction, writes: writes);
return databasesDocumentsResource.commit(commitRequest, kDatabase);
}
/// Queries for recent commits.
///
/// The [limit] argument specifies the maximum number of commits to retrieve.
///
/// The returned commits will be ordered by most recent [Commit.timestamp].
Future<List<Commit>> queryRecentCommits({
int limit = 100,
int? timestamp,
String? branch,
required RepositorySlug slug,
}) async {
timestamp ??= DateTime.now().millisecondsSinceEpoch;
branch ??= Config.defaultBranch(slug);
final Map<String, Object> filterMap = <String, Object>{
'$kCommitBranchField =': branch,
'$kCommitRepositoryPathField =': slug.fullName,
'$kCommitCreateTimestampField <': timestamp,
};
final Map<String, String> orderMap = <String, String>{
kCommitCreateTimestampField: kQueryOrderDescending,
};
final List<Document> documents = await query(kCommitCollectionId, filterMap, orderMap: orderMap, limit: limit);
return documents.map((Document document) => Commit.fromDocument(commitDocument: document)).toList();
}
/// Returns all tasks running against the speificed [commitSha].
Future<List<Task>> queryCommitTasks(String commitSha) async {
final Map<String, Object> filterMap = <String, Object>{
'$kTaskCommitShaField =': commitSha,
};
final Map<String, String> orderMap = <String, String>{
kTaskCreateTimestampField: kQueryOrderDescending,
};
final List<Document> documents = await query(kTaskCollectionId, filterMap, orderMap: orderMap);
return documents.map((Document document) => Task.fromDocument(taskDocument: document)).toList();
}
/// Queries the last updated Gold status for the [slug] and [prNumber].
///
/// If not existing, returns a fresh new Gold status.
Future<GithubGoldStatus> queryLastGoldStatus(RepositorySlug slug, int prNumber) async {
final Map<String, Object> filterMap = <String, Object>{
'$kGithubGoldStatusPrNumberField =': prNumber,
'$kGithubGoldStatusRepositoryField =': slug.fullName,
};
final List<Document> documents = await query(kGithubGoldStatusCollectionId, filterMap);
final List<GithubGoldStatus> githubGoldStatuses =
documents.map((Document document) => GithubGoldStatus.fromDocument(githubGoldStatus: document)).toList();
if (githubGoldStatuses.isEmpty) {
return GithubGoldStatus.fromDocument(
githubGoldStatus: Document(
name: '$kDatabase/documents/$kGithubGoldStatusCollectionId/${slug.owner}_${slug.name}_$prNumber',
fields: <String, Value>{
kGithubGoldStatusPrNumberField: Value(integerValue: prNumber.toString()),
kGithubGoldStatusHeadField: Value(stringValue: ''),
kGithubGoldStatusStatusField: Value(stringValue: ''),
kGithubGoldStatusUpdatesField: Value(integerValue: '0'),
kGithubGoldStatusDescriptionField: Value(stringValue: ''),
kGithubGoldStatusRepositoryField: Value(stringValue: slug.fullName),
},
),
);
} else {
if (githubGoldStatuses.length > 1) {
throw StateError('GithubGoldStatusUpdate should have no more than one entry on '
'repository ${slug.fullName}, pr $prNumber.');
}
return githubGoldStatuses.single;
}
}
/// Queries the last updated build status for the [slug], [prNumber], and [head].
///
/// If not existing, returns a fresh new Build status.
Future<GithubBuildStatus> queryLastBuildStatus(RepositorySlug slug, int prNumber, String head) async {
final Map<String, Object> filterMap = <String, Object>{
'$kGithubBuildStatusPrNumberField =': prNumber,
'$kGithubBuildStatusRepositoryField =': slug.fullName,
'$kGithubBuildStatusHeadField =': head,
};
final List<Document> documents = await query(kGithubBuildStatusCollectionId, filterMap);
final List<GithubBuildStatus> githubBuildStatuses =
documents.map((Document document) => GithubBuildStatus.fromDocument(githubBuildStatus: document)).toList();
if (githubBuildStatuses.isEmpty) {
return GithubBuildStatus.fromDocument(
githubBuildStatus: Document(
name: '$kDatabase/documents/$kGithubBuildStatusCollectionId/${head}_$prNumber',
fields: <String, Value>{
kGithubBuildStatusPrNumberField: Value(integerValue: prNumber.toString()),
kGithubBuildStatusHeadField: Value(stringValue: head),
kGithubBuildStatusStatusField: Value(stringValue: ''),
kGithubBuildStatusUpdatesField: Value(integerValue: '0'),
kGithubBuildStatusUpdateTimeMillisField:
Value(integerValue: DateTime.now().millisecondsSinceEpoch.toString()),
kGithubBuildStatusRepositoryField: Value(stringValue: slug.fullName),
},
),
);
} else {
if (githubBuildStatuses.length > 1) {
throw StateError('GithubBuildStatus should have no more than one entry on '
'repository ${slug.fullName}, pr $prNumber, and head $head.');
}
return githubBuildStatuses.single;
}
}
/// Returns Firestore [Value] based on corresponding object type.
Value getValueFromFilter(Object comparisonOject) {
if (comparisonOject is int) {
return Value(integerValue: comparisonOject.toString());
} else if (comparisonOject is bool) {
return Value(booleanValue: comparisonOject);
}
return Value(stringValue: comparisonOject as String);
}
/// Generates Firestore query filter based on "human" read conditions.
Filter generateFilter(Map<String, Object> filterMap, String compositeFilterOp) {
final List<Filter> filters = <Filter>[];
filterMap.forEach((filterString, comparisonOject) {
final List<String> parts = filterString.split(' ');
if (parts.length != kFilterStringSpaceSplitElements ||
!kRelationMapping.containsKey(parts[kFilterStringSpaceSplitOpIndex])) {
throw ArgumentError("Invalid filter string '$filterString'.");
}
final String name = parts[0];
final String comparison = kRelationMapping[parts[1]]!;
final Value value = getValueFromFilter(comparisonOject);
filters.add(
Filter(
fieldFilter: FieldFilter(
field: FieldReference(fieldPath: name),
op: comparison,
value: value,
),
),
);
});
return Filter(
compositeFilter: CompositeFilter(
filters: filters,
op: compositeFilterOp,
),
);
}
List<Order>? generateOrders(Map<String, String>? orderMap) {
if (orderMap == null || orderMap.isEmpty) {
return null;
}
final List<Order> orders = <Order>[];
orderMap.forEach((field, direction) {
orders.add(Order(field: FieldReference(fieldPath: field), direction: direction));
});
return orders;
}
/// Wrapper to simplify Firestore query.
///
/// The [filterMap] follows format:
/// {
/// 'fieldInt =': 1,
/// 'fieldString =': 'string',
/// 'fieldBool =': true,
/// }
/// Note
/// 1. the space in the key, which will be used to retrieve the field name and operator.
/// 2. the value could be any type, like int, string, bool, etc.
Future<List<Document>> query(
String collectionId,
Map<String, Object> filterMap, {
int? limit,
Map<String, String>? orderMap,
String compositeFilterOp = kCompositeFilterOpAnd,
}) async {
final ProjectsDatabasesDocumentsResource databasesDocumentsResource = await documentResource();
final List<CollectionSelector> from = <CollectionSelector>[
CollectionSelector(collectionId: collectionId),
];
final Filter filter = generateFilter(filterMap, compositeFilterOp);
final List<Order>? orders = generateOrders(orderMap);
final RunQueryRequest runQueryRequest = RunQueryRequest(
structuredQuery: StructuredQuery(
from: from,
where: filter,
orderBy: orders,
limit: limit,
),
);
final List<RunQueryResponseElement> runQueryResponseElements =
await databasesDocumentsResource.runQuery(runQueryRequest, kDocumentParent);
return documentsFromQueryResponse(runQueryResponseElements);
}
/// Retrieve documents based on query response.
List<Document> documentsFromQueryResponse(List<RunQueryResponseElement> runQueryResponseElements) {
final List<Document> documents = <Document>[];
for (RunQueryResponseElement runQueryResponseElement in runQueryResponseElements) {
if (runQueryResponseElement.document != null) {
documents.add(runQueryResponseElement.document!);
}
}
return documents;
}
}
/// Creates a list of [Write] based on documents.
///
/// Null `exists` means either update when a document exists or insert when a document doesn't.
/// `exists = false` means inserting a new document, assuming a document doesn't exist.
/// `exists = true` means updating an existing document, assuming it exisits.
List<Write> documentsToWrites(List<Document> documents, {bool? exists}) {
return documents
.map(
(Document document) => Write(
update: document,
currentDocument: Precondition(exists: exists),
),
)
.toList();
}