diff --git a/CHANGELOG.md b/CHANGELOG.md index 2838be37db..f657abc530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ AppEngine version, listed here to ease deployment and troubleshooting. ## Next Release (replace with git tag when deployed) * Note: `Publisher` and `PublisherMember` are now mirrored to SQL. + * Note: `Like` is now mirrored to SQL. ## `20261014t070500-all` * Bump runtimeVersion to `2026.10.13`. diff --git a/app/lib/account/like_backend.dart b/app/lib/account/like_backend.dart index 9a70ffe184..8d2f1da963 100644 --- a/app/lib/account/like_backend.dart +++ b/app/lib/account/like_backend.dart @@ -6,7 +6,11 @@ import 'dart:async'; import 'package:clock/clock.dart'; import 'package:gcloud/service_scope.dart' as ss; +import 'package:logging/logging.dart'; +import 'package:typed_sql/typed_sql.dart' hide AuthenticationException; +import '../database/database.dart'; +import '../database/schema.dart'; import '../package/backend.dart' show packageBackend; import '../package/models.dart'; import '../shared/datastore.dart'; @@ -16,6 +20,8 @@ import '../shared/redis_cache.dart' show cache; import 'backend.dart' show purgeAccountCache; import 'models.dart'; +final _logger = Logger('like_backend'); + /// Sets the like backend service. void registerLikeBackend(LikeBackend backend) => ss.register(#_likeBackend, backend); @@ -83,6 +89,7 @@ class LikeBackend { }); if (updatedPackage != null) { await packageBackend.mirrorPackageToSql(updatedPackage!); + await mirrorToSql(res); } await cache.packageView(package).purge(); await purgeAccountCache(userId: user.userId); @@ -117,8 +124,107 @@ class LikeBackend { }); if (updatedPackage != null) { await packageBackend.mirrorPackageToSql(updatedPackage!); + await deleteFromSql(user.userId, package); } await cache.packageView(package).purge(); await cache.userPackageLikes(user.userId).purge(); } + + /// Upserts [like] into SQL. The user and the package must exist in SQL. + /// + /// A [Like] without `created` gets the current time, also stored in Datastore. + Future mirrorToSql(Like like) async { + final created = like.created ?? await _setMissingCreated(like); + await primaryDatabase.transactWithRetry( + (db) => db.packageLikes + .upsertValue( + userId: like.userId, + package: like.package, + createdAt: created, + ) + .execute(), + ); + } + + /// Sets the missing `created` of [like] in Datastore, unless it was set + /// concurrently, and returns the stored value. + Future _setMissingCreated(Like like) async { + final updated = await withRetryTransaction(_db, (tx) async { + final current = await tx.lookupOrNull(like.key); + if (current == null) return null; + current.created ??= clock.now().toUtc(); + tx.insert(current); + return current; + }); + // If the entity was removed concurrently, the next backfill drops the row. + final created = updated?.created ?? clock.now().toUtc(); + like.created = created; + return created; + } + + /// Deletes the SQL row of the like of [userId] on [package]. + Future deleteFromSql(String userId, String package) async { + await primaryDatabase.withRetry( + (db) => db.packageLikes.delete(userId, package).execute(), + ); + } + + /// Syncs SQL with the [Like] entities, one package at a time: upserts the + /// missing or changed likes, and removes the rows without a matching entity. + /// + /// Likes of users or packages that are missing from SQL are skipped. + Future backfillSqlFromDatastore() async { + var count = 0; + final packageNames = await packageBackend.allPackageNames().toList(); + for (final package in packageNames) { + final remainingSqlRows = {}; + final rows = await primaryDatabase.withRetry( + (db) => db.packageLikes + .where((l) => l.package.equalsValue(package)) + .select((l) => (l.userId, l.createdAt)) + .fetch(), + ); + for (final (userId, createdAt) in rows) { + remainingSqlRows[userId] = createdAt; + } + + bool? hasPackageInSql; + final query = _db.query()..filter('packageName =', package); + await for (final like in query.run()) { + final sqlCreated = remainingSqlRows.remove(like.userId); + final created = like.created; + if (created != null && + sqlCreated != null && + sqlCreated.isAtSameMomentAs(created)) { + continue; + } + hasPackageInSql ??= + await primaryDatabase.withRetry( + (db) => db.packages.byKey(package).fetch(), + ) != + null; + final hasUserInSql = + await primaryDatabase.withRetry( + (db) => db.users.byKey(like.userId).fetch(), + ) != + null; + if (!hasPackageInSql || !hasUserInSql) { + _logger.warning( + 'Skipping like of "${like.userId}" on "$package": ' + 'user or package is missing in SQL.', + ); + continue; + } + await mirrorToSql(like); + count++; + } + + // Rows left over have no Datastore entity. + for (final userId in remainingSqlRows.keys) { + await deleteFromSql(userId, package); + count++; + } + } + return count; + } } diff --git a/app/lib/admin/tools/user_merger.dart b/app/lib/admin/tools/user_merger.dart index fa8cc9b5d6..831b7ce546 100644 --- a/app/lib/admin/tools/user_merger.dart +++ b/app/lib/admin/tools/user_merger.dart @@ -7,6 +7,7 @@ import 'package:clock/clock.dart'; import 'package:logging/logging.dart'; import 'package:pool/pool.dart'; import 'package:pub_dev/account/backend.dart'; +import 'package:pub_dev/account/like_backend.dart'; import 'package:pub_dev/account/models.dart'; import 'package:pub_dev/audit/backend.dart'; import 'package:pub_dev/audit/models.dart'; @@ -216,12 +217,39 @@ class UserMerger { await _processConcurrently(_db.query(ancestorKey: fromUserKey), ( Like like, ) async { + final newLike = like.changeParentUser(toUserKey); + Package? updatedPackage; + var moved = false; await withRetryTransaction(_db, (tx) async { - tx.queueMutations( - inserts: [like.changeParentUser(toUserKey)], - deletes: [like.key], + updatedPackage = null; + moved = false; + final existing = await tx.lookupOrNull(newLike.key); + if (existing == null) { + tx.queueMutations(inserts: [newLike], deletes: [like.key]); + moved = true; + return; + } + // The target user already likes the package: keep their like, drop + // the duplicate and decrement the counter to avoid drifting. + final p = await tx.lookupOrNull( + _db.emptyKey.append(Package, id: like.package), ); + if (p == null) { + tx.queueMutations(deletes: [like.key]); + return; + } + p.likes--; + p.updated = clock.now().toUtc(); + tx.queueMutations(inserts: [p], deletes: [like.key]); + updatedPackage = p; }); + if (moved) { + await likeBackend.mirrorToSql(newLike); + } + await likeBackend.deleteFromSql(like.userId, like.package); + if (updatedPackage != null) { + await packageBackend.mirrorPackageToSql(updatedPackage!); + } }); // UserSession diff --git a/app/lib/database/schema.account.dart b/app/lib/database/schema.account.dart index c39d2c1b65..22b86d2fa7 100644 --- a/app/lib/database/schema.account.dart +++ b/app/lib/database/schema.account.dart @@ -72,3 +72,32 @@ abstract final class UserRow extends Row { String? get moderatedReason; } + +/// A user liking a package. +@PrimaryKey(['userId', 'package']) +@ForeignKey( + ['package'], + table: 'packages', + fields: ['name'], + name: 'packageRow', + as: 'likes', + onDelete: .cascade, + onUpdate: .cascade, +) +@ForeignKey( + ['userId'], + table: 'users', + fields: ['userId'], + name: 'userRow', + as: 'likedPackages', + onDelete: .cascade, + onUpdate: .cascade, +) +@Index(name: 'packageUser', fields: ['package', 'userId']) +abstract final class PackageLike extends Row { + String get userId; + + String get package; + + DateTime get createdAt; +} diff --git a/app/lib/database/schema.dart b/app/lib/database/schema.dart index f9776b16df..872ec11490 100644 --- a/app/lib/database/schema.dart +++ b/app/lib/database/schema.dart @@ -30,6 +30,8 @@ abstract final class PrimarySchema extends Schema { Table get userSessions; + Table get packageLikes; + // audit tables Table get auditLogRecords; diff --git a/app/lib/database/schema.g.dart b/app/lib/database/schema.g.dart index 81e823f3b6..b4007d671c 100644 --- a/app/lib/database/schema.g.dart +++ b/app/lib/database/schema.g.dart @@ -32,6 +32,7 @@ extension PrimarySchemaSchema on Database { static final _$tables = [ _$UserRow._$table, _$UserSession._$table, + _$PackageLike._$table, _$AuditLogRecordRow._$table, _$AuditLogAssociation._$table, _$ConsentRow._$table, @@ -61,6 +62,9 @@ extension PrimarySchemaSchema on Database { Table get userSessions => $ForGeneratedCode.declareTable(this, _$UserSession._$table); + Table get packageLikes => + $ForGeneratedCode.declareTable(this, _$PackageLike._$table); + Table get auditLogRecords => $ForGeneratedCode.declareTable(this, _$AuditLogRecordRow._$table); @@ -705,6 +709,17 @@ extension ExpressionUserRowExt on Expr { Expr get moderatedReason => $ForGeneratedCode.field(this, 7, $ForGeneratedCode.text); + /// Get [SubQuery] of rows from the `packageLikes` table which + /// reference this row. + /// + /// This returns a [SubQuery] of [PackageLike] rows, + /// where [PackageLike.userId] + /// references [UserRow.userId] + /// in this row. + SubQuery<(Expr,)> get likedPackages => $ForGeneratedCode + .subqueryTable(_$PackageLike._$table) + .where((r) => r.userId.equalsUnlessNull(userId)); + /// Get [SubQuery] of rows from the `packageUploaders` table which /// reference this row. /// @@ -759,6 +774,19 @@ extension ExpressionNullableUserRowExt on Expr { Expr get moderatedReason => $ForGeneratedCode.field(this, 7, $ForGeneratedCode.text); + /// Get [SubQuery] of rows from the `packageLikes` table which + /// reference this row. + /// + /// This returns a [SubQuery] of [PackageLike] rows, + /// where [PackageLike.userId] + /// references [UserRow.userId] + /// in this row, if any. + /// + /// If this row is `NULL` the subquery is always be empty. + SubQuery<(Expr,)> get likedPackages => $ForGeneratedCode + .subqueryTable(_$PackageLike._$table) + .where((r) => r.userId.equalsUnlessNull(userId)); + /// Get [SubQuery] of rows from the `packageUploaders` table which /// reference this row. /// @@ -803,6 +831,33 @@ extension ExpressionNullableUserRowExt on Expr { Expr isNull() => isNotNull().not(); } +extension InnerJoinUserRowPackageLikeExt + on InnerJoin<(Expr,), (Expr,)> { + /// Join using the `userRow` _foreign key_. + /// + /// This will match rows where [UserRow.userId] = [PackageLike.userId]. + Query<(Expr, Expr)> usingUserRow() => + on((a, b) => a.userId.equalsUnlessNull(b.userId)); +} + +extension LeftJoinUserRowPackageLikeExt + on LeftJoin<(Expr,), (Expr,)> { + /// Join using the `userRow` _foreign key_. + /// + /// This will match rows where [UserRow.userId] = [PackageLike.userId]. + Query<(Expr, Expr)> usingUserRow() => + on((a, b) => a.userId.equalsUnlessNull(b.userId)); +} + +extension RightJoinUserRowPackageLikeExt + on RightJoin<(Expr,), (Expr,)> { + /// Join using the `userRow` _foreign key_. + /// + /// This will match rows where [UserRow.userId] = [PackageLike.userId]. + Query<(Expr, Expr)> usingUserRow() => + on((a, b) => a.userId.equalsUnlessNull(b.userId)); +} + extension InnerJoinUserRowPackageUploaderExt on InnerJoin<(Expr,), (Expr,)> { /// Join using the `userRow` _foreign key_. @@ -1797,54 +1852,736 @@ extension ExpressionUserSessionExt on Expr { $ForGeneratedCode.field(this, 11, $ForGeneratedCode.text); } -extension ExpressionNullableUserSessionExt on Expr { - /// The session ID, a random UUID String. - Expr get sessionId => +extension ExpressionNullableUserSessionExt on Expr { + /// The session ID, a random UUID String. + Expr get sessionId => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.text); + + /// The id of the `User` that has this session. + Expr get userId => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + /// The email of the `User` that has this session. + Expr get email => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.text); + + /// The name of the `User` - given by the authentication provider. + Expr get name => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.text); + + /// The profile image URL of the `User` - given by the authentication provider. + Expr get imageUrl => + $ForGeneratedCode.field(this, 4, $ForGeneratedCode.text); + + /// The time when the session was created. + Expr get created => + $ForGeneratedCode.field(this, 5, $ForGeneratedCode.dateTime); + + /// The time when the session will expire. + Expr get expires => + $ForGeneratedCode.field(this, 6, $ForGeneratedCode.dateTime); + + /// The time when the session was last authenticated. + Expr get authenticatedAt => + $ForGeneratedCode.field(this, 7, $ForGeneratedCode.dateTime); + + /// The CSRF token that the session uses. + Expr get csrfToken => + $ForGeneratedCode.field(this, 8, $ForGeneratedCode.text); + + /// The random value used for OpenID authentication. + Expr get openidNonce => + $ForGeneratedCode.field(this, 9, $ForGeneratedCode.text); + + /// The access token from the OpenID authentication. + Expr get accessToken => + $ForGeneratedCode.field(this, 10, $ForGeneratedCode.text); + + /// The granted scopes from the OpenID authentication. + Expr get grantedScopes => + $ForGeneratedCode.field(this, 11, $ForGeneratedCode.text); + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => sessionId.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum UserSessionConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `sessionId`. + primaryKey(['session_id']); + + const UserSessionConflict(this._fields); + + final List _fields; +} + +extension InsertUserSessionExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((userSession, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict(UserSessionConflict target) => + $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictUserSessionExt on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `userSession` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr userSession, + Expr excluded, + UpdateSet Function({ + Expr sessionId, + Expr userId, + Expr email, + Expr name, + Expr imageUrl, + Expr created, + Expr expires, + Expr authenticatedAt, + Expr csrfToken, + Expr openidNonce, + Expr accessToken, + Expr grantedScopes, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (userSession, excluded) => updateBuilder( + userSession, + excluded, + ({ + Expr? sessionId, + Expr? userId, + Expr? email, + Expr? name, + Expr? imageUrl, + Expr? created, + Expr? expires, + Expr? authenticatedAt, + Expr? csrfToken, + Expr? openidNonce, + Expr? accessToken, + Expr? grantedScopes, + }) => $ForGeneratedCode.buildUpdate([ + sessionId, + userId, + email, + name, + imageUrl, + created, + expires, + authenticatedAt, + csrfToken, + openidNonce, + accessToken, + grantedScopes, + ]), + ), + ); +} + +extension InsertSingleUserSessionExt on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((userSession, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict(UserSessionConflict target) => + $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleUserSessionExt + on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `userSession` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr userSession, + Expr excluded, + UpdateSet Function({ + Expr sessionId, + Expr userId, + Expr email, + Expr name, + Expr imageUrl, + Expr created, + Expr expires, + Expr authenticatedAt, + Expr csrfToken, + Expr openidNonce, + Expr accessToken, + Expr grantedScopes, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (userSession, excluded) => updateBuilder( + userSession, + excluded, + ({ + Expr? sessionId, + Expr? userId, + Expr? email, + Expr? name, + Expr? imageUrl, + Expr? created, + Expr? expires, + Expr? authenticatedAt, + Expr? csrfToken, + Expr? openidNonce, + Expr? accessToken, + Expr? grantedScopes, + }) => $ForGeneratedCode.buildUpdate([ + sessionId, + userId, + email, + name, + imageUrl, + created, + expires, + authenticatedAt, + csrfToken, + openidNonce, + accessToken, + grantedScopes, + ]), + ), + ); +} + +final class _$PackageLike extends PackageLike { + _$PackageLike._(this.userId, this.package, this.createdAt); + + @override + final String userId; + + @override + final String package; + + @override + final DateTime createdAt; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'package_likes', + columns: ['user_id', 'package', 'created_at'], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.dateTime, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + ], + primaryKey: ['user_id', 'package'], + unique: >[], + foreignKeys: [ + $ForGeneratedCode.foreignKeyDefinition( + name: 'packageRow', + columns: ['package'], + referencedTable: 'packages', + referencedColumns: ['name'], + onDelete: .cascade, + onUpdate: .cascade, + ), + $ForGeneratedCode.foreignKeyDefinition( + name: 'userRow', + columns: ['user_id'], + referencedTable: 'users', + referencedColumns: ['user_id'], + onDelete: .cascade, + onUpdate: .cascade, + ), + ], + indexes: [ + $ForGeneratedCode.indexDefinition( + name: 'packageUser', + sqlName: 'package_user', + columns: ['package', 'user_id'], + ), + ], + readRow: _$PackageLike._$fromDatabase, + ); + + static PackageLike? _$fromDatabase(RowReader row) { + final userId = row.readString(); + final package = row.readString(); + final createdAt = row.readDateTime(); + if (userId == null && package == null && createdAt == null) { + return null; + } + return _$PackageLike._(userId!, package!, createdAt!); + } + + @override + String toString() => + 'PackageLike(userId: "$userId", package: "$package", createdAt: "$createdAt")'; +} + +/// Extension methods for table defined in [PackageLike]. +extension TablePackageLikeExt on Table { + /// Insert row into the `packageLikes` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({ + required Expr userId, + required Expr package, + required Expr createdAt, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [userId, package, createdAt], + ); + + /// Insert row into the `packageLikes` table, or update the + /// existing row if it conflicts with the _primary key_. + /// + /// This is a shorthand for calling `.insert(...)` followed by + /// `.onConflict(.primaryKey)` and `.update(...)` to overwrite + /// the fields `createdAt`, + /// with the values given, leaving the _primary key_ untouched. + /// + /// Returns an [UpsertSingle] statement on which `.execute()` must be + /// called for the row to be inserted or updated. + UpsertSingle upsert({ + required Expr userId, + required Expr package, + required Expr createdAt, + }) => insert(userId: userId, package: package, createdAt: createdAt) + .onConflict(.primaryKey) + .update((_, excluded, set) => set(createdAt: excluded.createdAt)); + + /// Insert row into the `packageLikes` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({ + required String userId, + required String package, + required DateTime createdAt, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [userId.asExpr, package.asExpr, createdAt.asExpr], + ); + + /// Insert row into the `packageLikes` table, or update the + /// existing row if it conflicts with the _primary key_. + /// + /// This is a shorthand for calling `.insertValue(...)` followed by + /// `.onConflict(.primaryKey)` and `.update(...)` to overwrite + /// the fields `createdAt`, + /// with the values given, leaving the _primary key_ untouched. + /// + /// Returns an [UpsertSingle] statement on which `.execute()` must be + /// called for the row to be inserted or updated. + UpsertSingle upsertValue({ + required String userId, + required String package, + required DateTime createdAt, + }) => insertValue(userId: userId, package: package, createdAt: createdAt) + .onConflict(.primaryKey) + .update((_, excluded, set) => set(createdAt: excluded.createdAt)); + + /// Bulk insert rows into the `packageLikes` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + required String Function(T row) userId, + required String Function(T row) package, + required DateTime Function(T row) createdAt, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [userId, package, createdAt], + ); + + /// Delete a single row from the `packageLikes` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(String userId, String package) => + $ForGeneratedCode.deleteSingle( + byKey(userId, package), + _$PackageLike._$table, + ); +} + +/// Extension methods for building queries against the `packageLikes` table. +extension QueryPackageLikeExt on Query<(Expr,)> { + /// Lookup a single row in `packageLikes` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey(String userId, String package) => + where( + (packageLike) => + packageLike.userId.equalsValue(userId) & + packageLike.package.equalsValue(package), + ).first; + + /// Update all rows in the `packageLikes` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr packageLike, + UpdateSet Function({ + Expr userId, + Expr package, + Expr createdAt, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$PackageLike._$table, + (packageLike) => updateBuilder( + packageLike, + ({ + Expr? userId, + Expr? package, + Expr? createdAt, + }) => $ForGeneratedCode.buildUpdate([ + userId, + package, + createdAt, + ]), + ), + ); + + /// Delete all rows in the `packageLikes` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => + $ForGeneratedCode.delete(this, _$PackageLike._$table); +} + +/// Extension methods for building point queries against the `packageLikes` table. +extension QuerySinglePackageLikeExt on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `packageLikes` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr packageLike, + UpdateSet Function({ + Expr userId, + Expr package, + Expr createdAt, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$PackageLike._$table, + (packageLike) => updateBuilder( + packageLike, + ({ + Expr? userId, + Expr? package, + Expr? createdAt, + }) => $ForGeneratedCode.buildUpdate([ + userId, + package, + createdAt, + ]), + ), + ); + + /// Delete the row (if any) in the `packageLikes` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$PackageLike._$table); +} + +/// Extension methods for expressions on a row in the `packageLikes` table. +extension ExpressionPackageLikeExt on Expr { + Expr get userId => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.text); + + Expr get package => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + Expr get createdAt => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.dateTime); + + /// Do a subquery lookup of the row from table + /// `packages` referenced in + /// [package]. + /// + /// The gets the row from table `packages` where + /// [PackageRow.name] + /// is equal to [package]. + Expr get packageRow => $ForGeneratedCode + .subqueryTable(_$PackageRow._$table) + .where((r) => r.name.equalsUnlessNull(package)) + .first + .asNotNull(); + + /// Do a subquery lookup of the row from table + /// `users` referenced in + /// [userId]. + /// + /// The gets the row from table `users` where + /// [UserRow.userId] + /// is equal to [userId]. + Expr get userRow => $ForGeneratedCode + .subqueryTable(_$UserRow._$table) + .where((r) => r.userId.equalsUnlessNull(userId)) + .first + .asNotNull(); +} + +extension ExpressionNullablePackageLikeExt on Expr { + Expr get userId => $ForGeneratedCode.field(this, 0, $ForGeneratedCode.text); - /// The id of the `User` that has this session. - Expr get userId => + Expr get package => $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); - /// The email of the `User` that has this session. - Expr get email => - $ForGeneratedCode.field(this, 2, $ForGeneratedCode.text); - - /// The name of the `User` - given by the authentication provider. - Expr get name => - $ForGeneratedCode.field(this, 3, $ForGeneratedCode.text); - - /// The profile image URL of the `User` - given by the authentication provider. - Expr get imageUrl => - $ForGeneratedCode.field(this, 4, $ForGeneratedCode.text); - - /// The time when the session was created. - Expr get created => - $ForGeneratedCode.field(this, 5, $ForGeneratedCode.dateTime); - - /// The time when the session will expire. - Expr get expires => - $ForGeneratedCode.field(this, 6, $ForGeneratedCode.dateTime); - - /// The time when the session was last authenticated. - Expr get authenticatedAt => - $ForGeneratedCode.field(this, 7, $ForGeneratedCode.dateTime); - - /// The CSRF token that the session uses. - Expr get csrfToken => - $ForGeneratedCode.field(this, 8, $ForGeneratedCode.text); - - /// The random value used for OpenID authentication. - Expr get openidNonce => - $ForGeneratedCode.field(this, 9, $ForGeneratedCode.text); + Expr get createdAt => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.dateTime); - /// The access token from the OpenID authentication. - Expr get accessToken => - $ForGeneratedCode.field(this, 10, $ForGeneratedCode.text); + /// Do a subquery lookup of the row from table + /// `packages` referenced in + /// [package]. + /// + /// The gets the row from table `packages` where + /// [PackageRow.name] + /// is equal to [package], if any. + /// + /// If this row is `NULL` the subquery is always return `NULL`. + Expr get packageRow => $ForGeneratedCode + .subqueryTable(_$PackageRow._$table) + .where((r) => r.name.equalsUnlessNull(package)) + .first; - /// The granted scopes from the OpenID authentication. - Expr get grantedScopes => - $ForGeneratedCode.field(this, 11, $ForGeneratedCode.text); + /// Do a subquery lookup of the row from table + /// `users` referenced in + /// [userId]. + /// + /// The gets the row from table `users` where + /// [UserRow.userId] + /// is equal to [userId], if any. + /// + /// If this row is `NULL` the subquery is always return `NULL`. + Expr get userRow => $ForGeneratedCode + .subqueryTable(_$UserRow._$table) + .where((r) => r.userId.equalsUnlessNull(userId)) + .first; /// Check if the row is not `NULL`. /// @@ -1852,7 +2589,7 @@ extension ExpressionNullableUserSessionExt on Expr { /// /// If this is a reference lookup by subquery it might be more efficient /// to check if the referencing field is `NULL`. - Expr isNotNull() => sessionId.isNotNull(); + Expr isNotNull() => userId.isNotNull() & package.isNotNull(); /// Check if the row is `NULL`. /// @@ -1863,20 +2600,74 @@ extension ExpressionNullableUserSessionExt on Expr { Expr isNull() => isNotNull().not(); } -/// `Table` conflict targets for use with `.onConflict`. -enum UserSessionConflict { +extension InnerJoinPackageLikePackageRowExt + on InnerJoin<(Expr,), (Expr,)> { + /// Join using the `packageRow` _foreign key_. + /// + /// This will match rows where [PackageLike.package] = [PackageRow.name]. + Query<(Expr, Expr)> usingPackageRow() => + on((a, b) => b.name.equalsUnlessNull(a.package)); +} + +extension LeftJoinPackageLikePackageRowExt + on LeftJoin<(Expr,), (Expr,)> { + /// Join using the `packageRow` _foreign key_. + /// + /// This will match rows where [PackageLike.package] = [PackageRow.name]. + Query<(Expr, Expr)> usingPackageRow() => + on((a, b) => b.name.equalsUnlessNull(a.package)); +} + +extension RightJoinPackageLikePackageRowExt + on RightJoin<(Expr,), (Expr,)> { + /// Join using the `packageRow` _foreign key_. + /// + /// This will match rows where [PackageLike.package] = [PackageRow.name]. + Query<(Expr, Expr)> usingPackageRow() => + on((a, b) => b.name.equalsUnlessNull(a.package)); +} + +extension InnerJoinPackageLikeUserRowExt + on InnerJoin<(Expr,), (Expr,)> { + /// Join using the `userRow` _foreign key_. + /// + /// This will match rows where [PackageLike.userId] = [UserRow.userId]. + Query<(Expr, Expr)> usingUserRow() => + on((a, b) => b.userId.equalsUnlessNull(a.userId)); +} + +extension LeftJoinPackageLikeUserRowExt + on LeftJoin<(Expr,), (Expr,)> { + /// Join using the `userRow` _foreign key_. + /// + /// This will match rows where [PackageLike.userId] = [UserRow.userId]. + Query<(Expr, Expr)> usingUserRow() => + on((a, b) => b.userId.equalsUnlessNull(a.userId)); +} + +extension RightJoinPackageLikeUserRowExt + on RightJoin<(Expr,), (Expr,)> { + /// Join using the `userRow` _foreign key_. + /// + /// This will match rows where [PackageLike.userId] = [UserRow.userId]. + Query<(Expr, Expr)> usingUserRow() => + on((a, b) => b.userId.equalsUnlessNull(a.userId)); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum PackageLikeConflict { /// Conflict with an existing row that has a matching primary key. /// /// Thus, the other row has matching values for: - /// `sessionId`. - primaryKey(['session_id']); + /// `userId`, `package`. + primaryKey(['user_id', 'package']); - const UserSessionConflict(this._fields); + const PackageLikeConflict(this._fields); final List _fields; } -extension InsertUserSessionExt on Insert { +extension InsertPackageLikeExt on Insert { /// Build an `INSERT` statement with an `ON CONFLICT` clause. /// /// The [target] argument specifies the _conflict target_ to be @@ -1886,7 +2677,7 @@ extension InsertUserSessionExt on Insert { /// If a row to be inserted violates the _conflict target_ constraint, /// then the conflict action is triggered: /// * `.doNothing()` to skip insertion of the new row, and, - /// * `.update((userSession, excluded, set) => set(...))` to + /// * `.update((packageLike, excluded, set) => set(...))` to /// update the conflicting row. /// /// If a row to be inserted violates a constraint other than the one @@ -1894,18 +2685,18 @@ extension InsertUserSessionExt on Insert { /// will fail. /// /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. - InsertOnConflict onConflict(UserSessionConflict target) => + InsertOnConflict onConflict(PackageLikeConflict target) => $ForGeneratedCode.insertOnConflict(this, target._fields); } -extension InsertOnConflictUserSessionExt on InsertOnConflict { +extension InsertOnConflictPackageLikeExt on InsertOnConflict { /// Build an `INSERT` statement an [upsert-clause][1]. /// /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` /// constraint previously specified as _conflict target_, the existing /// row is updated using the expressions defined with the /// [updateBuilder]. The [updateBuilder] is given 3 parameters: - /// * `userSession` an [Expr] representing the existing row in + /// * `packageLike` an [Expr] representing the existing row in /// the database, /// * `excluded` an [Expr] representing the row to be inserted in the /// database, and, @@ -1939,64 +2730,37 @@ extension InsertOnConflictUserSessionExt on InsertOnConflict { /// > be returned immediately. /// /// [1]: https://www.sqlite.org/lang_upsert.html - Upsert update( - UpdateSet Function( - Expr userSession, - Expr excluded, - UpdateSet Function({ - Expr sessionId, - Expr userId, - Expr email, - Expr name, - Expr imageUrl, - Expr created, - Expr expires, - Expr authenticatedAt, - Expr csrfToken, - Expr openidNonce, - Expr accessToken, - Expr grantedScopes, + Upsert update( + UpdateSet Function( + Expr packageLike, + Expr excluded, + UpdateSet Function({ + Expr userId, + Expr package, + Expr createdAt, }) set, ) updateBuilder, - ) => $ForGeneratedCode.updateOnConflict( + ) => $ForGeneratedCode.updateOnConflict( this, - (userSession, excluded) => updateBuilder( - userSession, + (packageLike, excluded) => updateBuilder( + packageLike, excluded, ({ - Expr? sessionId, - Expr? userId, - Expr? email, - Expr? name, - Expr? imageUrl, - Expr? created, - Expr? expires, - Expr? authenticatedAt, - Expr? csrfToken, - Expr? openidNonce, - Expr? accessToken, - Expr? grantedScopes, - }) => $ForGeneratedCode.buildUpdate([ - sessionId, + Expr? userId, + Expr? package, + Expr? createdAt, + }) => $ForGeneratedCode.buildUpdate([ userId, - email, - name, - imageUrl, - created, - expires, - authenticatedAt, - csrfToken, - openidNonce, - accessToken, - grantedScopes, + package, + createdAt, ]), ), ); } -extension InsertSingleUserSessionExt on InsertSingle { +extension InsertSinglePackageLikeExt on InsertSingle { /// Build an `INSERT` statement with an `ON CONFLICT` clause. /// /// The [target] argument specifies the _conflict target_ to be @@ -2006,7 +2770,7 @@ extension InsertSingleUserSessionExt on InsertSingle { /// If a row to be inserted violates the _conflict target_ constraint, /// then the conflict action is triggered: /// * `.doNothing()` to skip insertion of the new row, and, - /// * `.update((userSession, excluded, set) => set(...))` to + /// * `.update((packageLike, excluded, set) => set(...))` to /// update the conflicting row. /// /// If a row to be inserted violates a constraint other than the one @@ -2014,19 +2778,19 @@ extension InsertSingleUserSessionExt on InsertSingle { /// will fail. /// /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. - InsertOnConflictSingle onConflict(UserSessionConflict target) => + InsertOnConflictSingle onConflict(PackageLikeConflict target) => $ForGeneratedCode.insertOnConflictSingle(this, target._fields); } -extension InsertOnConflictSingleUserSessionExt - on InsertOnConflictSingle { +extension InsertOnConflictSinglePackageLikeExt + on InsertOnConflictSingle { /// Build an `INSERT` statement an [upsert-clause][1]. /// /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` /// constraint previously specified as _conflict target_, the existing /// row is updated using the expressions defined with the /// [updateBuilder]. The [updateBuilder] is given 3 parameters: - /// * `userSession` an [Expr] representing the existing row in + /// * `packageLike` an [Expr] representing the existing row in /// the database, /// * `excluded` an [Expr] representing the row to be inserted in the /// database, and, @@ -2060,58 +2824,31 @@ extension InsertOnConflictSingleUserSessionExt /// > be returned immediately. /// /// [1]: https://www.sqlite.org/lang_upsert.html - UpsertSingle update( - UpdateSet Function( - Expr userSession, - Expr excluded, - UpdateSet Function({ - Expr sessionId, - Expr userId, - Expr email, - Expr name, - Expr imageUrl, - Expr created, - Expr expires, - Expr authenticatedAt, - Expr csrfToken, - Expr openidNonce, - Expr accessToken, - Expr grantedScopes, + UpsertSingle update( + UpdateSet Function( + Expr packageLike, + Expr excluded, + UpdateSet Function({ + Expr userId, + Expr package, + Expr createdAt, }) set, ) updateBuilder, - ) => $ForGeneratedCode.updateOnConflictSingle( + ) => $ForGeneratedCode.updateOnConflictSingle( this, - (userSession, excluded) => updateBuilder( - userSession, + (packageLike, excluded) => updateBuilder( + packageLike, excluded, ({ - Expr? sessionId, - Expr? userId, - Expr? email, - Expr? name, - Expr? imageUrl, - Expr? created, - Expr? expires, - Expr? authenticatedAt, - Expr? csrfToken, - Expr? openidNonce, - Expr? accessToken, - Expr? grantedScopes, - }) => $ForGeneratedCode.buildUpdate([ - sessionId, + Expr? userId, + Expr? package, + Expr? createdAt, + }) => $ForGeneratedCode.buildUpdate([ userId, - email, - name, - imageUrl, - created, - expires, - authenticatedAt, - csrfToken, - openidNonce, - accessToken, - grantedScopes, + package, + createdAt, ]), ), ); @@ -7947,6 +8684,17 @@ extension ExpressionPackageRowExt on Expr { Expr get latestAdvisoryAt => $ForGeneratedCode.field(this, 23, $ForGeneratedCode.dateTime); + /// Get [SubQuery] of rows from the `packageLikes` table which + /// reference this row. + /// + /// This returns a [SubQuery] of [PackageLike] rows, + /// where [PackageLike.package] + /// references [PackageRow.name] + /// in this row. + SubQuery<(Expr,)> get likes => $ForGeneratedCode + .subqueryTable(_$PackageLike._$table) + .where((r) => r.package.equalsUnlessNull(name)); + /// Get [SubQuery] of rows from the `packageUploaders` table which /// reference this row. /// @@ -8055,6 +8803,19 @@ extension ExpressionNullablePackageRowExt on Expr { Expr get latestAdvisoryAt => $ForGeneratedCode.field(this, 23, $ForGeneratedCode.dateTime); + /// Get [SubQuery] of rows from the `packageLikes` table which + /// reference this row. + /// + /// This returns a [SubQuery] of [PackageLike] rows, + /// where [PackageLike.package] + /// references [PackageRow.name] + /// in this row, if any. + /// + /// If this row is `NULL` the subquery is always be empty. + SubQuery<(Expr,)> get likes => $ForGeneratedCode + .subqueryTable(_$PackageLike._$table) + .where((r) => r.package.equalsUnlessNull(name)); + /// Get [SubQuery] of rows from the `packageUploaders` table which /// reference this row. /// @@ -8098,6 +8859,33 @@ extension ExpressionNullablePackageRowExt on Expr { Expr isNull() => isNotNull().not(); } +extension InnerJoinPackageRowPackageLikeExt + on InnerJoin<(Expr,), (Expr,)> { + /// Join using the `packageRow` _foreign key_. + /// + /// This will match rows where [PackageRow.name] = [PackageLike.package]. + Query<(Expr, Expr)> usingPackageRow() => + on((a, b) => a.name.equalsUnlessNull(b.package)); +} + +extension LeftJoinPackageRowPackageLikeExt + on LeftJoin<(Expr,), (Expr,)> { + /// Join using the `packageRow` _foreign key_. + /// + /// This will match rows where [PackageRow.name] = [PackageLike.package]. + Query<(Expr, Expr)> usingPackageRow() => + on((a, b) => a.name.equalsUnlessNull(b.package)); +} + +extension RightJoinPackageRowPackageLikeExt + on RightJoin<(Expr,), (Expr,)> { + /// Join using the `packageRow` _foreign key_. + /// + /// This will match rows where [PackageRow.name] = [PackageLike.package]. + Query<(Expr, Expr)> usingPackageRow() => + on((a, b) => a.name.equalsUnlessNull(b.package)); +} + extension InnerJoinPackageRowPackageUploaderExt on InnerJoin<(Expr,), (Expr,)> { /// Join using the `packageRow` _foreign key_. diff --git a/app/lib/tool/backfill/backfill_new_fields.dart b/app/lib/tool/backfill/backfill_new_fields.dart index e1e88b3795..c77f648ad2 100644 --- a/app/lib/tool/backfill/backfill_new_fields.dart +++ b/app/lib/tool/backfill/backfill_new_fields.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import 'package:pub_dev/account/backend.dart'; import 'package:pub_dev/account/consent_backend.dart'; +import 'package:pub_dev/account/like_backend.dart'; import 'package:pub_dev/package/backend.dart'; import 'package:pub_dev/package/models.dart'; import 'package:pub_dev/publisher/backend.dart'; @@ -60,4 +61,8 @@ Future backfillNewFields() async { // NOTE: Keep this around until Publisher and PublisherMember are migrated to use SQL. _logger.info('Backfilling publishers and publisher members...'); await publisherBackend.backfillPublishersSqlFromDatastore(); + + // NOTE: Keep this around until Like is migrated to SQL. + _logger.info('Backfilling package likes...'); + await likeBackend.backfillSqlFromDatastore(); } diff --git a/app/lib/tool/maintenance/remove_orphaned_likes.dart b/app/lib/tool/maintenance/remove_orphaned_likes.dart index 4bc8f6223c..9908f0597a 100644 --- a/app/lib/tool/maintenance/remove_orphaned_likes.dart +++ b/app/lib/tool/maintenance/remove_orphaned_likes.dart @@ -7,6 +7,7 @@ import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import '../../account/backend.dart'; +import '../../account/like_backend.dart'; import '../../account/models.dart'; import '../../package/backend.dart'; import '../../shared/datastore.dart'; @@ -68,6 +69,7 @@ Future removeOrphanedLikes({ 'Removing like for package `${like.package}` because userId `${like.userId}` is missing.', ); await withRetryTransaction(dbService, (tx) async => tx.delete(like.key)); + await likeBackend.deleteFromSql(like.userId, like.package); deleted++; return; } @@ -77,6 +79,7 @@ Future removeOrphanedLikes({ 'Removing like for userId `${like.userId}` because package `${like.package}` is missing.', ); await withRetryTransaction(dbService, (tx) async => tx.delete(like.key)); + await likeBackend.deleteFromSql(like.userId, like.package); deleted++; return; } diff --git a/app/migrations/000020_package_like.sql b/app/migrations/000020_package_like.sql new file mode 100644 index 0000000000..732868317e --- /dev/null +++ b/app/migrations/000020_package_like.sql @@ -0,0 +1,12 @@ +-- Create "package_likes" table +CREATE TABLE "package_likes" ( + "user_id" text NOT NULL, + "package" text NOT NULL, + "created_at" timestamptz NOT NULL, + PRIMARY KEY ("user_id", "package"), + CONSTRAINT "package_likes_fk_packageRow" FOREIGN KEY ("package") REFERENCES "packages" ("name") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "package_likes_fk_userRow" FOREIGN KEY ("user_id") REFERENCES "users" ("user_id") ON UPDATE CASCADE ON DELETE CASCADE +); + +-- Create index "package_likes_idx_package_user" to table: "package_likes" +CREATE INDEX "package_likes_idx_package_user" ON "package_likes" ("package", "user_id"); diff --git a/app/migrations/sha256sum.txt b/app/migrations/sha256sum.txt index 2e414c433a..7f26a7d035 100644 --- a/app/migrations/sha256sum.txt +++ b/app/migrations/sha256sum.txt @@ -17,3 +17,4 @@ a1d582bf359628ebe7ed88d9b5c066185fe702b146dc8c4c9aacaecc56aa2c53 000014_audit_i 28a863706f66d9a1b5d1f2ee37abaf4294c99cf1940b72777f34f2302361aa60 000017_package_version_asset.sql c2eacad15a797fc11eb464296c73cc32211a07c17b3c8012402ecd92e13f6adf 000018_package_and_version.sql 8454af023ff5db777fa463a33768d5d01af2310d209a823a69dd2fcdf8c1eb1b 000019_publisher.sql +46b4d641734a4acc11ee6266117ed259df43e9865ceaa72ed8b7e24499d9aee4 000020_package_like.sql diff --git a/app/test/account/like_test.dart b/app/test/account/like_test.dart index 689ddc187f..e75ed753fb 100644 --- a/app/test/account/like_test.dart +++ b/app/test/account/like_test.dart @@ -2,12 +2,19 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:pub_dev/account/backend.dart'; +import 'package:pub_dev/account/like_backend.dart'; +import 'package:pub_dev/account/models.dart'; +import 'package:pub_dev/database/database.dart'; +import 'package:pub_dev/database/schema.dart'; import 'package:pub_dev/fake/backend/fake_auth_provider.dart'; import 'package:pub_dev/package/backend.dart'; import 'package:pub_dev/package/models.dart'; import 'package:pub_dev/shared/datastore.dart'; import 'package:test/test.dart'; +import 'package:typed_sql/typed_sql.dart'; +import '../database/like_sql_utils.dart'; import '../database/package_sql_utils.dart'; import '../shared/handlers_test_utils.dart'; import '../shared/test_models.dart'; @@ -37,6 +44,7 @@ void main() { expect(rs3.liked, true); await expectPackageInSqlMatchesDatastore(pkg1); + await expectLikesInSqlMatchDatastore(); }, ); @@ -133,6 +141,61 @@ void main() { await expectPackageInSqlMatchesDatastore(pkg1); await expectPackageInSqlMatchesDatastore(pkg2); + await expectLikesInSqlMatchDatastore(); + }, + ); + + testWithProfile( + 'Backfill syncs missing and stale like rows', + fn: () async { + final client = await createFakeAuthPubApiClient( + email: userAtPubDevEmail, + ); + await client.likePackage(pkg1); + await client.likePackage(pkg2); + expect(await likeBackend.backfillSqlFromDatastore(), 0); + + final user = await accountBackend.lookupUserByEmail(userAtPubDevEmail); + await likeBackend.deleteFromSql(user.userId, pkg1); + expect(await likeBackend.backfillSqlFromDatastore(), 1); + await expectLikesInSqlMatchDatastore(); + + // removes the row without a Datastore entity + await withRetryTransaction(dbService, (tx) async { + tx.delete( + dbService.emptyKey + .append(User, id: user.userId) + .append(Like, id: pkg2), + ); + }); + expect(await likeBackend.backfillSqlFromDatastore(), 1); + expect(await likeBackend.backfillSqlFromDatastore(), 0); + }, + ); + + testWithProfile( + 'Backfill sets missing created timestamp in Datastore and SQL', + fn: () async { + final user = await accountBackend.lookupUserByEmail(userAtPubDevEmail); + await withRetryTransaction(dbService, (tx) async { + tx.insert( + Like() + ..parentKey = user.key + ..id = pkg1 + ..packageName = pkg1, + ); + }); + expect(await likeBackend.backfillSqlFromDatastore(), 1); + final like = await likeBackend.getPackageLikeStatus(user.userId, pkg1); + expect(like!.created, isNotNull); + final row = await primaryDatabase.withRetry( + (db) => db.packageLikes.byKey(user.userId, pkg1).fetch(), + ); + expect( + row!.createdAt.microsecondsSinceEpoch, + like.created!.microsecondsSinceEpoch, + ); + expect(await likeBackend.backfillSqlFromDatastore(), 0); }, ); diff --git a/app/test/admin/user_merger_test.dart b/app/test/admin/user_merger_test.dart index 68f20d2897..b11f2e4251 100644 --- a/app/test/admin/user_merger_test.dart +++ b/app/test/admin/user_merger_test.dart @@ -18,6 +18,7 @@ import 'package:pub_dev/tool/test_profile/models.dart'; import 'package:test/test.dart'; import 'package:typed_sql/typed_sql.dart'; +import '../database/like_sql_utils.dart'; import '../database/package_sql_utils.dart'; import '../database/publisher_sql_utils.dart'; import '../shared/test_services.dart'; @@ -211,6 +212,30 @@ void main() { )).map((e) => e.package).toList(), ['oxygen'], ); + await expectLikesInSqlMatchDatastore(); + }, + ); + + testWithProfile( + 'like when the target user already likes the package', + fn: () async { + final user = await accountBackend.lookupUserByEmail('user@pub.dev'); + final admin = await accountBackend.lookupUserByEmail('admin@pub.dev'); + await likeBackend.likePackage(user, 'oxygen'); + await likeBackend.likePackage(admin, 'oxygen'); + expect((await packageBackend.lookupPackage('oxygen'))!.likes, 2); + + await _corruptAndFix(); + + expect(await likeBackend.listPackageLikes(admin.userId), isEmpty); + expect( + (await likeBackend.listPackageLikes( + user.userId, + )).map((e) => e.package).toList(), + ['oxygen'], + ); + expect((await packageBackend.lookupPackage('oxygen'))!.likes, 1); + await expectLikesInSqlMatchDatastore(); }, ); diff --git a/app/test/database/like_sql_utils.dart b/app/test/database/like_sql_utils.dart new file mode 100644 index 0000000000..3b1414e460 --- /dev/null +++ b/app/test/database/like_sql_utils.dart @@ -0,0 +1,36 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_dev/account/models.dart'; +import 'package:pub_dev/database/database.dart'; +import 'package:pub_dev/database/schema.dart'; +import 'package:pub_dev/shared/datastore.dart'; +import 'package:test/test.dart'; +import 'package:typed_sql/typed_sql.dart'; + +/// Expects that the SQL likes match the [Like] entities, and that each +/// package's likes count matches its number of likes. +Future expectLikesInSqlMatchDatastore() async { + final datastoreLikes = {}; + await for (final like in dbService.query().run()) { + datastoreLikes['${like.userId}/${like.package}'] = + like.created!.microsecondsSinceEpoch; + } + final rows = await primaryDatabase.withRetry((db) => db.packageLikes.fetch()); + expect({ + for (final r in rows) + '${r.userId}/${r.package}': r.createdAt.microsecondsSinceEpoch, + }, datastoreLikes); + + final counts = {}; + for (final r in rows) { + counts[r.package] = (counts[r.package] ?? 0) + 1; + } + final packages = await primaryDatabase.withRetry( + (db) => db.packages.select((p) => (p.name, p.likesCount)).fetch(), + ); + for (final (name, likesCount) in packages) { + expect(likesCount, counts[name] ?? 0, reason: 'likes count of $name'); + } +}