Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions crates/persistence/src/backends/sqlite/search/query_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,18 @@ impl QueryBuilder {
param: &SearchParameter,
param_offset: usize,
) -> Option<SqlFragment> {
// Handle special parameters
if param.name.starts_with('_') {
// Handle special parameters. `_tag`/`_profile`/`_security`/`_source`
// are NOT special on the query side: the extractor indexes them from
// meta like any typed parameter (rows in search_index under their own
// param_name), so they take the regular token/uri path below. Routing
// them into the special handler silently dropped the condition and
// returned the unfiltered result set (#474).
if param.name.starts_with('_')
&& !matches!(
param.name.as_str(),
"_tag" | "_profile" | "_security" | "_source"
)
{
return self.build_special_parameter_condition(param, param_offset);
}

Expand Down
158 changes: 158 additions & 0 deletions crates/persistence/tests/search/meta_params_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//! Tests for the meta search parameters `_tag`, `_profile`, `_security`,
//! and `_source` (#474).
//!
//! These are ordinary typed parameters indexed from `Resource.meta.*` by the
//! spec `SearchParameter` set; the query builder must apply them rather than
//! treating them as unimplemented specials and silently returning the
//! unfiltered result set.

use serde_json::json;

use helios_persistence::core::{ResourceStorage, SearchProvider};
use helios_persistence::tenant::{TenantContext, TenantId, TenantPermissions};
use helios_persistence::types::{SearchParamType, SearchParameter, SearchQuery, SearchValue};

use helios_fhir::FhirVersion;

#[cfg(feature = "sqlite")]
use helios_persistence::backends::sqlite::SqliteBackend;

#[cfg(feature = "sqlite")]
fn create_sqlite_backend() -> SqliteBackend {
super::make_sqlite_backend()
}

fn create_tenant() -> TenantContext {
TenantContext::new(
TenantId::new("test-tenant"),
TenantPermissions::full_access(),
)
}

#[cfg(feature = "sqlite")]
async fn seed_meta_patients(backend: &SqliteBackend, tenant: &TenantContext) {
let tagged = json!({
"resourceType": "Patient",
"meta": {
"tag": [{"system": "http://example.org/tags", "code": "test-data"}],
"profile": ["http://example.org/StructureDefinition/custom-patient"],
"security": [{
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "HTEST"
}]
},
"gender": "female"
});
let untagged = json!({"resourceType": "Patient", "gender": "male"});
backend
.create(tenant, "Patient", tagged, FhirVersion::default())
.await
.unwrap();
backend
.create(tenant, "Patient", untagged, FhirVersion::default())
.await
.unwrap();
}

fn token_param(name: &str, system: Option<&str>, code: &str) -> SearchParameter {
SearchParameter {
name: name.to_string(),
param_type: SearchParamType::Token,
modifier: None,
values: vec![SearchValue::token(system, code)],
chain: vec![],
components: vec![],
}
}

#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_tag_filters_to_tagged_resources() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_meta_patients(&backend, &tenant).await;

let query = SearchQuery::new("Patient").with_parameter(token_param(
"_tag",
Some("http://example.org/tags"),
"test-data",
));
let result = backend
.search(&tenant, &query.with_count(100))
.await
.unwrap();

assert_eq!(
result.resources.len(),
1,
"_tag must filter, not fall through to the unfiltered set"
);
assert_eq!(result.resources.items[0].content()["gender"], "female");
}

#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_tag_with_no_match_returns_empty() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_meta_patients(&backend, &tenant).await;

let query = SearchQuery::new("Patient").with_parameter(token_param(
"_tag",
Some("http://example.org/tags"),
"no-such-tag",
));
let result = backend
.search(&tenant, &query.with_count(100))
.await
.unwrap();

assert_eq!(result.resources.len(), 0);
}

#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_security_filters_by_security_label() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_meta_patients(&backend, &tenant).await;

let query = SearchQuery::new("Patient").with_parameter(token_param(
"_security",
Some("http://terminology.hl7.org/CodeSystem/v3-ActCode"),
"HTEST",
));
let result = backend
.search(&tenant, &query.with_count(100))
.await
.unwrap();

assert_eq!(result.resources.len(), 1);
assert_eq!(result.resources.items[0].content()["gender"], "female");
}

#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_profile_filters_by_canonical() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_meta_patients(&backend, &tenant).await;

let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "_profile".to_string(),
param_type: SearchParamType::Uri,
modifier: None,
values: vec![SearchValue::string(
"http://example.org/StructureDefinition/custom-patient",
)],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query.with_count(100))
.await
.unwrap();

assert_eq!(result.resources.len(), 1);
assert_eq!(result.resources.items[0].content()["gender"], "female");
}
1 change: 1 addition & 0 deletions crates/persistence/tests/search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub fn make_sqlite_backend() -> SqliteBackend {
pub mod chained_tests;
pub mod date_tests;
pub mod include_tests;
pub mod meta_params_tests;
pub mod modifier_tests;
pub mod number_tests;
pub mod pagination_tests;
Expand Down
70 changes: 70 additions & 0 deletions crates/rest/tests/search_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2577,3 +2577,73 @@ mod summary_count {
assert!(body["total"].is_null(), "explicit _total=none wins: {body}");
}
}

// ============================================================================
// Meta Parameter Tests (_tag / _profile / _security, #474)
// ============================================================================

mod meta_params {
use super::*;
use helios_persistence::tenant::{TenantContext, TenantId, TenantPermissions};

async fn seed_tagged_patient(backend: &SqliteBackend) {
let tenant = TenantContext::new(
TenantId::new("test-tenant"),
TenantPermissions::full_access(),
);
let tagged = json!({
"resourceType": "Patient",
"meta": {
"tag": [{"system": "http://example.org/tags", "code": "test-data"}],
"profile": ["http://example.org/StructureDefinition/custom-patient"]
},
"name": [{"family": "Tagged"}]
});
backend
.create(&tenant, "Patient", tagged, FhirVersion::R4)
.await
.unwrap();
}

#[tokio::test]
async fn test_tag_filters_over_http() {
let (server, backend) = create_test_server().await;
seed_search_test_data(&backend).await;
seed_tagged_patient(&backend).await;

let response = server
.get("/Patient?_tag=http%3A%2F%2Fexample.org%2Ftags%7Ctest-data")
.add_header(X_TENANT_ID, HeaderValue::from_static("test-tenant"))
.await;

response.assert_status_ok();
let body: Value = response.json();
let entries = get_bundle_entries(&body);
assert_eq!(
entries.len(),
1,
"_tag must filter instead of returning every patient"
);
assert_eq!(entries[0]["resource"]["name"][0]["family"], "Tagged");
}

#[tokio::test]
async fn test_profile_filters_over_http() {
let (server, backend) = create_test_server().await;
seed_search_test_data(&backend).await;
seed_tagged_patient(&backend).await;

let response = server
.get(
"/Patient?_profile=http%3A%2F%2Fexample.org%2FStructureDefinition%2Fcustom-patient",
)
.add_header(X_TENANT_ID, HeaderValue::from_static("test-tenant"))
.await;

response.assert_status_ok();
let body: Value = response.json();
let entries = get_bundle_entries(&body);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["resource"]["name"][0]["family"], "Tagged");
}
}
Loading