mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-19 12:46:39 +08:00
fix(resource): 统一 ResourceQuery 路径匹配契约
This commit is contained in:
+171
-63
@@ -2,7 +2,9 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bat_core::domain::{Resource, ResourceEntry, ResourceMetadata, ResourceType};
|
||||
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
|
||||
use bat_core::repositories::resource_repository::{
|
||||
ResourcePathPattern, ResourceQuery, ResourceRepository,
|
||||
};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
|
||||
use sqlx::{QueryBuilder, Sqlite, SqlitePool};
|
||||
use std::collections::HashMap;
|
||||
@@ -56,9 +58,10 @@ impl ResourceRepository for InMemoryResourceRepository {
|
||||
|
||||
async fn list(&self, query: ResourceQuery) -> bat_core::Result<Vec<Resource>> {
|
||||
let resources = self.resources.read().await;
|
||||
let path_pattern = query.path_pattern.as_deref().map(ResourcePathPattern::new);
|
||||
let resources = Self::sorted_resources(&resources)
|
||||
.into_iter()
|
||||
.filter(|resource| query_matches(&query, resource))
|
||||
.filter(|resource| query_matches(&query, path_pattern.as_ref(), resource))
|
||||
.collect();
|
||||
Ok(resources)
|
||||
}
|
||||
@@ -359,14 +362,6 @@ impl SqliteResourceRepository {
|
||||
builder.push_bind(hash);
|
||||
}
|
||||
|
||||
if let Some(pattern) = &query.path_pattern {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("path LIKE ");
|
||||
builder
|
||||
.push_bind(glob_to_like(pattern))
|
||||
.push(" ESCAPE '\\'");
|
||||
}
|
||||
|
||||
if let Some(destination) = &query.destination {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("path = ");
|
||||
@@ -431,7 +426,7 @@ impl SqliteResourceRepository {
|
||||
);
|
||||
Self::apply_filters(&mut builder, query)?;
|
||||
builder.push(" ORDER BY id");
|
||||
if let Some(limit) = limit {
|
||||
if let Some(limit) = limit.filter(|_| !query.requires_resource_scan()) {
|
||||
builder.push(" LIMIT ").push_bind(limit as i64);
|
||||
}
|
||||
|
||||
@@ -445,13 +440,22 @@ impl SqliteResourceRepository {
|
||||
.into_iter()
|
||||
.map(Self::resource_from_row)
|
||||
.collect::<bat_core::Result<Vec<_>>>()?;
|
||||
Ok(resources
|
||||
let path_pattern = query.path_pattern.as_deref().map(ResourcePathPattern::new);
|
||||
let mut resources = resources
|
||||
.into_iter()
|
||||
.filter(|resource| query_matches(query, resource))
|
||||
.collect())
|
||||
.filter(|resource| query_matches(query, path_pattern.as_ref(), resource))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(limit) = limit {
|
||||
resources.truncate(limit);
|
||||
}
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> {
|
||||
if query.requires_resource_scan() {
|
||||
return Ok(self.fetch_resources(query, None).await?.len() as u64);
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM resources");
|
||||
Self::apply_filters(&mut builder, query)?;
|
||||
|
||||
@@ -598,31 +602,11 @@ type ResourceRow = (
|
||||
String,
|
||||
);
|
||||
|
||||
fn glob_to_like(pattern: &str) -> String {
|
||||
let mut escaped = String::new();
|
||||
let mut chars = pattern.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'*' => {
|
||||
if matches!(chars.peek(), Some('*')) {
|
||||
chars.next();
|
||||
}
|
||||
escaped.push('%');
|
||||
}
|
||||
'?' => escaped.push('_'),
|
||||
'%' | '_' | '\\' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
other => escaped.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
escaped
|
||||
}
|
||||
|
||||
fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
|
||||
fn query_matches(
|
||||
query: &ResourceQuery,
|
||||
path_pattern: Option<&ResourcePathPattern>,
|
||||
resource: &Resource,
|
||||
) -> bool {
|
||||
if let Some(resource_type) = query.resource_type {
|
||||
if resource.entry.resource_type != resource_type {
|
||||
return false;
|
||||
@@ -635,8 +619,8 @@ fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pattern) = &query.path_pattern {
|
||||
if !wildcard_matches(pattern, &resource.entry.path) {
|
||||
if let Some(pattern) = path_pattern {
|
||||
if !pattern.matches(&resource.entry.path) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -701,28 +685,6 @@ fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn wildcard_matches(pattern: &str, value: &str) -> bool {
|
||||
wildcard_matches_bytes(pattern.as_bytes(), value.as_bytes())
|
||||
}
|
||||
|
||||
fn wildcard_matches_bytes(pattern: &[u8], value: &[u8]) -> bool {
|
||||
match (pattern.first(), value.first()) {
|
||||
(None, None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(Some(b'*'), _) => {
|
||||
wildcard_matches_bytes(&pattern[1..], value)
|
||||
|| value
|
||||
.first()
|
||||
.is_some_and(|_| wildcard_matches_bytes(pattern, &value[1..]))
|
||||
}
|
||||
(Some(b'?'), Some(_)) => wildcard_matches_bytes(&pattern[1..], &value[1..]),
|
||||
(Some(pattern_byte), Some(value_byte)) if pattern_byte == value_byte => {
|
||||
wildcard_matches_bytes(&pattern[1..], &value[1..])
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -748,6 +710,90 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn parity_resources() -> Vec<Resource> {
|
||||
let mut current = resource(
|
||||
"resource/assets-a",
|
||||
"assets/a.json",
|
||||
"hash-assets-a",
|
||||
ResourceType::TextAsset,
|
||||
);
|
||||
current.metadata.official_release_id = Some("release-current".to_string());
|
||||
current.metadata.platform = Some("windows".to_string());
|
||||
|
||||
let mut unicode = resource(
|
||||
"resource/assets-unicode",
|
||||
"assets/你.json",
|
||||
"hash-assets-unicode",
|
||||
ResourceType::TextAsset,
|
||||
);
|
||||
unicode.metadata.official_release_id = Some("release-current".to_string());
|
||||
unicode.metadata.platform = Some("windows".to_string());
|
||||
|
||||
let mut stale = resource(
|
||||
"resource/assets-stale",
|
||||
"assets/stale.json",
|
||||
"hash-assets-stale",
|
||||
ResourceType::TextAsset,
|
||||
);
|
||||
stale.metadata.official_release_id = Some("release-old".to_string());
|
||||
stale.metadata.platform = Some("android".to_string());
|
||||
|
||||
vec![
|
||||
resource(
|
||||
"resource/root-json",
|
||||
"a.json",
|
||||
"hash-root",
|
||||
ResourceType::TextAsset,
|
||||
),
|
||||
resource(
|
||||
"resource/nested-json",
|
||||
"nested/a.json",
|
||||
"hash-nested",
|
||||
ResourceType::TextAsset,
|
||||
),
|
||||
current,
|
||||
resource(
|
||||
"resource/deep-json",
|
||||
"assets/nested/deep/a.json",
|
||||
"hash-assets-deep",
|
||||
ResourceType::TextAsset,
|
||||
),
|
||||
unicode,
|
||||
resource(
|
||||
"resource/assets-japanese",
|
||||
"assets/あ.json",
|
||||
"hash-assets-japanese",
|
||||
ResourceType::TextAsset,
|
||||
),
|
||||
resource(
|
||||
"resource/assets-emoji",
|
||||
"assets/😀.json",
|
||||
"hash-assets-emoji",
|
||||
ResourceType::TextAsset,
|
||||
),
|
||||
stale,
|
||||
resource(
|
||||
"resource/assets-bundle",
|
||||
"assets/a.bundle",
|
||||
"hash-assets-bundle",
|
||||
ResourceType::AssetBundle,
|
||||
),
|
||||
resource(
|
||||
"resource/special",
|
||||
r"special/100%_\x",
|
||||
"hash-special",
|
||||
ResourceType::Media,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn resource_ids(resources: &[Resource]) -> Vec<String> {
|
||||
resources
|
||||
.iter()
|
||||
.map(|resource| resource.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_find_update_and_delete_resource() {
|
||||
let repository = InMemoryResourceRepository::new();
|
||||
@@ -864,6 +910,68 @@ mod tests {
|
||||
assert_eq!(repository.count(query).await.unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_and_sqlite_share_path_and_count_contract() {
|
||||
let memory = InMemoryResourceRepository::new();
|
||||
let (_temp_dir, sqlite) = sqlite_repository().await;
|
||||
|
||||
for resource in parity_resources() {
|
||||
memory.add(resource.clone()).await.unwrap();
|
||||
sqlite.add(resource).await.unwrap();
|
||||
}
|
||||
|
||||
let queries = vec![
|
||||
ResourceQuery {
|
||||
path_pattern: Some("**/*.json".to_string()),
|
||||
..ResourceQuery::all()
|
||||
},
|
||||
ResourceQuery {
|
||||
path_pattern: Some("assets/**/*.json".to_string()),
|
||||
..ResourceQuery::all()
|
||||
},
|
||||
ResourceQuery {
|
||||
path_pattern: Some("assets/?.json".to_string()),
|
||||
..ResourceQuery::all()
|
||||
},
|
||||
ResourceQuery {
|
||||
path_pattern: Some(r"special/100%_\x".to_string()),
|
||||
..ResourceQuery::all()
|
||||
},
|
||||
ResourceQuery {
|
||||
path_pattern: Some("does-not-exist/**".to_string()),
|
||||
..ResourceQuery::all()
|
||||
},
|
||||
ResourceQuery {
|
||||
resource_type: Some(ResourceType::TextAsset),
|
||||
hash: Some("hash-assets-a".to_string()),
|
||||
path_pattern: Some("assets/**/*.json".to_string()),
|
||||
official_release_id: Some("release-current".to_string()),
|
||||
platform: Some("windows".to_string()),
|
||||
..ResourceQuery::all()
|
||||
},
|
||||
];
|
||||
|
||||
for query in queries {
|
||||
let memory_results = memory.list(query.clone()).await.unwrap();
|
||||
let sqlite_results = sqlite.list(query.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
resource_ids(&memory_results),
|
||||
resource_ids(&sqlite_results),
|
||||
"list parity for query {query:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
memory.count(query.clone()).await.unwrap(),
|
||||
memory_results.len() as u64,
|
||||
"in-memory count/list parity for query {query:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite.count(query.clone()).await.unwrap(),
|
||||
sqlite_results.len() as u64,
|
||||
"SQLite count/list parity for query {query:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn sqlite_repository() -> (tempfile::TempDir, SqliteResourceRepository) {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let repository = SqliteResourceRepository::new(temp_dir.path().join("resources.sqlite"))
|
||||
|
||||
Reference in New Issue
Block a user