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:
@@ -10,6 +10,6 @@ pub mod translation_repository;
|
||||
|
||||
pub use cas_repository::CasRepository;
|
||||
pub use glossary_repository::GlossaryRepository;
|
||||
pub use resource_repository::ResourceRepository;
|
||||
pub use resource_repository::{ResourcePathPattern, ResourceQuery, ResourceRepository};
|
||||
pub use translation_memory_repository::TranslationMemoryRepository;
|
||||
pub use translation_repository::TranslationRepository;
|
||||
|
||||
@@ -35,6 +35,120 @@
|
||||
use crate::domain::{Resource, ResourceType};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Compiled path pattern used by [`ResourceQuery::path_pattern`].
|
||||
///
|
||||
/// The matcher operates on Unicode scalar values rather than UTF-8 bytes:
|
||||
///
|
||||
/// - `*` matches zero or more non-`/` scalar values.
|
||||
/// - `**` matches zero or more scalar values, including `/`.
|
||||
/// - `?` matches exactly one non-`/` scalar value.
|
||||
/// - `/` and every other character are literals.
|
||||
///
|
||||
/// There is no escape syntax. Matching is case-sensitive and uses a bounded
|
||||
/// dynamic-programming table, so wildcard-heavy input cannot trigger
|
||||
/// exponential backtracking.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResourcePathPattern {
|
||||
tokens: Vec<ResourcePathToken>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ResourcePathToken {
|
||||
Literal(char),
|
||||
Single,
|
||||
Star,
|
||||
GlobStar,
|
||||
RecursiveDirectory,
|
||||
}
|
||||
|
||||
impl ResourcePathPattern {
|
||||
/// Compiles a resource path pattern.
|
||||
///
|
||||
/// `**/` is treated as a recursive directory prefix so that patterns such
|
||||
/// as `**/*.json` also match a root-level `a.json`.
|
||||
pub fn new(pattern: &str) -> Self {
|
||||
let chars = pattern.chars().collect::<Vec<_>>();
|
||||
let mut tokens = Vec::with_capacity(chars.len());
|
||||
let mut index = 0;
|
||||
|
||||
while index < chars.len() {
|
||||
match chars[index] {
|
||||
'*' if chars.get(index + 1) == Some(&'*') => {
|
||||
index += 2;
|
||||
if chars.get(index) == Some(&'/') {
|
||||
tokens.push(ResourcePathToken::RecursiveDirectory);
|
||||
index += 1;
|
||||
} else {
|
||||
tokens.push(ResourcePathToken::GlobStar);
|
||||
}
|
||||
}
|
||||
'*' => {
|
||||
tokens.push(ResourcePathToken::Star);
|
||||
index += 1;
|
||||
}
|
||||
'?' => {
|
||||
tokens.push(ResourcePathToken::Single);
|
||||
index += 1;
|
||||
}
|
||||
literal => {
|
||||
tokens.push(ResourcePathToken::Literal(literal));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self { tokens }
|
||||
}
|
||||
|
||||
/// Returns whether `path` satisfies this pattern.
|
||||
pub fn matches(&self, path: &str) -> bool {
|
||||
let path = path.chars().collect::<Vec<_>>();
|
||||
let token_count = self.tokens.len();
|
||||
let path_count = path.len();
|
||||
let mut table = vec![false; (token_count + 1) * (path_count + 1)];
|
||||
|
||||
let cell = |token: usize, value: usize| token * (path_count + 1) + value;
|
||||
table[cell(token_count, path_count)] = true;
|
||||
|
||||
for token in (0..token_count).rev() {
|
||||
for value in (0..=path_count).rev() {
|
||||
table[cell(token, value)] = match self.tokens[token] {
|
||||
ResourcePathToken::Literal(literal) => {
|
||||
value < path_count
|
||||
&& path[value] == literal
|
||||
&& table[cell(token + 1, value + 1)]
|
||||
}
|
||||
ResourcePathToken::Single => {
|
||||
value < path_count
|
||||
&& path[value] != '/'
|
||||
&& table[cell(token + 1, value + 1)]
|
||||
}
|
||||
ResourcePathToken::Star => {
|
||||
table[cell(token + 1, value)]
|
||||
|| (value < path_count
|
||||
&& path[value] != '/'
|
||||
&& table[cell(token, value + 1)])
|
||||
}
|
||||
ResourcePathToken::GlobStar => {
|
||||
table[cell(token + 1, value)]
|
||||
|| (value < path_count && table[cell(token, value + 1)])
|
||||
}
|
||||
ResourcePathToken::RecursiveDirectory => {
|
||||
if table[cell(token + 1, value)] {
|
||||
true
|
||||
} else {
|
||||
(value..path_count)
|
||||
.any(|index| path[index] == '/' && table[cell(token, index + 1)])
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
table[cell(0, 0)]
|
||||
}
|
||||
}
|
||||
|
||||
/// 资源查询条件
|
||||
///
|
||||
/// 用于构建灵活的资源查询。支持按类型、Hash、路径、官方 release 和解析摘要过滤。
|
||||
@@ -81,9 +195,12 @@ pub struct ResourceQuery {
|
||||
/// 如果为 `None`,不过滤路径。
|
||||
///
|
||||
/// 支持通配符:
|
||||
/// - `*` 匹配任意字符(不包括 `/`)
|
||||
/// - `**` 匹配任意字符(包括 `/`)
|
||||
/// - `?` 匹配单个字符
|
||||
/// - `*` 匹配零个或多个 Unicode scalar value(不包括 `/`)
|
||||
/// - `**` 匹配零个或多个 Unicode scalar value(包括 `/`)
|
||||
/// - `?` 匹配恰好一个 Unicode scalar value(不包括 `/`)
|
||||
/// - `/` 是字面量分隔符;`%`、`_`、`\` 没有特殊含义
|
||||
///
|
||||
/// 匹配区分大小写,不存在转义语法。空模式只匹配空路径。
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
@@ -192,12 +309,13 @@ impl ResourceQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否包含通用仓储需要读取完整 `Resource` 后才能判断的条件。
|
||||
/// 是否包含通用仓储需要读取完整 `Resource` 后才能最终判断的条件。
|
||||
///
|
||||
/// 具体后端可以把这些 metadata 条件下推到自身索引;内存实现仍用完整
|
||||
/// `Resource` 过滤来保持 `list()` 与 `count()` 的语义一致。
|
||||
/// 具体后端可以把 metadata 条件下推到自身索引,但路径模式和这些条件都必须
|
||||
/// 经过同一最终过滤逻辑,以保持 `list()` 与 `count()` 的语义一致。
|
||||
pub fn requires_resource_scan(&self) -> bool {
|
||||
self.official_release_id.is_some()
|
||||
self.path_pattern.is_some()
|
||||
|| self.official_release_id.is_some()
|
||||
|| self.platform.is_some()
|
||||
|| self.bundle_path.is_some()
|
||||
|| self.archive_entry.is_some()
|
||||
@@ -355,7 +473,7 @@ pub trait ResourceRepository: Send + Sync {
|
||||
///
|
||||
/// # 实现建议
|
||||
///
|
||||
/// - 对于路径模式匹配,使用 `glob` crate
|
||||
/// - 路径模式匹配使用 [`ResourcePathPattern`],不要把 SQL `LIKE` 当作正式语义
|
||||
/// - 考虑结果缓存以提高性能
|
||||
/// - 对于超大结果集,返回迭代器(未来版本)
|
||||
async fn list(&self, query: ResourceQuery) -> crate::Result<Vec<Resource>>;
|
||||
@@ -429,7 +547,8 @@ pub trait ResourceRepository: Send + Sync {
|
||||
///
|
||||
/// # 性能
|
||||
///
|
||||
/// 此方法应该比 `list()` 更快,因为不需要返回实际数据。
|
||||
/// 无路径模式或 metadata 后置过滤时可以使用索引/SQL COUNT 快速路径;存在
|
||||
/// 后置过滤时必须与 `list(query).len()` 完全一致。
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
@@ -532,4 +651,46 @@ mod tests {
|
||||
|
||||
assert_eq!(query2.resource_type, Some(ResourceType::Manifest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_path_pattern_matches_unicode_and_separators() {
|
||||
let cases = [
|
||||
("", "", true),
|
||||
("", "a", false),
|
||||
("*", "", true),
|
||||
("*", "abc", true),
|
||||
("*", "a/b", false),
|
||||
("?", "你", true),
|
||||
("?", "あ", true),
|
||||
("?", "😀", true),
|
||||
("?", "/", false),
|
||||
("?", "ab", false),
|
||||
("assets/*.json", "assets/a.json", true),
|
||||
("assets/*.json", "assets/nested/a.json", false),
|
||||
("assets/**/*.json", "assets/a.json", true),
|
||||
("assets/**/*.json", "assets/nested/a.json", true),
|
||||
("assets/**/*.json", "assets/nested/deep/a.json", true),
|
||||
("**/*.json", "a.json", true),
|
||||
("**/*.json", "nested/a.json", true),
|
||||
("**/*.json", "nested/deep/a.json", true),
|
||||
("**/*.json", "a.txt", false),
|
||||
("A*", "abc", false),
|
||||
("A*", "Abc", true),
|
||||
("100%", "100%", true),
|
||||
("100%", "1000", false),
|
||||
("a_b", "a_b", true),
|
||||
("a_b", "axb", false),
|
||||
(r"a\b", r"a\b", true),
|
||||
(r"a\b", "a/b", false),
|
||||
("***", "nested/path", true),
|
||||
];
|
||||
|
||||
for (pattern, path, expected) in cases {
|
||||
assert_eq!(
|
||||
ResourcePathPattern::new(pattern).matches(path),
|
||||
expected,
|
||||
"pattern={pattern:?} path={path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user