WIP: database增加读取功能

单元测试没过
This commit is contained in:
2026-05-22 20:41:43 +08:00
parent 6d65bb6b62
commit d705d47b34
3 changed files with 439 additions and 6 deletions
+221 -1
View File
@@ -66,7 +66,7 @@ public class DatabaseTests
{ {
// Act — 连续调用两次 // Act — 连续调用两次
Database.InitializeDatabase(dbName); Database.InitializeDatabase(dbName);
var exception = Record.Exception(() => Database.InitializeDatabase(dbName)); Exception? exception = Record.Exception(() => Database.InitializeDatabase(dbName));
// Assert — 不应抛出异常 // Assert — 不应抛出异常
Assert.Null(exception); Assert.Null(exception);
@@ -83,4 +83,224 @@ public class DatabaseTests
if (File.Exists(dbPath)) File.Delete(dbPath); if (File.Exists(dbPath)) File.Delete(dbPath);
} }
} }
#region
private static AudioFileMeta CreateSampleMeta(int id = 0)
{
var guid = Guid.NewGuid().ToString("N");
return new AudioFileMeta
{
Id = id,
UniqueId = guid,
ShortId = "TEST",
Md5 = guid[..16],
Path = $"/test/path/{guid}.wav",
Filename = $"{guid}.wav",
Folder = "test",
Directory = "/test/path",
Duration = 2.5,
BitDepth = 24,
Channels = 2,
SampleRate = 48000,
Type = "WAV",
DateAdded = DateTime.UtcNow,
LastWriteTime = DateTime.UtcNow,
CreationTime = DateTime.UtcNow,
Description = "Test explosion sound",
Category = "SFX",
Keywords = "test,boom,explosion",
FxName = "Test Explosion Boom"
};
}
private static string GetDefaultDbPath() =>
Path.Combine(PreferencesManager.GetDefaultDatabasePath(), "default.rdb");
private static void DeleteTestEntries(params string[] uniqueIds)
{
using var conn = new SQLiteConnection($"Data Source={GetDefaultDbPath()};Version=3;");
conn.Open();
foreach (var uid in uniqueIds)
conn.Execute("DELETE FROM audio_files WHERE unique_id = @UniqueId", new { UniqueId = uid });
}
[Fact]
public void AddEntry_Then_GetEntryById_ShouldReturnRecord()
{
Database.InitializeDatabase();
var meta = CreateSampleMeta();
Assert.True(Database.AddEntry(meta));
try
{
var retrieved = Database.GetEntryById(meta.Id);
Assert.NotNull(retrieved);
Assert.Equal(meta.UniqueId, retrieved!.UniqueId);
Assert.Equal(meta.Filename, retrieved.Filename);
Assert.Equal(meta.Duration, retrieved.Duration);
}
finally
{
DeleteTestEntries(meta.UniqueId);
}
}
[Fact]
public void GetEntryById_NotFound_ShouldReturnNull()
{
Database.InitializeDatabase();
var result = Database.GetEntryById(int.MaxValue);
Assert.Null(result);
}
[Fact]
public void AddEntry_Then_GetEntryByUniqueId_ShouldReturnRecord()
{
Database.InitializeDatabase();
var meta = CreateSampleMeta();
Database.AddEntry(meta);
try
{
var retrieved = Database.GetEntryByUniqueId(meta.UniqueId);
Assert.NotNull(retrieved);
Assert.Equal(meta.Md5, retrieved!.Md5);
}
finally
{
DeleteTestEntries(meta.UniqueId);
}
}
[Fact]
public void GetEntryByUniqueId_NotFound_ShouldReturnNull()
{
Database.InitializeDatabase();
var result = Database.GetEntryByUniqueId("nonexistent-guid");
Assert.Null(result);
}
[Fact]
public void QueryEntries_ShouldFilterBySearchText()
{
Database.InitializeDatabase();
var meta = CreateSampleMeta();
meta.Description = "Unique underwater ambience loop";
meta.Keywords = "water,ocean,deep";
Database.AddEntry(meta);
try
{
var results = Database.QueryEntries(searchText: "underwater").ToList();
Assert.Single(results);
Assert.Equal(meta.UniqueId, results[0].UniqueId);
var empty = Database.QueryEntries(searchText: "zzz_nonexistent_zzz").ToList();
Assert.Empty(empty);
}
finally
{
DeleteTestEntries(meta.UniqueId);
}
}
[Fact]
public void QueryEntries_ShouldFilterByDuration()
{
Database.InitializeDatabase();
var shortMeta = CreateSampleMeta();
shortMeta.Duration = 1.0;
var longMeta = CreateSampleMeta();
longMeta.Duration = 10.0;
Database.AddEntries(new[] { shortMeta, longMeta });
try
{
var results = Database.QueryEntries(minDuration: 5.0).ToList();
Assert.Single(results);
Assert.Equal(longMeta.UniqueId, results[0].UniqueId);
}
finally
{
DeleteTestEntries(shortMeta.UniqueId, longMeta.UniqueId);
}
}
[Fact]
public void QueryEntries_ShouldSupportPagination()
{
Database.InitializeDatabase();
var entries = Enumerable.Range(0, 5).Select(_ => CreateSampleMeta()).ToList();
Database.AddEntries(entries);
try
{
var page = Database.QueryEntries(limit: 2, offset: 1).ToList();
Assert.Equal(2, page.Count);
}
finally
{
DeleteTestEntries(entries.Select(e => e.UniqueId).ToArray());
}
}
[Fact]
public void SearchEntries_ShouldFindByKeyword()
{
Database.InitializeDatabase();
var meta = CreateSampleMeta();
meta.Keywords = "magic,spell,fireball";
Database.AddEntry(meta);
try
{
var results = Database.SearchEntries("fireball").ToList();
Assert.Single(results);
}
finally
{
DeleteTestEntries(meta.UniqueId);
}
}
[Fact]
public void GetTotalCount_ShouldRespectSearchFilter()
{
Database.InitializeDatabase();
var meta = CreateSampleMeta();
var uniqueTag = $"UNIQUE_TAG_{meta.UniqueId[..8]}";
meta.Description = uniqueTag;
Database.AddEntry(meta);
try
{
var count = Database.GetTotalCount(searchText: uniqueTag);
Assert.Equal(1, count);
}
finally
{
DeleteTestEntries(meta.UniqueId);
}
}
[Fact]
public void AddEntries_ShouldReturnCorrectCount()
{
Database.InitializeDatabase();
var entries = Enumerable.Range(0, 3).Select(_ => CreateSampleMeta()).ToList();
try
{
var count = Database.AddEntries(entries);
Assert.Equal(3, count);
}
finally
{
DeleteTestEntries(entries.Select(e => e.UniqueId).ToArray());
}
}
#endregion
} }
+216 -5
View File
@@ -6,6 +6,10 @@ namespace OCES.Resonance.Core;
public static class Database public static class Database
{ {
static Database()
{
DefaultTypeMap.MatchNamesWithUnderscores = true;
}
/// <summary> /// <summary>
/// 获取数据库连接 /// 获取数据库连接
/// </summary> /// </summary>
@@ -107,11 +111,11 @@ public static class Database
waveform BLOB waveform BLOB
); );
CREATE INDEX idx_file_name on audio_files(filename); CREATE INDEX if not exists idx_file_name on audio_files(filename);
CREATE INDEX idx_md5 on audio_files(md5); CREATE INDEX if not exists idx_md5 on audio_files(md5);
CREATE INDEX idx_path on audio_files(path); CREATE INDEX if not exists idx_path on audio_files(path);
CREATE INDEX idx_unique_id on audio_files(unique_id); CREATE INDEX if not exists idx_unique_id on audio_files(unique_id);
CREATE INDEX idx_description on audio_files(description); CREATE INDEX if not exists idx_description on audio_files(description);
"""; """;
@@ -253,4 +257,211 @@ public static class Database
var count = connection.ExecuteScalar<int>(sql, new { Md5 = md5, Path = filePath }); var count = connection.ExecuteScalar<int>(sql, new { Md5 = md5, Path = filePath });
return count > 0; return count > 0;
} }
#region
// 详情查询公共列(含 BLOB),用于 GetEntryById / GetEntryByUniqueId
private const string DetailSelectColumns = @"
id, unique_id, short_id, md5, path, filename, folder, directory,
duration, bit_depth, channels, sample_rate, type,
date_added, original_modification_date AS last_write_time, origination_time AS creation_time,
bpm, frame_rate, timecode, description, category, subcategory,
cat_id, category_full, genre, style, mood, keywords, rating,
artist, composer, designer, recordist, publisher, manufacturer,
originator AS bwf_originator, originator_ref AS bwf_originator_ref, project_name, library, cd_title,
track_title, episode, scene, take, tape, cue_number, sync_point,
release_date, track_year, is_edited, is_split, location, [group],
markers, comments, notes, copyright, coding_history AS bwf_coding_history, microphone,
mic_perspective,
fx_name, channel_layout, disk_number, track_number,
artwork, waveform, bwf_umid,
user1, user2, user3, user4, user5, user6, user7, user8";
// 列表查询公共列(排除 BLOBartwork / waveform / bwf_umid),减少数据传输
private const string ListSelectColumns = @"
id, unique_id, short_id, md5, path, filename, folder, directory,
duration, bit_depth, channels, sample_rate, type,
date_added, original_modification_date AS last_write_time, origination_time AS creation_time,
bpm, frame_rate, timecode, description, category, subcategory,
cat_id, category_full, genre, style, mood, keywords, rating,
artist, composer, designer, recordist, publisher, manufacturer,
originator AS bwf_originator, originator_ref AS bwf_originator_ref, project_name, library, cd_title,
track_title, episode, scene, take, tape, cue_number, sync_point,
release_date, track_year, is_edited, is_split, location, [group],
markers, comments, notes, copyright, coding_history AS bwf_coding_history, microphone,
mic_perspective,
fx_name, channel_layout, disk_number, track_number,
user1, user2, user3, user4, user5, user6, user7, user8";
// 排序白名单,防止 SQL 注入
private static readonly HashSet<string> AllowedSortColumns = new(StringComparer.OrdinalIgnoreCase)
{
"filename", "duration", "sample_rate", "channels",
"date_added", "rating", "type", "category", "id"
};
/// <summary>
/// 按主键取回单条记录(含 BLOB 字段:artwork / waveform / bwf_umid
/// </summary>
public static AudioFileMeta? GetEntryById(int id)
{
using var connection = GetConnection();
connection.Open();
const string sql = $"SELECT {DetailSelectColumns} FROM audio_files WHERE id = @Id;";
return connection.QueryFirstOrDefault<AudioFileMeta>(sql, new { Id = id });
}
/// <summary>
/// 按全局唯一标识符取回单条记录(含 BLOB 字段)
/// </summary>
public static AudioFileMeta? GetEntryByUniqueId(string uniqueId)
{
using var connection = GetConnection();
connection.Open();
const string sql = $"SELECT {DetailSelectColumns} FROM audio_files WHERE unique_id = @UniqueId;";
return connection.QueryFirstOrDefault<AudioFileMeta>(sql, new { UniqueId = uniqueId });
}
/// <summary>
/// 多条件过滤查询(排除 BLOB 字段),支持分页和排序
/// </summary>
public static IEnumerable<AudioFileMeta> QueryEntries(
string? searchText = null,
double? minDuration = null,
double? maxDuration = null,
int? sampleRate = null,
int? channels = null,
string? category = null,
string? sortBy = null,
bool sortDescending = false,
int limit = 100,
int offset = 0)
{
using var connection = GetConnection();
connection.Open();
var where = new List<string>();
var parameters = new DynamicParameters();
if (!string.IsNullOrWhiteSpace(searchText))
{
where.Add("(filename LIKE @SearchText OR description LIKE @SearchText OR keywords LIKE @SearchText OR fx_name LIKE @SearchText)");
parameters.Add("@SearchText", $"%{searchText}%");
}
if (minDuration.HasValue)
{
where.Add("duration >= @MinDuration");
parameters.Add("@MinDuration", minDuration.Value);
}
if (maxDuration.HasValue)
{
where.Add("duration <= @MaxDuration");
parameters.Add("@MaxDuration", maxDuration.Value);
}
if (sampleRate.HasValue)
{
where.Add("sample_rate = @SampleRate");
parameters.Add("@SampleRate", sampleRate.Value);
}
if (channels.HasValue)
{
where.Add("channels = @Channels");
parameters.Add("@Channels", channels.Value);
}
if (!string.IsNullOrWhiteSpace(category))
{
where.Add("category = @Category");
parameters.Add("@Category", category);
}
var whereClause = where.Count > 0 ? "WHERE " + string.Join(" AND ", where) : "";
var orderBy = "ORDER BY date_added DESC";
if (!string.IsNullOrWhiteSpace(sortBy) && AllowedSortColumns.Contains(sortBy))
{
var dir = sortDescending ? "DESC" : "ASC";
orderBy = $"ORDER BY [{sortBy}] {dir}";
}
var sql = $"""
SELECT {ListSelectColumns}
FROM audio_files
{whereClause}
{orderBy}
LIMIT @Limit OFFSET @Offset;
""";
parameters.Add("@Limit", limit);
parameters.Add("@Offset", offset);
return connection.Query<AudioFileMeta>(sql, parameters);
}
/// <summary>
/// 文本搜索(filename / description / keywords / fx_name),排除 BLOB 字段
/// </summary>
public static IEnumerable<AudioFileMeta> SearchEntries(string query, int limit = 100, int offset = 0)
{
return QueryEntries(searchText: query, limit: limit, offset: offset);
}
/// <summary>
/// 返回匹配条件的总记录数(用于分页控件)
/// </summary>
public static int GetTotalCount(
string? searchText = null,
double? minDuration = null,
double? maxDuration = null,
int? sampleRate = null,
int? channels = null,
string? category = null)
{
using var connection = GetConnection();
connection.Open();
var where = new List<string>();
var parameters = new DynamicParameters();
if (!string.IsNullOrWhiteSpace(searchText))
{
where.Add("(filename LIKE @SearchText OR description LIKE @SearchText OR keywords LIKE @SearchText OR fx_name LIKE @SearchText)");
parameters.Add("@SearchText", $"%{searchText}%");
}
if (minDuration.HasValue)
{
where.Add("duration >= @MinDuration");
parameters.Add("@MinDuration", minDuration.Value);
}
if (maxDuration.HasValue)
{
where.Add("duration <= @MaxDuration");
parameters.Add("@MaxDuration", maxDuration.Value);
}
if (sampleRate.HasValue)
{
where.Add("sample_rate = @SampleRate");
parameters.Add("@SampleRate", sampleRate.Value);
}
if (channels.HasValue)
{
where.Add("channels = @Channels");
parameters.Add("@Channels", channels.Value);
}
if (!string.IsNullOrWhiteSpace(category))
{
where.Add("category = @Category");
parameters.Add("@Category", category);
}
var whereClause = where.Count > 0 ? "WHERE " + string.Join(" AND ", where) : "";
var sql = $"SELECT COUNT(*) FROM audio_files {whereClause};";
return connection.ExecuteScalar<int>(sql, parameters);
}
#endregion
} }
+2
View File
@@ -1,5 +1,6 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFormat_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fe2d95a1d7b987bbb7785b2ad7e2e3e29075ba79cdf7619dd26c10d266c5_003FFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFormat_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fe2d95a1d7b987bbb7785b2ad7e2e3e29075ba79cdf7619dd26c10d266c5_003FFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIDbConnection_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01c4805efedf4381865d701be69b5ce5314910_003F77_003Ffb273e4d_003FIDbConnection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOSPlatform_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F9e988c941e6515999ebab7336243c87df16e340b8faeb57b5f562d2b8a7c2c_003FOSPlatform_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOSPlatform_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F9e988c941e6515999ebab7336243c87df16e340b8faeb57b5f562d2b8a7c2c_003FOSPlatform_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATrack_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F872a20b9878a835418db772627eab53c29e04a65284443b02d35345255649a3a_003FTrack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATrack_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F872a20b9878a835418db772627eab53c29e04a65284443b02d35345255649a3a_003FTrack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -18,5 +19,6 @@
&lt;TestId&gt;xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.AudioMetadataReaderTest&lt;/TestId&gt; &lt;TestId&gt;xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.AudioMetadataReaderTest&lt;/TestId&gt;
&lt;TestId&gt;xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests.InitializeDatabase_ShouldCreateAudioFilesTable&lt;/TestId&gt; &lt;TestId&gt;xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests.InitializeDatabase_ShouldCreateAudioFilesTable&lt;/TestId&gt;
&lt;TestId&gt;xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests.InitializeDatabase_ShouldBeIdempotent&lt;/TestId&gt; &lt;TestId&gt;xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests.InitializeDatabase_ShouldBeIdempotent&lt;/TestId&gt;
&lt;TestId&gt;xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests&lt;/TestId&gt;
&lt;/TestAncestor&gt; &lt;/TestAncestor&gt;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary> &lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>