fix(resource): 统一 ResourceQuery 路径匹配契约

This commit is contained in:
2026-09-19 07:59:31 +08:00
parent 045d598400
commit 56ad014199
5 changed files with 367 additions and 85 deletions
+171 -63
View File
@@ -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"))