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
|
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:
|
||||||
|
|||||||
+3601
File diff suppressed because it is too large
Load Diff
@@ -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()();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user