Merge pull request 'Konsistenzfehler im JSON Vermeiden' (#125) from enhancement/70-konsistenzfehler-im-json-vermeiden into development

Reviewed-on: #125
Reviewed-by: Felix Kirchner <felix.kirchner.fk@gmail.com>
This commit was merged in pull request #125.
This commit is contained in:
2026-01-07 11:27:44 +00:00
5 changed files with 192 additions and 204 deletions

View File

@@ -2,178 +2,103 @@
"$schema": "http://json-schema.org/draft-04/schema#", "$schema": "http://json-schema.org/draft-04/schema#",
"type": "object", "type": "object",
"properties": { "properties": {
"games": { "players": {
"type": "array", "type": "array",
"items": [ "items": {
{ "type": "object",
"type": "object", "properties": {
"properties": { "id": {
"id": { "type": "string"
"type": "string"
},
"createdAt": {
"type": "string"
},
"name": {
"type": "string"
},
"players": {
"type": [
"array",
"null"
],
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"name"
]
}
}, },
"group": { "createdAt": {
"type": [ "type": "string"
"object",
"null"
],
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"name": {
"type": "string"
},
"members": {
"type": "array",
"items": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"name"
]
}
]
}
},
"required": [
"id",
"createdAt",
"name",
"members"
]
}, },
"winner": { "name": {
"type": ["object","null"] "type": "string"
}, }
"required": [ },
"id", "required": [
"createdAt", "id",
"name" "createdAt",
] "name"
} ]
] }
}, },
"groups": { "groups": {
"type": "array", "type": "array",
"items": [ "items": {
{ "type": "object",
"type": "object", "properties": {
"properties": { "id": {
"id": { "type": "string"
"type": "string"
},
"createdAt": {
"type": "string"
},
"name": {
"type": "string"
},
"members": {
"type": "array",
"items": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"name"
]
}
]
}
}, },
"required": [ "name": {
"id", "type": "string"
"createdAt", },
"name", "createdAt": {
"members" "type": "string"
] },
} "memberIds": {
] "type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"id",
"name",
"createdAt",
"memberIds"
]
}
}, },
"players": { "matches": {
"type": "array", "type": "array",
"items": [ "items": {
{ "type": "object",
"type": [ "properties": {
"object", "id": {
"null" "type": "string"
], },
"properties": { "name": {
"id": { "type": "string"
"type": "string" },
}, "createdAt": {
"createdAt": { "type": "string"
"type": "string" },
}, "groupId": {
"name": { "anyOf": [
{"type": "string"},
{"type": "null"}
]
},
"playerIds": {
"type": "array",
"items": {
"type": "string" "type": "string"
} }
}, },
"required": [ "winnerId": {
"id", "anyOf": [
"createdAt", {"type": "string"},
"name" {"type": "null"}
] ]
} }
] },
"required": [
"id",
"name",
"createdAt",
"groupId",
"playerIds"
]
}
} }
} },
} "required": [
"players",
"groups",
"matches"
]
}

View File

@@ -95,6 +95,8 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
} }
// Insert unique groups in batch // Insert unique groups in batch
// Using insertOrIgnore to avoid triggering cascade deletes on
// player_group associations when groups already exist
await db.batch( await db.batch(
(b) => b.insertAll( (b) => b.insertAll(
groupTable, groupTable,
@@ -107,7 +109,7 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
), ),
) )
.toList(), .toList(),
mode: InsertMode.insertOrReplace, mode: InsertMode.insertOrIgnore,
), ),
); );
@@ -120,6 +122,8 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
} }
if (uniquePlayers.isNotEmpty) { if (uniquePlayers.isNotEmpty) {
// Using insertOrIgnore to avoid triggering cascade deletes on
// player_group associations when players already exist
await db.batch( await db.batch(
(b) => b.insertAll( (b) => b.insertAll(
db.playerTable, db.playerTable,
@@ -132,7 +136,7 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
), ),
) )
.toList(), .toList(),
mode: InsertMode.insertOrReplace, mode: InsertMode.insertOrIgnore,
), ),
); );
} }

View File

@@ -124,6 +124,8 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
); );
// Add all groups of the matches in batch // Add all groups of the matches in batch
// Using insertOrIgnore to avoid overwriting existing groups (which would
// trigger cascade deletes on player_group associations)
await db.batch( await db.batch(
(b) => b.insertAll( (b) => b.insertAll(
db.groupTable, db.groupTable,
@@ -137,7 +139,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
), ),
) )
.toList(), .toList(),
mode: InsertMode.insertOrReplace, mode: InsertMode.insertOrIgnore,
), ),
); );
@@ -158,6 +160,8 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
} }
if (uniquePlayers.isNotEmpty) { if (uniquePlayers.isNotEmpty) {
// Using insertOrIgnore to avoid triggering cascade deletes on
// player_group/player_match associations when players already exist
await db.batch( await db.batch(
(b) => b.insertAll( (b) => b.insertAll(
db.playerTable, db.playerTable,
@@ -170,7 +174,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
), ),
) )
.toList(), .toList(),
mode: InsertMode.insertOrReplace, mode: InsertMode.insertOrIgnore,
), ),
); );
} }

View File

@@ -50,6 +50,8 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
} }
/// Adds multiple [players] to the database in a batch operation. /// Adds multiple [players] to the database in a batch operation.
/// Uses insertOrIgnore to avoid triggering cascade deletes on
/// player_group associations when players already exist.
Future<bool> addPlayersAsList({required List<Player> players}) async { Future<bool> addPlayersAsList({required List<Player> players}) async {
if (players.isEmpty) return false; if (players.isEmpty) return false;
@@ -65,7 +67,7 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
), ),
) )
.toList(), .toList(),
mode: InsertMode.insertOrReplace, mode: InsertMode.insertOrIgnore,
), ),
); );

View File

@@ -31,9 +31,25 @@ class DataTransferService {
// Construct a JSON representation of the data // Construct a JSON representation of the data
final Map<String, dynamic> jsonMap = { final Map<String, dynamic> jsonMap = {
'matches': matches.map((match) => match.toJson()).toList(), 'players': players.map((p) => p.toJson()).toList(),
'groups': groups.map((group) => group.toJson()).toList(),
'players': players.map((player) => player.toJson()).toList(), 'groups': groups
.map((g) => {
'id': g.id,
'name': g.name,
'createdAt': g.createdAt.toIso8601String(),
'memberIds': (g.members).map((m) => m.id).toList(),
}).toList(),
'matches': matches
.map((m) => {
'id': m.id,
'name': m.name,
'createdAt': m.createdAt.toIso8601String(),
'groupId': m.group?.id,
'playerIds': (m.players ?? []).map((p) => p.id).toList(),
'winnerId': m.winner?.id,
}).toList(),
}; };
return json.encode(jsonMap); return json.encode(jsonMap);
@@ -46,7 +62,7 @@ class DataTransferService {
/// [fileName] The desired name for the exported file (without extension). /// [fileName] The desired name for the exported file (without extension).
static Future<ExportResult> exportData( static Future<ExportResult> exportData(
String jsonString, String jsonString,
String fileName, String fileName
) async { ) async {
try { try {
final bytes = Uint8List.fromList(utf8.encode(jsonString)); final bytes = Uint8List.fromList(utf8.encode(jsonString));
@@ -54,11 +70,13 @@ class DataTransferService {
fileName: '$fileName.json', fileName: '$fileName.json',
bytes: bytes, bytes: bytes,
); );
if (path == null) { if (path == null) {
return ExportResult.canceled; return ExportResult.canceled;
} else { } else {
return ExportResult.success; return ExportResult.success;
} }
} catch (e, stack) { } catch (e, stack) {
print('[exportData] $e'); print('[exportData] $e');
print(stack); print(stack);
@@ -81,42 +99,77 @@ class DataTransferService {
try { try {
final jsonString = await _readFileContent(path.files.single); final jsonString = await _readFileContent(path.files.single);
if (jsonString == null) { if (jsonString == null) return ImportResult.fileReadError;
return ImportResult.fileReadError;
}
if (await _validateJsonSchema(jsonString)) { final isValid = await _validateJsonSchema(jsonString);
final Map<String, dynamic> jsonData = if (!isValid) return ImportResult.invalidSchema;
json.decode(jsonString) as Map<String, dynamic>;
final List<dynamic>? matchesJson = final Map<String, dynamic> decoded = json.decode(jsonString) as Map<String, dynamic>;
jsonData['matches'] as List<dynamic>?;
final List<dynamic>? groupsJson = jsonData['groups'] as List<dynamic>?;
final List<dynamic>? playersJson =
jsonData['players'] as List<dynamic>?;
final List<Match> importedMatches = final List<dynamic> playersJson = (decoded['players'] as List<dynamic>?) ?? [];
matchesJson final List<dynamic> groupsJson = (decoded['groups'] as List<dynamic>?) ?? [];
?.map((g) => Match.fromJson(g as Map<String, dynamic>)) final List<dynamic> matchesJson = (decoded['matches'] as List<dynamic>?) ?? [];
.toList() ??
[]; // Players
final List<Group> importedGroups = final List<Player> importedPlayers = playersJson
groupsJson .map((p) => Player.fromJson(p as Map<String, dynamic>))
?.map((g) => Group.fromJson(g as Map<String, dynamic>)) .toList();
.toList() ??
[]; final Map<String, Player> playerById = {
final List<Player> importedPlayers = for (final p in importedPlayers) p.id: p,
playersJson };
?.map((p) => Player.fromJson(p as Map<String, dynamic>))
.toList() ?? // Groups
[]; final List<Group> importedGroups = groupsJson.map((g) {
final map = g as Map<String, dynamic>;
final memberIds = (map['memberIds'] as List<dynamic>? ?? []).cast<String>();
final members = memberIds
.map((id) => playerById[id])
.whereType<Player>()
.toList();
return Group(
id: map['id'] as String,
name: map['name'] as String,
members: members,
createdAt: DateTime.parse(map['createdAt'] as String),
);
}).toList();
final Map<String, Group> groupById = {
for (final g in importedGroups) g.id: g,
};
// Matches
final List<Match> importedMatches = matchesJson.map((m) {
final map = m as Map<String, dynamic>;
final String? groupId = map['groupId'] as String?;
final List<String> playerIds = (map['playerIds'] as List<dynamic>? ?? []).cast<String>();
final String? winnerId = map['winnerId'] as String?;
final group = (groupId == null) ? null : groupById[groupId];
final players = playerIds
.map((id) => playerById[id])
.whereType<Player>()
.toList();
final winner = (winnerId == null) ? null : playerById[winnerId];
return Match(
id: map['id'] as String,
name: map['name'] as String,
group: group,
players: players,
createdAt: DateTime.parse(map['createdAt'] as String),
winner: winner,
);
}).toList();
await db.playerDao.addPlayersAsList(players: importedPlayers);
await db.groupDao.addGroupsAsList(groups: importedGroups);
await db.matchDao.addMatchAsList(matches: importedMatches);
await db.playerDao.addPlayersAsList(players: importedPlayers);
await db.groupDao.addGroupsAsList(groups: importedGroups);
await db.matchDao.addMatchAsList(matches: importedMatches);
} else {
return ImportResult.invalidSchema;
}
return ImportResult.success; return ImportResult.success;
} on FormatException catch (e, stack) { } on FormatException catch (e, stack) {
print('[importData] FormatException'); print('[importData] FormatException');
@@ -159,4 +212,4 @@ class DataTransferService {
return false; return false;
} }
} }
} }