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:
Santo Shakil
2026-09-18 17:41:15 +00:00
committed by GitHub
parent 747af325d9
commit 909b8aac93
24 changed files with 3817 additions and 52 deletions
+2
View File
@@ -36,6 +36,7 @@ linter:
close_sinks: true close_sinks: true
collection_methods_unrelated_type: true collection_methods_unrelated_type: true
deprecated_consistency: true deprecated_consistency: true
deprecated_member_use_from_same_package: true
discarded_futures: true discarded_futures: true
no_adjacent_strings_in_list: true no_adjacent_strings_in_list: true
no_self_assignments: true no_self_assignments: true
@@ -100,6 +101,7 @@ analyzer:
errors: errors:
unawaited_futures: warning unawaited_futures: warning
always_put_control_body_on_new_line: warning always_put_control_body_on_new_line: warning
deprecated_member_use_from_same_package: error
dart_code_metrics: dart_code_metrics:
rules: rules:
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -1,18 +1,26 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/logger/table/log.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; import 'package:immich_mobile/domain/models/log.model.dart' as domain;
class LogMessageEntity extends Table { class LogMessageEntity extends Table with DriftDefaultsMixin {
const LogMessageEntity(); const LogMessageEntity();
@override @override
String get tableName => 'logger_messages'; String get tableName => 'logger_messages';
@override
bool get isStrict => false;
@override
bool get withoutRowId => false;
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
TextColumn get message => text()(); TextColumn get message => text()();
TextColumn get details => text().nullable()(); TextColumn get details => text().nullable()();
IntColumn get level => intEnum<domain.LogLevel>()(); IntColumn get level => intEnum<domain.LogLevel>()();
DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get createdAt => customType(clampedDateTime)();
TextColumn get logger => text().nullable()(); TextColumn get logger => text().nullable()();
TextColumn get stack => text().nullable()(); TextColumn get stack => text().nullable()();
} }
+41 -1
View File
@@ -159,7 +159,7 @@ class Drift extends $Drift {
} }
@override @override
int get schemaVersion => 33; int get schemaVersion => 34;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -364,6 +364,9 @@ class Drift extends $Drift {
// Removed foreign key constraints on asset_face.assetId and asset_face.personId // Removed foreign key constraints on asset_face.assetId and asset_face.personId
await m.alterTable(TableMigration(v33.assetFaceEntity)); 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 // ignore: invalid_use_of_internal_member
final class _DriftPoolStreamQueries extends StreamQueryStore { final class _DriftPoolStreamQueries extends StreamQueryStore {
_DriftPoolStreamQueries(this._pool); _DriftPoolStreamQueries(this._pool);
@@ -1,4 +1,5 @@
import 'package:drift/drift.dart'; 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/data/db/util/defaults_mixin.dart';
class SettingsEntity extends Table with DriftDefaultsMixin { class SettingsEntity extends Table with DriftDefaultsMixin {
@@ -8,7 +9,7 @@ class SettingsEntity extends Table with DriftDefaultsMixin {
TextColumn get value => text().nullable()(); TextColumn get value => text().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
@override @override
Set<Column> get primaryKey => {key}; Set<Column> get primaryKey => {key};
@@ -1,6 +1,7 @@
import 'package:drift/drift.dart'; 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/local/album.drift.dart';
import 'package:immich_mobile/data/db/main/table/remote/album.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/data/db/util/defaults_mixin.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.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 id => text()();
TextColumn get name => text()(); TextColumn get name => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
IntColumn get backupSelection => intEnum<BackupSelection>()(); IntColumn get backupSelection => intEnum<BackupSelection>()();
BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))(); BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))();
@@ -1,6 +1,7 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/main/table/local/asset.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/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/data/db/util/defaults_mixin.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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()(); TextColumn get iCloudId => text().nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()(); DateTimeColumn get adjustmentTime => customType(clampedDateTime).nullable()();
RealColumn get latitude => real().nullable()(); RealColumn get latitude => real().nullable()();
@@ -1,5 +1,6 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/main/table/user/user.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/data/db/util/defaults_mixin.dart';
import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart';
@@ -8,11 +9,11 @@ class MemoryEntity extends Table with DriftDefaultsMixin {
TextColumn get id => text()(); 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)(); 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))(); 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 @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
@@ -1,4 +1,5 @@
import 'package:drift/drift.dart'; 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/data/db/util/defaults_mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)') @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))(); BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
DateTimeColumn get deletedAt => dateTime().nullable()(); DateTimeColumn get deletedAt => customType(clampedDateTime).nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
@@ -1,5 +1,6 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/main/table/user/user.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/data/db/util/defaults_mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)') @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()(); 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)(); 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()(); TextColumn get color => text().nullable()();
DateTimeColumn get birthDate => dateTime().nullable()(); DateTimeColumn get birthDate => customType(clampedDateTime).nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
@@ -1,5 +1,6 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/main/table/remote/asset.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/data/db/util/defaults_mixin.dart';
import 'package:immich_mobile/domain/models/album/album.model.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(''))(); 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 => TextColumn get thumbnailAssetId =>
text().references(RemoteAssetEntity, #id, onDelete: KeyAction.setNull).nullable()(); 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/remote/asset.drift.dart';
import 'package:immich_mobile/data/db/main/table/user/user.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/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/data/db/util/defaults_mixin.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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)(); 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()(); 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()(); TextColumn get livePhotoVideoId => text().nullable()();
@@ -1,5 +1,6 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/main/table/remote/asset.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/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)') @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()(); 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()(); RealColumn get latitude => real().nullable()();
@@ -1,6 +1,7 @@
import 'package:drift/drift.dart' hide Query; 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/asset.dart';
import 'package:immich_mobile/data/db/main/table/remote/exif.drift.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/data/db/util/defaults_mixin.dart';
import 'package:immich_mobile/domain/models/exif.model.dart' as domain; import 'package:immich_mobile/domain/models/exif.model.dart' as domain;
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
@@ -21,7 +22,7 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin {
TextColumn get country => text().nullable()(); TextColumn get country => text().nullable()();
DateTimeColumn get dateTimeOriginal => dateTime().nullable()(); DateTimeColumn get dateTimeOriginal => customType(clampedDateTime).nullable()();
TextColumn get description => text().nullable()(); TextColumn get description => text().nullable()();
@@ -1,5 +1,6 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/data/db/main/table/user/user.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/data/db/util/defaults_mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)') @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()(); 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)(); TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
@@ -1,4 +1,5 @@
import 'package:drift/drift.dart'; 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/data/db/util/defaults_mixin.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
@@ -12,7 +13,7 @@ class AuthUserEntity extends Table with DriftDefaultsMixin {
// Profile image // Profile image
BoolColumn get hasProfileImage => boolean().withDefault(const Constant(false))(); 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>()(); IntColumn get avatarColor => intEnum<AvatarColor>()();
// Quota // Quota
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:drift/drift.dart' hide Index; 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/data/db/util/defaults_mixin.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
@@ -11,7 +12,7 @@ class UserEntity extends Table with DriftDefaultsMixin {
// Profile image // Profile image
BoolColumn get hasProfileImage => boolean().withDefault(const Constant(false))(); 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))(); IntColumn get avatarColor => intEnum<AvatarColor>().withDefault(const Constant(0))();
@override @override
+5 -3
View File
@@ -1,11 +1,13 @@
import 'package:drift/drift.dart'; 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'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
mixin AssetEntityMixin on Table { mixin AssetEntityMixin on DriftDefaultsMixin {
TextColumn get name => text()(); TextColumn get name => text()();
IntColumn get type => intEnum<AssetType>()(); IntColumn get type => intEnum<AssetType>()();
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)();
IntColumn get width => integer().nullable()(); IntColumn get width => integer().nullable()();
IntColumn get height => integer().nullable()(); IntColumn get height => integer().nullable()();
IntColumn get durationMs => 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'; import 'package:drift/drift.dart';
mixin DriftDefaultsMixin on Table { mixin DriftDefaultsMixin on Table {
@Deprecated('Use customType(clampedDateTime)')
@override
ColumnBuilder<DateTime> dateTime() => super.dateTime();
@override @override
bool get isStrict => true; bool get isStrict => true;
-7
View File
@@ -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}) { DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch, {bool isUtc = false}) {
if (secondsSinceEpoch == null) { if (secondsSinceEpoch == null) {
return null; return null;
} }
final milliSeconds = secondsSinceEpoch * 1000; final milliSeconds = secondsSinceEpoch * 1000;
if (milliSeconds < _minMillisecondsSinceEpoch || milliSeconds > _maxMillisecondsSinceEpoch) {
return null;
}
try { try {
return DateTime.fromMillisecondsSinceEpoch(milliSeconds, isUtc: isUtc); return DateTime.fromMillisecondsSinceEpoch(milliSeconds, isUtc: isUtc);
} catch (e) { } 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); 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', () { test('returns null for value above maximum allowed range', () {
// _maxMillisecondsSinceEpoch = 8640000000000000
const seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed const seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed
final result = tryFromSecondsSinceEpoch(seconds); final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull); expect(result, isNull);
@@ -122,29 +122,29 @@ void main() {
expect(Store.tryGet(StoreKey.version), 27); 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); final wrongDate = DateTime.utc(2026);
await Store.put(StoreKey.version, 26); await Store.put(StoreKey.version, 26);
await ctx.newLocalAsset(id: 'local', createdAt: wrongDate); await ctx.newLocalAsset(id: 'local', createdAt: wrongDate);
when( when(
() => nativeSyncApi.getAssetsForAlbum('album'), () => 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); await migrateDatabaseIfNeeded(ctx.db, nativeSyncApi, permissionApi);
final asset = await (ctx.db.select(ctx.db.localAssetEntity)..where((row) => row.id.equals('local'))).getSingle(); 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); expect(Store.tryGet(StoreKey.version), 27);
}); });
} }
class MockPermissionApi extends Mock implements PermissionApi {} 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, id: id,
name: '$id.jpg', name: '$id.jpg',
type: 1, type: 1,
createdAt: createdAtSeconds ?? createdAt.millisecondsSinceEpoch ~/ 1000, createdAt: createdAt.millisecondsSinceEpoch ~/ 1000,
updatedAt: (updatedAt ?? createdAt).millisecondsSinceEpoch ~/ 1000, updatedAt: (updatedAt ?? createdAt).millisecondsSinceEpoch ~/ 1000,
durationMs: 0, durationMs: 0,
orientation: 0, orientation: 0,