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
+19 -12
View File
@@ -2,7 +2,7 @@
> 基线:`experiment` > 基线:`experiment`
> >
> 当前已知基线提交:`37d49c9793d25288fd2fcc60b95c1ba55bdf7623` > 当前已知基线提交:`045d598400f9435051bb9554e06e123d0d8a3704`
> >
> 工程原则:不以“最小修复”为目标。所有修复与新功能优先考虑长期可维护性、可用性、安全性、明确契约、回归测试和后续扩展成本;避免无关重构,但一旦处理一个问题,应完整收口其工程边界。 > 工程原则:不以“最小修复”为目标。所有修复与新功能优先考虑长期可维护性、可用性、安全性、明确契约、回归测试和后续扩展成本;避免无关重构,但一旦处理一个问题,应完整收口其工程边界。
@@ -168,7 +168,7 @@ transition、generation race、过期和失败失效回归,并验证现有 CDN
# T02 — ResourceRepository 查询契约统一 # T02 — ResourceRepository 查询契约统一
**类型:** P2 **类型:** P2
**状态:** Ready **状态:** Done
## 问题 ## 问题
@@ -179,7 +179,7 @@ transition、generation race、过期和失败失效回归,并验证现有 CDN
```text ```text
* 不跨 / * 不跨 /
** 可以跨 / ** 可以跨 /
? 匹配一个字符 ? 匹配一个 Unicode scalar value
``` ```
当前 Rust matcher、SQLite LIKE 转换以及 `list()` / `count()` 的实际过滤语义并不完全一致。 当前 Rust matcher、SQLite LIKE 转换以及 `list()` / `count()` 的实际过滤语义并不完全一致。
@@ -225,12 +225,23 @@ list(query).len() != count(query)
## 关系 ## 关系
**Hard Blocker** **历史 Hard Blocker(已满足)**
```text ```text
T02 → T13 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 体系化 # T03 — SQLite Schema Migration 体系化
@@ -828,7 +839,7 @@ T09/T10 ─→ T11 增加 AssetBundle/Patch 真实验证
# T13 — G-011 ResourceRepository 查询扩展 # T13 — G-011 ResourceRepository 查询扩展
**类型:** Feature **类型:** Feature
**状态:** Blocked by T02 **状态:** Ready
## 目标 ## 目标
@@ -844,9 +855,7 @@ T09/T10 ─→ T11 增加 AssetBundle/Patch 真实验证
## 依赖 ## 依赖
**Hard BlockerT02** 前置任务 T02 已完成,查询 contract 已满足。
## 完成记录
# T14 — G-012 Translation Memory 后续扩展 # 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 T03 SQLite Migration ──Hard──→ T04 TM Trust ──Hard──→ T14 G-012
@@ -1069,12 +1078,12 @@ Stable Resource / Release / AssetBundle Platform
## Ready ## Ready
```text ```text
T02 — ResourceRepository 查询契约统一
T05 — bat.sock 本地 IPC 安全与资源边界 T05 — bat.sock 本地 IPC 安全与资源边界
T06 — AssetBundle / ZIP Parser Resource Budget T06 — AssetBundle / ZIP Parser Resource Budget
T07 — Durable Atomic State Write T07 — Durable Atomic State Write
T11 — Official Smoke / 长期运行证据 T11 — Official Smoke / 长期运行证据
T14 — Translation Memory 后续扩展 T14 — Translation Memory 后续扩展
T13 — G-011 ResourceRepository 查询扩展
``` ```
T11 可以现在就开始采集,但 T01 后的数据才作为新的正式 distribution integrity evidence。 T11 可以现在就开始采集,但 T01 后的数据才作为新的正式 distribution integrity evidence。
@@ -1089,8 +1098,6 @@ T10 ← T09
T12 ← 推荐 T06/T08 T12 ← 推荐 T06/T08
T13 ← T02
T15 ← 推荐 T03 T15 ← 推荐 T03
T16 ← 推荐 T03 T16 ← 推荐 T03
+1 -1
View File
@@ -10,6 +10,6 @@ pub mod translation_repository;
pub use cas_repository::CasRepository; pub use cas_repository::CasRepository;
pub use glossary_repository::GlossaryRepository; 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_memory_repository::TranslationMemoryRepository;
pub use translation_repository::TranslationRepository; pub use translation_repository::TranslationRepository;
+170 -9
View File
@@ -35,6 +35,120 @@
use crate::domain::{Resource, ResourceType}; use crate::domain::{Resource, ResourceType};
use async_trait::async_trait; 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 和解析摘要过滤。 /// 用于构建灵活的资源查询。支持按类型、Hash、路径、官方 release 和解析摘要过滤。
@@ -81,9 +195,12 @@ pub struct ResourceQuery {
/// 如果为 `None`,不过滤路径。 /// 如果为 `None`,不过滤路径。
/// ///
/// 支持通配符: /// 支持通配符:
/// - `*` 匹配任意字符(不包括 `/` /// - `*` 匹配零个或多个 Unicode scalar value(不包括 `/`
/// - `**` 匹配任意字符(包括 `/` /// - `**` 匹配零个或多个 Unicode scalar value(包括 `/`
/// - `?` 匹配单个字符 /// - `?` 匹配恰好一个 Unicode scalar value(不包括 `/`
/// - `/` 是字面量分隔符;`%`、`_`、`\` 没有特殊含义
///
/// 匹配区分大小写,不存在转义语法。空模式只匹配空路径。
/// ///
/// # 示例 /// # 示例
/// ///
@@ -192,12 +309,13 @@ impl ResourceQuery {
} }
} }
/// 是否包含通用仓储需要读取完整 `Resource` 后才能判断的条件。 /// 是否包含通用仓储需要读取完整 `Resource` 后才能最终判断的条件。
/// ///
/// 具体后端可以把这些 metadata 条件下推到自身索引;内存实现仍用完整 /// 具体后端可以把 metadata 条件下推到自身索引,但路径模式和这些条件都必须
/// `Resource` 过滤来保持 `list()` 与 `count()` 的语义一致。 /// 经过同一最终过滤逻辑,以保持 `list()` 与 `count()` 的语义一致。
pub fn requires_resource_scan(&self) -> bool { 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.platform.is_some()
|| self.bundle_path.is_some() || self.bundle_path.is_some()
|| self.archive_entry.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>>; 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)); 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:?}"
);
}
}
} }
+6
View File
@@ -119,6 +119,12 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是 属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
`1..=1000`,非法参数返回 `BAT-ERR-700002` `1..=1000`,非法参数返回 `BAT-ERR-700002`
`path_pattern` 使用 `ResourceRepository` 的统一匹配契约:`*` 匹配零个或多个
不含 `/` 的 Unicode scalar value`**` 可以跨 `/``?` 匹配恰好一个不含 `/`
的 Unicode scalar value`**/` 可匹配根目录文件和任意深度目录。匹配区分大小写,
没有转义语法,`/``%``_``\` 都是字面量。SQLite 只做候选筛选,最终由
同一 matcher 判定,带路径或 metadata 后置过滤时 `count()``list()` 保持一致。
`resource.manifest` 的请求必须携带由 `release.attestation` 返回的 `resource.manifest` 的请求必须携带由 `release.attestation` 返回的
`release_id``expected_publication_identity``expected_manifest_identity` `release_id``expected_publication_identity``expected_manifest_identity`
`expected_verification_generation` `expected_verification_generation`
+171 -63
View File
@@ -2,7 +2,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use bat_core::domain::{Resource, ResourceEntry, ResourceMetadata, ResourceType}; 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::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
use sqlx::{QueryBuilder, Sqlite, SqlitePool}; use sqlx::{QueryBuilder, Sqlite, SqlitePool};
use std::collections::HashMap; use std::collections::HashMap;
@@ -56,9 +58,10 @@ impl ResourceRepository for InMemoryResourceRepository {
async fn list(&self, query: ResourceQuery) -> bat_core::Result<Vec<Resource>> { async fn list(&self, query: ResourceQuery) -> bat_core::Result<Vec<Resource>> {
let resources = self.resources.read().await; let resources = self.resources.read().await;
let path_pattern = query.path_pattern.as_deref().map(ResourcePathPattern::new);
let resources = Self::sorted_resources(&resources) let resources = Self::sorted_resources(&resources)
.into_iter() .into_iter()
.filter(|resource| query_matches(&query, resource)) .filter(|resource| query_matches(&query, path_pattern.as_ref(), resource))
.collect(); .collect();
Ok(resources) Ok(resources)
} }
@@ -359,14 +362,6 @@ impl SqliteResourceRepository {
builder.push_bind(hash); 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 { if let Some(destination) = &query.destination {
push_condition_prefix(builder, &mut has_where); push_condition_prefix(builder, &mut has_where);
builder.push("path = "); builder.push("path = ");
@@ -431,7 +426,7 @@ impl SqliteResourceRepository {
); );
Self::apply_filters(&mut builder, query)?; Self::apply_filters(&mut builder, query)?;
builder.push(" ORDER BY id"); 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); builder.push(" LIMIT ").push_bind(limit as i64);
} }
@@ -445,13 +440,22 @@ impl SqliteResourceRepository {
.into_iter() .into_iter()
.map(Self::resource_from_row) .map(Self::resource_from_row)
.collect::<bat_core::Result<Vec<_>>>()?; .collect::<bat_core::Result<Vec<_>>>()?;
Ok(resources let path_pattern = query.path_pattern.as_deref().map(ResourcePathPattern::new);
let mut resources = resources
.into_iter() .into_iter()
.filter(|resource| query_matches(query, resource)) .filter(|resource| query_matches(query, path_pattern.as_ref(), resource))
.collect()) .collect::<Vec<_>>();
if let Some(limit) = limit {
resources.truncate(limit);
}
Ok(resources)
} }
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> { 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"); let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM resources");
Self::apply_filters(&mut builder, query)?; Self::apply_filters(&mut builder, query)?;
@@ -598,31 +602,11 @@ type ResourceRow = (
String, String,
); );
fn glob_to_like(pattern: &str) -> String { fn query_matches(
let mut escaped = String::new(); query: &ResourceQuery,
let mut chars = pattern.chars().peekable(); path_pattern: Option<&ResourcePathPattern>,
resource: &Resource,
while let Some(ch) = chars.next() { ) -> bool {
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 {
if let Some(resource_type) = query.resource_type { if let Some(resource_type) = query.resource_type {
if resource.entry.resource_type != resource_type { if resource.entry.resource_type != resource_type {
return false; return false;
@@ -635,8 +619,8 @@ fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
} }
} }
if let Some(pattern) = &query.path_pattern { if let Some(pattern) = path_pattern {
if !wildcard_matches(pattern, &resource.entry.path) { if !pattern.matches(&resource.entry.path) {
return false; return false;
} }
} }
@@ -701,28 +685,6 @@ fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
true 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[tokio::test]
async fn add_find_update_and_delete_resource() { async fn add_find_update_and_delete_resource() {
let repository = InMemoryResourceRepository::new(); let repository = InMemoryResourceRepository::new();
@@ -864,6 +910,68 @@ mod tests {
assert_eq!(repository.count(query).await.unwrap(), 1); 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) { async fn sqlite_repository() -> (tempfile::TempDir, SqliteResourceRepository) {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
let repository = SqliteResourceRepository::new(temp_dir.path().join("resources.sqlite")) let repository = SqliteResourceRepository::new(temp_dir.path().join("resources.sqlite"))