WIP: database增加读取功能
单元测试没过
This commit is contained in:
@@ -66,7 +66,7 @@ public class DatabaseTests
|
||||
{
|
||||
// Act — 连续调用两次
|
||||
Database.InitializeDatabase(dbName);
|
||||
var exception = Record.Exception(() => Database.InitializeDatabase(dbName));
|
||||
Exception? exception = Record.Exception(() => Database.InitializeDatabase(dbName));
|
||||
|
||||
// Assert — 不应抛出异常
|
||||
Assert.Null(exception);
|
||||
@@ -83,4 +83,224 @@ public class DatabaseTests
|
||||
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
@@ -6,6 +6,10 @@ namespace OCES.Resonance.Core;
|
||||
|
||||
public static class Database
|
||||
{
|
||||
static Database()
|
||||
{
|
||||
DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取数据库连接
|
||||
/// </summary>
|
||||
@@ -107,11 +111,11 @@ public static class Database
|
||||
waveform BLOB
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_name on audio_files(filename);
|
||||
CREATE INDEX idx_md5 on audio_files(md5);
|
||||
CREATE INDEX idx_path on audio_files(path);
|
||||
CREATE INDEX idx_unique_id on audio_files(unique_id);
|
||||
CREATE INDEX idx_description on audio_files(description);
|
||||
CREATE INDEX if not exists idx_file_name on audio_files(filename);
|
||||
CREATE INDEX if not exists idx_md5 on audio_files(md5);
|
||||
CREATE INDEX if not exists idx_path on audio_files(path);
|
||||
CREATE INDEX if not exists idx_unique_id on audio_files(unique_id);
|
||||
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 });
|
||||
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";
|
||||
|
||||
// 列表查询公共列(排除 BLOB:artwork / 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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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_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 @@
|
||||
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.AudioMetadataReaderTest</TestId>
|
||||
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests.InitializeDatabase_ShouldCreateAudioFilesTable</TestId>
|
||||
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests.InitializeDatabase_ShouldBeIdempotent</TestId>
|
||||
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests</TestId>
|
||||
</TestAncestor>
|
||||
</SessionState></s:String></wpf:ResourceDictionary>
|
||||
Reference in New Issue
Block a user