blob: 6890f6184c50d42bce488cc5c1be4372fe4002af [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_server/access_client_provider.dart';
import 'package:github/github.dart';
import 'package:googleapis/firestore/v1.dart';
import '../../cocoon_service.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 'config.dart';
const String kDatabase =
'projects/${Config.flutterGcpProjectId}/databases/${Config.flutterGcpFirestoreDatabase}';
const String kDocumentParent = '$kDatabase/documents';
const String kFieldFilterOpEqual = 'EQUAL';
const String kFieldFilterOpNotEqual = 'NOT_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',
'!=': 'NOT_EQUAL',
};
final kFieldMapRegExp = RegExp(
r'([a-zA-Z0-9_\t ]+)'
'(${kRelationMapping.keys.join("|")})',
);
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 = 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 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 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 databasesDocumentsResource = await documentResource();
final beginTransactionRequest = BeginTransactionRequest(
options: TransactionOptions(readWrite: ReadWrite()),
);
final beginTransactionResponse = await databasesDocumentsResource
.beginTransaction(beginTransactionRequest, kDatabase);
final 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 filterMap = <String, Object>{
'$kCommitBranchField =': branch,
'$kCommitRepositoryPathField =': slug.fullName,
'$kCommitCreateTimestampField <': timestamp,
};
final orderMap = <String, String>{
kCommitCreateTimestampField: kQueryOrderDescending,
};
final documents = await query(
kCommitCollectionId,
filterMap,
orderMap: orderMap,
limit: limit,
);
return documents
.map(
(Document document) => Commit.fromDocument(commitDocument: document),
)
.toList();
}
/// Queries for recent [Task] by [name].
Future<List<Task>> queryRecentTasksByName({
int limit = 100,
required String name,
}) async {
final filterMap = {'$kTaskNameField =': name};
final orderMap = {kTaskCreateTimestampField: kQueryOrderDescending};
final documents = await query(
kTaskCollectionId,
filterMap,
orderMap: orderMap,
);
return documents.map((d) => Task.fromDocument(taskDocument: d)).toList();
}
/// Returns all tasks running against the speificed [commitSha].
Future<List<Task>> queryCommitTasks(String commitSha) async {
final filterMap = <String, Object>{'$kTaskCommitShaField =': commitSha};
final orderMap = <String, String>{
kTaskCreateTimestampField: kQueryOrderDescending,
};
final 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 filterMap = <String, Object>{
'$kGithubGoldStatusPrNumberField =': prNumber,
'$kGithubGoldStatusRepositoryField =': slug.fullName,
};
final documents = await query(kGithubGoldStatusCollectionId, filterMap);
final 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 filterMap = <String, Object>{
'$kGithubBuildStatusPrNumberField =': prNumber,
'$kGithubBuildStatusRepositoryField =': slug.fullName,
'$kGithubBuildStatusHeadField =': head,
};
final documents = await query(kGithubBuildStatusCollectionId, filterMap);
final 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 filters = <Filter>[];
filterMap.forEach((filterString, comparisonOject) {
final match = kFieldMapRegExp.firstMatch(filterString);
if (match == null) {
throw ArgumentError("Invalid filter string '$filterString'.");
}
final [name!, comparison!] = match.groups([1, 2]);
if (!kRelationMapping.containsKey(comparison)) {
throw ArgumentError("Invalid filter comparison in '$filterString'.");
}
final value = getValueFromFilter(comparisonOject);
filters.add(
Filter(
fieldFilter: FieldFilter(
field: FieldReference(fieldPath: '`${name.trim()}`'),
op: kRelationMapping[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 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 databasesDocumentsResource = await documentResource();
final from = <CollectionSelector>[
CollectionSelector(collectionId: collectionId),
];
final filter = generateFilter(filterMap, compositeFilterOp);
final orders = generateOrders(orderMap);
final runQueryRequest = RunQueryRequest(
structuredQuery: StructuredQuery(
from: from,
where: filter,
orderBy: orders,
limit: limit,
),
);
final runQueryResponseElements = await databasesDocumentsResource.runQuery(
runQueryRequest,
kDocumentParent,
);
return documentsFromQueryResponse(runQueryResponseElements);
}
/// Retrieve documents based on query response.
List<Document> documentsFromQueryResponse(
List<RunQueryResponseElement> runQueryResponseElements,
) {
final documents = <Document>[];
for (var 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();
}