mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-19 10:26:39 +08:00
fix(resource): 统一 ResourceQuery 路径匹配契约
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> 基线:`experiment`
|
||||
>
|
||||
> 当前已知基线提交:`37d49c9793d25288fd2fcc60b95c1ba55bdf7623`
|
||||
> 当前已知基线提交:`045d598400f9435051bb9554e06e123d0d8a3704`
|
||||
>
|
||||
> 工程原则:不以“最小修复”为目标。所有修复与新功能优先考虑长期可维护性、可用性、安全性、明确契约、回归测试和后续扩展成本;避免无关重构,但一旦处理一个问题,应完整收口其工程边界。
|
||||
|
||||
@@ -168,7 +168,7 @@ transition、generation race、过期和失败失效回归,并验证现有 CDN
|
||||
# T02 — ResourceRepository 查询契约统一
|
||||
|
||||
**类型:** P2
|
||||
**状态:** Ready
|
||||
**状态:** Done
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -179,7 +179,7 @@ transition、generation race、过期和失败失效回归,并验证现有 CDN
|
||||
```text
|
||||
* 不跨 /
|
||||
** 可以跨 /
|
||||
? 匹配一个字符
|
||||
? 匹配一个 Unicode scalar value
|
||||
```
|
||||
|
||||
当前 Rust matcher、SQLite LIKE 转换以及 `list()` / `count()` 的实际过滤语义并不完全一致。
|
||||
@@ -225,12 +225,23 @@ list(query).len() != count(query)
|
||||
|
||||
## 关系
|
||||
|
||||
**Hard Blocker:**
|
||||
**历史 Hard Blocker(已满足):**
|
||||
|
||||
```text
|
||||
T02 → T13
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
已将 `ResourceQuery.path_pattern` 收口为 `bat-core` 共享的 Unicode scalar
|
||||
matcher:`*` 与 `?` 不跨 `/`,`**` 支持跨 `/`,`**/` 可匹配根目录和任意深度目录,
|
||||
`/`、大小写、`%`、`_`、`\` 均按字面量处理,空路径/空模式和连续通配符都有确定语义;
|
||||
匹配使用有界动态规划,不再使用按字节递归回溯。InMemory 与 SQLite 共用同一最终判定,
|
||||
SQLite 仅下推安全候选过滤,不再把 SQL `LIKE` 当作正式 glob 语义;包含路径或 metadata
|
||||
后置过滤的 `count()` 会复用完整查询结果,保证与 `list()` 精确相等,无后置过滤时保留
|
||||
SQL COUNT 快速路径。已补齐 `*`、`**`、`?`、分隔符、Unicode、特殊字面量、无匹配、
|
||||
组合过滤、稳定 ID 顺序以及 InMemory/SQLite list/count parity 回归测试。
|
||||
|
||||
---
|
||||
|
||||
# T03 — SQLite Schema Migration 体系化
|
||||
@@ -828,7 +839,7 @@ T09/T10 ─→ T11 增加 AssetBundle/Patch 真实验证
|
||||
# T13 — G-011 ResourceRepository 查询扩展
|
||||
|
||||
**类型:** Feature
|
||||
**状态:** Blocked by T02
|
||||
**状态:** Ready
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -844,9 +855,7 @@ T09/T10 ─→ T11 增加 AssetBundle/Patch 真实验证
|
||||
|
||||
## 依赖
|
||||
|
||||
**Hard Blocker:T02**
|
||||
|
||||
## 完成记录
|
||||
前置任务 T02 已完成,查询 contract 已满足。
|
||||
|
||||
# T14 — G-012 Translation Memory 后续扩展
|
||||
|
||||
@@ -1025,7 +1034,7 @@ resource bootstrap
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
|
||||
T02 ResourceRepository ──Hard──→ T13 G-011
|
||||
T02 ResourceRepository ──Hard──→ T13 G-011(历史前置关系,已满足)
|
||||
|
||||
T03 SQLite Migration ──Hard──→ T04 TM Trust ──Hard──→ T14 G-012
|
||||
│
|
||||
@@ -1069,12 +1078,12 @@ Stable Resource / Release / AssetBundle Platform
|
||||
## Ready
|
||||
|
||||
```text
|
||||
T02 — ResourceRepository 查询契约统一
|
||||
T05 — bat.sock 本地 IPC 安全与资源边界
|
||||
T06 — AssetBundle / ZIP Parser Resource Budget
|
||||
T07 — Durable Atomic State Write
|
||||
T11 — Official Smoke / 长期运行证据
|
||||
T14 — Translation Memory 后续扩展
|
||||
T13 — G-011 ResourceRepository 查询扩展
|
||||
```
|
||||
|
||||
T11 可以现在就开始采集,但 T01 后的数据才作为新的正式 distribution integrity evidence。
|
||||
@@ -1089,8 +1098,6 @@ T10 ← T09
|
||||
|
||||
T12 ← 推荐 T06/T08
|
||||
|
||||
T13 ← T02
|
||||
|
||||
T15 ← 推荐 T03
|
||||
T16 ← 推荐 T03
|
||||
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,12 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
||||
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
|
||||
`1..=1000`,非法参数返回 `BAT-ERR-700002`。
|
||||
|
||||
`path_pattern` 使用 `ResourceRepository` 的统一匹配契约:`*` 匹配零个或多个
|
||||
不含 `/` 的 Unicode scalar value,`**` 可以跨 `/`,`?` 匹配恰好一个不含 `/`
|
||||
的 Unicode scalar value;`**/` 可匹配根目录文件和任意深度目录。匹配区分大小写,
|
||||
没有转义语法,`/`、`%`、`_` 和 `\` 都是字面量。SQLite 只做候选筛选,最终由
|
||||
同一 matcher 判定,带路径或 metadata 后置过滤时 `count()` 与 `list()` 保持一致。
|
||||
|
||||
`resource.manifest` 的请求必须携带由 `release.attestation` 返回的
|
||||
`release_id`、`expected_publication_identity`、`expected_manifest_identity` 和
|
||||
`expected_verification_generation`。
|
||||
|
||||
+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