fix(mobile): clamp out of range datetimes so timeline queries cannot crash (#30490)
* fix(mobile): clamp out of range datetimes so timeline queries cannot crash * replace the per column converters with a clamping custom type * heal each table in one statement, rename the clamp type, read dates as stored * one medium test for the clamp, drop the dart side bounds, private clamp type
This commit is contained in:
@@ -36,6 +36,7 @@ linter:
|
||||
close_sinks: true
|
||||
collection_methods_unrelated_type: true
|
||||
deprecated_consistency: true
|
||||
deprecated_member_use_from_same_package: true
|
||||
discarded_futures: true
|
||||
no_adjacent_strings_in_list: true
|
||||
no_self_assignments: true
|
||||
@@ -100,6 +101,7 @@ analyzer:
|
||||
errors:
|
||||
unawaited_futures: warning
|
||||
always_put_control_body_on_new_line: warning
|
||||
deprecated_member_use_from_same_package: error
|
||||
|
||||
dart_code_metrics:
|
||||
rules:
|
||||
|
||||
+3601
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,26 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/logger/table/log.drift.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart' as domain;
|
||||
|
||||
class LogMessageEntity extends Table {
|
||||
class LogMessageEntity extends Table with DriftDefaultsMixin {
|
||||
const LogMessageEntity();
|
||||
|
||||
@override
|
||||
String get tableName => 'logger_messages';
|
||||
|
||||
@override
|
||||
bool get isStrict => false;
|
||||
|
||||
@override
|
||||
bool get withoutRowId => false;
|
||||
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get message => text()();
|
||||
TextColumn get details => text().nullable()();
|
||||
IntColumn get level => intEnum<domain.LogLevel>()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get createdAt => customType(clampedDateTime)();
|
||||
TextColumn get logger => text().nullable()();
|
||||
TextColumn get stack => text().nullable()();
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 33;
|
||||
int get schemaVersion => 34;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -364,6 +364,9 @@ class Drift extends $Drift {
|
||||
// Removed foreign key constraints on asset_face.assetId and asset_face.personId
|
||||
await m.alterTable(TableMigration(v33.assetFaceEntity));
|
||||
},
|
||||
from33To34: (m, v34) async {
|
||||
await _healV33DateTimes(this);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -390,6 +393,43 @@ class Drift extends $Drift {
|
||||
);
|
||||
}
|
||||
|
||||
// every datetime column of the v33 schema, hardcoded: the heal runs once at v33->v34,
|
||||
// so the set must not follow later schema changes
|
||||
const _v33DateTimeColumns = <String, List<String>>{
|
||||
'auth_user_entity': ['profile_changed_at'],
|
||||
'user_entity': ['profile_changed_at'],
|
||||
'local_album_entity': ['updated_at'],
|
||||
'local_asset_entity': ['created_at', 'updated_at', 'adjustment_time'],
|
||||
'remote_asset_entity': ['created_at', 'updated_at', 'local_date_time', 'deleted_at', 'uploaded_at'],
|
||||
'trashed_local_asset_entity': ['created_at', 'updated_at'],
|
||||
'remote_exif_entity': ['date_time_original'],
|
||||
'remote_album_entity': ['created_at', 'updated_at'],
|
||||
'remote_asset_cloud_id_entity': ['created_at', 'adjustment_time'],
|
||||
'memory_entity': ['created_at', 'updated_at', 'deleted_at', 'memory_at', 'seen_at', 'show_at', 'hide_at'],
|
||||
'stack_entity': ['created_at', 'updated_at'],
|
||||
'person_entity': ['created_at', 'updated_at', 'birth_date'],
|
||||
'asset_face_entity': ['deleted_at'],
|
||||
'settings': ['updated_at'],
|
||||
};
|
||||
|
||||
// Rewrites datetime text sqlite date functions cannot handle: signed extended
|
||||
// years and year 0000 (pre-clamp syncs), plus anything later than the safe
|
||||
// midnight ceiling, which re-overflows sqlite under 'localtime' east of UTC.
|
||||
// One statement per table: each column heals only when its own value is out of range
|
||||
Future<void> _healV33DateTimes(GeneratedDatabase db) async {
|
||||
const floor = '0001-01-01T00:00:00.000Z';
|
||||
const ceiling = '9999-12-31T00:00:00.000Z';
|
||||
for (final MapEntry(key: table, value: columns) in _v33DateTimeColumns.entries) {
|
||||
String low(String c) => "substr($c, 1, 1) = '-' OR substr($c, 1, 4) = '0000'";
|
||||
String high(String c) => "substr($c, 1, 1) = '+' OR $c > '$ceiling'";
|
||||
final assignments = columns.map(
|
||||
(c) => "$c = CASE WHEN ${low(c)} THEN '$floor' WHEN ${high(c)} THEN '$ceiling' ELSE $c END",
|
||||
);
|
||||
final outOfRange = columns.map((c) => '${low(c)} OR ${high(c)}');
|
||||
await db.customStatement('UPDATE $table SET ${assignments.join(', ')} WHERE ${outOfRange.join(' OR ')}');
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: invalid_use_of_internal_member
|
||||
final class _DriftPoolStreamQueries extends StreamQueryStore {
|
||||
_DriftPoolStreamQueries(this._pool);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
|
||||
class SettingsEntity extends Table with DriftDefaultsMixin {
|
||||
@@ -8,7 +9,7 @@ class SettingsEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get value => text().nullable()();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {key};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/local/album.drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/remote/album.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
|
||||
@@ -9,7 +10,7 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get name => text()();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
IntColumn get backupSelection => intEnum<BackupSelection>()();
|
||||
BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/local/asset.drift.dart';
|
||||
import 'package:immich_mobile/data/db/util/asset_mixin.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
@@ -20,7 +21,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
TextColumn get iCloudId => text().nullable()();
|
||||
|
||||
DateTimeColumn get adjustmentTime => dateTime().nullable()();
|
||||
DateTimeColumn get adjustmentTime => customType(clampedDateTime).nullable()();
|
||||
|
||||
RealColumn get latitude => real().nullable()();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/user/user.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
|
||||
@@ -8,11 +9,11 @@ class MemoryEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get deletedAt => dateTime().nullable()();
|
||||
DateTimeColumn get deletedAt => customType(clampedDateTime).nullable()();
|
||||
|
||||
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
@@ -22,13 +23,13 @@ class MemoryEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
BoolColumn get isSaved => boolean().withDefault(const Constant(false))();
|
||||
|
||||
DateTimeColumn get memoryAt => dateTime()();
|
||||
DateTimeColumn get memoryAt => customType(clampedDateTime)();
|
||||
|
||||
DateTimeColumn get seenAt => dateTime().nullable()();
|
||||
DateTimeColumn get seenAt => customType(clampedDateTime).nullable()();
|
||||
|
||||
DateTimeColumn get showAt => dateTime().nullable()();
|
||||
DateTimeColumn get showAt => customType(clampedDateTime).nullable()();
|
||||
|
||||
DateTimeColumn get hideAt => dateTime().nullable()();
|
||||
DateTimeColumn get hideAt => customType(clampedDateTime).nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)')
|
||||
@@ -33,7 +34,7 @@ class AssetFaceEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
|
||||
|
||||
DateTimeColumn get deletedAt => dateTime().nullable()();
|
||||
DateTimeColumn get deletedAt => customType(clampedDateTime).nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/user/user.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)')
|
||||
@@ -8,9 +9,9 @@ class PersonEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
@@ -24,7 +25,7 @@ class PersonEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get color => text().nullable()();
|
||||
|
||||
DateTimeColumn get birthDate => dateTime().nullable()();
|
||||
DateTimeColumn get birthDate => customType(clampedDateTime).nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/remote/asset.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
|
||||
@@ -12,9 +13,9 @@ class RemoteAlbumEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get description => text().withDefault(const Constant(''))();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
TextColumn get thumbnailAssetId =>
|
||||
text().references(RemoteAssetEntity, #id, onDelete: KeyAction.setNull).nullable()();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/remote/asset.drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/user/user.dart';
|
||||
import 'package:immich_mobile/data/db/util/asset_mixin.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
@@ -33,13 +34,13 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
|
||||
|
||||
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
DateTimeColumn get localDateTime => dateTime().nullable()();
|
||||
DateTimeColumn get localDateTime => customType(clampedDateTime).nullable()();
|
||||
|
||||
TextColumn get thumbHash => text().nullable()();
|
||||
|
||||
DateTimeColumn get deletedAt => dateTime().nullable()();
|
||||
DateTimeColumn get deletedAt => customType(clampedDateTime).nullable()();
|
||||
|
||||
DateTimeColumn get uploadedAt => dateTime().nullable()();
|
||||
DateTimeColumn get uploadedAt => customType(clampedDateTime).nullable()();
|
||||
|
||||
TextColumn get livePhotoVideoId => text().nullable()();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/remote/asset.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)')
|
||||
@@ -8,9 +9,9 @@ class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get cloudId => text().nullable()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().nullable()();
|
||||
DateTimeColumn get createdAt => customType(clampedDateTime).nullable()();
|
||||
|
||||
DateTimeColumn get adjustmentTime => dateTime().nullable()();
|
||||
DateTimeColumn get adjustmentTime => customType(clampedDateTime).nullable()();
|
||||
|
||||
RealColumn get latitude => real().nullable()();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:drift/drift.dart' hide Query;
|
||||
import 'package:immich_mobile/data/db/main/table/remote/asset.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/remote/exif.drift.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart' as domain;
|
||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||
@@ -21,7 +22,7 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get country => text().nullable()();
|
||||
|
||||
DateTimeColumn get dateTimeOriginal => dateTime().nullable()();
|
||||
DateTimeColumn get dateTimeOriginal => customType(clampedDateTime).nullable()();
|
||||
|
||||
TextColumn get description => text().nullable()();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/main/table/user/user.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)')
|
||||
@@ -8,9 +9,9 @@ class StackEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
|
||||
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
|
||||
@@ -12,7 +13,7 @@ class AuthUserEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
// Profile image
|
||||
BoolColumn get hasProfileImage => boolean().withDefault(const Constant(false))();
|
||||
DateTimeColumn get profileChangedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get profileChangedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
IntColumn get avatarColor => intEnum<AvatarColor>()();
|
||||
|
||||
// Quota
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:drift/drift.dart' hide Index;
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
|
||||
@@ -11,7 +12,7 @@ class UserEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
// Profile image
|
||||
BoolColumn get hasProfileImage => boolean().withDefault(const Constant(false))();
|
||||
DateTimeColumn get profileChangedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get profileChangedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
IntColumn get avatarColor => intEnum<AvatarColor>().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/data/db/util/datetime_clamp_type.dart';
|
||||
import 'package:immich_mobile/data/db/util/defaults_mixin.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
mixin AssetEntityMixin on Table {
|
||||
mixin AssetEntityMixin on DriftDefaultsMixin {
|
||||
TextColumn get name => text()();
|
||||
IntColumn get type => intEnum<AssetType>()();
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
|
||||
IntColumn get width => integer().nullable()();
|
||||
IntColumn get height => integer().nullable()();
|
||||
IntColumn get durationMs => integer().nullable()();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// A `TEXT` column designed to store a `DateTime`. Clamps to the same range given by SQLite's `DateTime`, that is, years [1...10000)
|
||||
const clampedDateTime = _DateTimeClampType();
|
||||
|
||||
final class _DateTimeClampType implements DialectAwareSqlType<DateTime> {
|
||||
const _DateTimeClampType();
|
||||
|
||||
@override
|
||||
DateTime read(SqlTypes types, Object fromSql) => types.read(DriftSqlType.dateTime, fromSql)!;
|
||||
|
||||
@override
|
||||
Object mapToSqlParameter(GenerationContext context, DateTime value) =>
|
||||
context.typeMapping.mapToSqlVariable(_clampDateTime(value))!;
|
||||
|
||||
@override
|
||||
String mapToSqlLiteral(GenerationContext context, DateTime value) =>
|
||||
context.typeMapping.mapToSqlLiteral(_clampDateTime(value));
|
||||
|
||||
@override
|
||||
String sqlTypeName(GenerationContext context) => DriftSqlType.dateTime.sqlTypeName(context);
|
||||
}
|
||||
|
||||
// utc so drift's text mapping never formats these with a historical local
|
||||
// offset (those carry seconds, which drift refuses to store). the ceiling
|
||||
// stays at midnight: a later time would overflow sqlite's year range again
|
||||
// when a query applies 'localtime' east of UTC
|
||||
final DateTime _floor = DateTime.utc(1);
|
||||
final DateTime _ceiling = DateTime.utc(9999, 12, 31);
|
||||
|
||||
DateTime _clampDateTime(DateTime value) {
|
||||
if (value.isBefore(_floor)) {
|
||||
return _floor;
|
||||
}
|
||||
if (value.isAfter(_ceiling)) {
|
||||
return _ceiling;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
mixin DriftDefaultsMixin on Table {
|
||||
@Deprecated('Use customType(clampedDateTime)')
|
||||
@override
|
||||
ColumnBuilder<DateTime> dateTime() => super.dateTime();
|
||||
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
|
||||
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
|
||||
|
||||
DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch, {bool isUtc = false}) {
|
||||
if (secondsSinceEpoch == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final milliSeconds = secondsSinceEpoch * 1000;
|
||||
if (milliSeconds < _minMillisecondsSinceEpoch || milliSeconds > _maxMillisecondsSinceEpoch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return DateTime.fromMillisecondsSinceEpoch(milliSeconds, isUtc: isUtc);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:drift_dev/api/migrations_native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/data/db/main/database.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
import '../../drift/main/generated/schema.dart';
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
|
||||
setUpAll(initializeDateFormatting);
|
||||
|
||||
setUp(() {
|
||||
ctx = MediumRepositoryContext();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
Future<DateTime> storedCreatedAt(DateTime createdAt) async {
|
||||
final asset = await ctx.newLocalAsset(createdAt: createdAt);
|
||||
final row = await (ctx.db.select(ctx.db.localAssetEntity)..where((t) => t.id.equals(asset.id))).getSingle();
|
||||
return row.createdAt;
|
||||
}
|
||||
|
||||
test('clamps a date before year 1 to the floor', () async {
|
||||
expect(await storedCreatedAt(DateTime.utc(-4712, 3, 4)), DateTime.utc(1));
|
||||
});
|
||||
|
||||
test('clamps a date after year 9999 to the ceiling', () async {
|
||||
// the reporter's date from #28524
|
||||
expect(await storedCreatedAt(DateTime.utc(144769, 11, 18, 12, 38, 32)), DateTime.utc(9999, 12, 31));
|
||||
});
|
||||
|
||||
test('stores a late hour on the last day at the midnight ceiling', () async {
|
||||
expect(await storedCreatedAt(DateTime.utc(9999, 12, 31, 23, 59, 59)), DateTime.utc(9999, 12, 31));
|
||||
});
|
||||
|
||||
test('stores a date in range unchanged', () async {
|
||||
final date = DateTime.utc(2024, 1, 2, 3, 4, 5, 123);
|
||||
expect(await storedCreatedAt(date), date);
|
||||
});
|
||||
|
||||
test('v34 clamps dates already stored out of range', () async {
|
||||
final verifier = SchemaVerifier(GeneratedHelper());
|
||||
final schema = await verifier.schemaAt(33);
|
||||
schema.rawDatabase.execute(
|
||||
"INSERT INTO local_asset_entity (id, name, type, created_at, updated_at) "
|
||||
"VALUES ('a', 'a.jpg', 0, '+144769-11-18T12:38:32.000Z', '-4712-03-04T05:06:07.000Z')",
|
||||
);
|
||||
final db = Drift(schema.newConnection());
|
||||
addTearDown(db.close);
|
||||
await verifier.migrateAndValidate(db, 34);
|
||||
|
||||
final row = await (db.select(db.localAssetEntity)..where((t) => t.id.equals('a'))).getSingle();
|
||||
expect(row.createdAt, DateTime.utc(9999, 12, 31));
|
||||
expect(row.updatedAt, DateTime.utc(1));
|
||||
});
|
||||
|
||||
test('timeline buckets a clamped date instead of crashing', () async {
|
||||
final user = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(ownerId: user.id, createdAt: DateTime.utc(144769, 11, 18, 12, 38, 32));
|
||||
|
||||
final buckets = await TimelineRepository(ctx.db).main([user.id], .day).bucketSource().first;
|
||||
expect(buckets, [TimeBucket(date: DateTime(9999, 12, 31), assetCount: 1)]);
|
||||
});
|
||||
}
|
||||
@@ -8,15 +8,7 @@ void main() {
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('returns null for value below minimum allowed range', () {
|
||||
// _minMillisecondsSinceEpoch = -62135596800000
|
||||
const seconds = -62135596800000 ~/ 1000 - 1; // One second before min allowed
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('returns null for value above maximum allowed range', () {
|
||||
// _maxMillisecondsSinceEpoch = 8640000000000000
|
||||
const seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, isNull);
|
||||
|
||||
@@ -122,29 +122,29 @@ void main() {
|
||||
expect(Store.tryGet(StoreKey.version), 27);
|
||||
});
|
||||
|
||||
test('skips out-of-range date and completes migration', () async {
|
||||
test('clamps out-of-range date and completes migration', () async {
|
||||
final wrongDate = DateTime.utc(2026);
|
||||
await Store.put(StoreKey.version, 26);
|
||||
await ctx.newLocalAsset(id: 'local', createdAt: wrongDate);
|
||||
when(
|
||||
() => nativeSyncApi.getAssetsForAlbum('album'),
|
||||
).thenAnswer((_) async => [_asset('local', wrongDate, createdAtSeconds: 8640000000001)]);
|
||||
).thenAnswer((_) async => [_asset('local', DateTime.utc(144769, 11, 18, 12, 38, 32))]);
|
||||
|
||||
await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi);
|
||||
|
||||
final asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle();
|
||||
expect(asset.createdAt, wrongDate);
|
||||
expect(asset.createdAt, DateTime.utc(9999, 12, 31));
|
||||
expect(Store.tryGet(StoreKey.version), 27);
|
||||
});
|
||||
}
|
||||
|
||||
class MockPermissionApi extends Mock implements PermissionApi {}
|
||||
|
||||
PlatformAsset _asset(String id, DateTime createdAt, {DateTime? updatedAt, int? createdAtSeconds}) => PlatformAsset(
|
||||
PlatformAsset _asset(String id, DateTime createdAt, {DateTime? updatedAt}) => PlatformAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
type: 1,
|
||||
createdAt: createdAtSeconds ?? createdAt.millisecondsSinceEpoch ~/ 1000,
|
||||
createdAt: createdAt.millisecondsSinceEpoch ~/ 1000,
|
||||
updatedAt: (updatedAt ?? createdAt).millisecondsSinceEpoch ~/ 1000,
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
|
||||
Reference in New Issue
Block a user