145 lines
4.6 KiB
Dart
145 lines
4.6 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:postgres/postgres.dart';
|
|
|
|
class ProfileDb {
|
|
ProfileDb(this._connection);
|
|
|
|
final Connection _connection;
|
|
|
|
static Future<ProfileDb> connect(String databaseUrl) async {
|
|
final Uri uri = Uri.parse(databaseUrl);
|
|
final Connection connection = await Connection.open(
|
|
Endpoint(
|
|
host: uri.host.isEmpty ? 'localhost' : uri.host,
|
|
port: uri.hasPort ? uri.port : 5432,
|
|
database: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : 'postgres',
|
|
username: uri.userInfo.isNotEmpty ? uri.userInfo.split(':').first : null,
|
|
password: uri.userInfo.contains(':')
|
|
? uri.userInfo.split(':').skip(1).join(':')
|
|
: null,
|
|
),
|
|
settings: const ConnectionSettings(sslMode: SslMode.disable),
|
|
);
|
|
return ProfileDb(connection);
|
|
}
|
|
|
|
Connection get connection => _connection;
|
|
|
|
Future<void> migrate() async {
|
|
final List<File> files = Directory('migrations')
|
|
.listSync()
|
|
.whereType<File>()
|
|
.where((File f) => f.path.endsWith('.sql'))
|
|
.toList()
|
|
..sort((File a, File b) => a.path.compareTo(b.path));
|
|
|
|
for (final File file in files) {
|
|
final String sql = await file.readAsString();
|
|
final List<String> statements = sql
|
|
.split(';')
|
|
.map((String s) => s.trim())
|
|
.where((String s) => s.isNotEmpty)
|
|
.toList();
|
|
|
|
for (final String statement in statements) {
|
|
await _connection.execute(statement);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> getProfile(String firebaseUid) async {
|
|
final Result result = await _connection.execute(
|
|
Sql.named(
|
|
'SELECT firebase_uid, email, display_name, photo_url, locale, timezone, '
|
|
'onboarding_done, revision, updated_at '
|
|
'FROM users WHERE firebase_uid = @uid',
|
|
),
|
|
parameters: <String, dynamic>{'uid': firebaseUid},
|
|
);
|
|
if (result.isEmpty) {
|
|
return null;
|
|
}
|
|
final ResultRow row = result.first;
|
|
return _rowToJson(row);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> upsertProfile({
|
|
required String firebaseUid,
|
|
required Map<String, dynamic> body,
|
|
required int clientRevision,
|
|
}) async {
|
|
final Result existing = await _connection.execute(
|
|
Sql.named('SELECT revision FROM users WHERE firebase_uid = @uid'),
|
|
parameters: <String, dynamic>{'uid': firebaseUid},
|
|
);
|
|
|
|
if (existing.isNotEmpty) {
|
|
final int serverRevision = (existing.first[0]! as num).toInt();
|
|
if (clientRevision < serverRevision) {
|
|
final Map<String, dynamic>? current = await getProfile(firebaseUid);
|
|
throw StaleRevisionException(current!);
|
|
}
|
|
}
|
|
|
|
await _connection.execute(
|
|
Sql.named(
|
|
'''
|
|
INSERT INTO users (
|
|
firebase_uid, email, display_name, photo_url, locale, timezone,
|
|
onboarding_done, revision, updated_at
|
|
) VALUES (
|
|
@uid, @email, @display_name, @photo_url, @locale, @timezone,
|
|
@onboarding_done, @revision, @updated_at
|
|
)
|
|
ON CONFLICT (firebase_uid) DO UPDATE SET
|
|
email = EXCLUDED.email,
|
|
display_name = EXCLUDED.display_name,
|
|
photo_url = EXCLUDED.photo_url,
|
|
locale = EXCLUDED.locale,
|
|
timezone = EXCLUDED.timezone,
|
|
onboarding_done = EXCLUDED.onboarding_done,
|
|
revision = EXCLUDED.revision,
|
|
updated_at = EXCLUDED.updated_at
|
|
''',
|
|
),
|
|
parameters: <String, dynamic>{
|
|
'uid': firebaseUid,
|
|
'email': body['email'],
|
|
'display_name': body['displayName'],
|
|
'photo_url': body['photoUrl'],
|
|
'locale': body['locale'] ?? 'en',
|
|
'timezone': body['timezone'],
|
|
'onboarding_done': body['onboardingCompleted'] ?? false,
|
|
'revision': clientRevision,
|
|
'updated_at': DateTime.parse(body['updatedAt'] as String).toUtc(),
|
|
},
|
|
);
|
|
|
|
return (await getProfile(firebaseUid))!;
|
|
}
|
|
|
|
Future<void> close() => _connection.close();
|
|
|
|
Map<String, dynamic> _rowToJson(ResultRow row) {
|
|
final DateTime updatedAt = row[8]! as DateTime;
|
|
return <String, dynamic>{
|
|
'firebaseUid': row[0]! as String,
|
|
'email': row[1] as String?,
|
|
'displayName': row[2] as String?,
|
|
'photoUrl': row[3] as String?,
|
|
'locale': row[4]! as String,
|
|
'timezone': row[5] as String?,
|
|
'onboardingCompleted': row[6]! as bool,
|
|
'revision': (row[7]! as num).toInt(),
|
|
'updatedAt': updatedAt.toUtc().toIso8601String(),
|
|
};
|
|
}
|
|
}
|
|
|
|
class StaleRevisionException implements Exception {
|
|
StaleRevisionException(this.serverProfile);
|
|
|
|
final Map<String, dynamic> serverProfile;
|
|
}
|