Skip to content
Merged
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
124 changes: 117 additions & 7 deletions crates/persistence/src/backends/postgres/search/query_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,15 +758,76 @@ impl PostgresQueryBuilder {
Some(combined)
}

/// Builds a `last_updated` comparison against the `resources` column.
///
/// Binds real timestamps — `last_updated` is `TIMESTAMPTZ`, and a text
/// parameter fails serialization outright (#871) — and applies the same
/// precision-range semantics as [`Self::build_date_condition`]: `eq` at
/// day precision means `[day, day+1)`, not a scalar equality that nothing
/// can ever hit.
fn build_last_updated_condition(values: &[SearchValue], offset: usize) -> Option<SqlFragment> {
let mut conditions = Vec::new();
for (i, value) in values.iter().enumerate() {
let param_num = offset + i + 1;
let op = Self::prefix_to_operator(&value.prefix);
conditions.push(SqlFragment::with_params(
format!("last_updated {} ${}", op, param_num),
vec![SqlParam::text(&value.value)],
));
let mut next = offset;
for value in values {
let (start, end) = date_precision_range(&value.value);
let degenerate = start == end;
let (sql, params): (String, Vec<SqlParam>) = match value.prefix {
SearchPrefix::Eq if !degenerate => {
next += 1;
let a = next;
next += 1;
(
format!("last_updated >= ${a} AND last_updated < ${next}"),
vec![SqlParam::Timestamp(start), SqlParam::Timestamp(end)],
)
}
SearchPrefix::Ne if !degenerate => {
next += 1;
let a = next;
next += 1;
(
format!("(last_updated < ${a} OR last_updated >= ${next})"),
vec![SqlParam::Timestamp(start), SqlParam::Timestamp(end)],
)
}
SearchPrefix::Gt | SearchPrefix::Sa if !degenerate => {
next += 1;
(
format!("last_updated >= ${next}"),
vec![SqlParam::Timestamp(end)],
)
}
SearchPrefix::Lt | SearchPrefix::Eb if !degenerate => {
next += 1;
(
format!("last_updated < ${next}"),
vec![SqlParam::Timestamp(start)],
)
}
SearchPrefix::Ge if !degenerate => {
next += 1;
(
format!("last_updated >= ${next}"),
vec![SqlParam::Timestamp(start)],
)
}
SearchPrefix::Le if !degenerate => {
next += 1;
(
format!("last_updated < ${next}"),
vec![SqlParam::Timestamp(end)],
)
}
other => {
let op = Self::prefix_to_operator(&other);
next += 1;
(
format!("last_updated {op} ${next}"),
vec![SqlParam::Timestamp(start)],
)
}
};
conditions.push(SqlFragment::with_params(sql, params));
}
if conditions.is_empty() {
return None;
Expand Down Expand Up @@ -2555,6 +2616,55 @@ mod tests {
assert_eq!(frag.params.len(), 1);
}

#[test]
fn last_updated_binds_timestamps_with_precision_ranges() {
// #871: a text param bound against the TIMESTAMPTZ column fails
// serialization, and day-precision eq must mean [day, day+1).
let query = SearchQuery::new("Patient").with_parameter(special_param(
"_lastUpdated",
vec![SearchValue::eq("2026-09-01")],
));
let frag = PostgresQueryBuilder::build_search_query(&query, 2)
.expect("_lastUpdated must produce a condition");

assert!(
frag.sql
.contains("last_updated >= $3 AND last_updated < $4"),
"{}",
frag.sql
);
assert_eq!(frag.params.len(), 2);
assert!(
frag.params
.iter()
.all(|p| matches!(p, SqlParam::Timestamp(_))),
"must bind timestamps, not text: {:?}",
frag.params
);
}

#[test]
fn last_updated_gt_excludes_the_named_day() {
let query = SearchQuery::new("Patient").with_parameter(special_param(
"_lastUpdated",
vec![SearchValue {
prefix: SearchPrefix::Gt,
value: "2026-09-01".to_string(),
}],
));
let frag = PostgresQueryBuilder::build_search_query(&query, 2)
.expect("_lastUpdated gt must produce a condition");

assert!(frag.sql.contains("last_updated >= $3"), "{}", frag.sql);
assert_eq!(frag.params.len(), 1);
match &frag.params[0] {
SqlParam::Timestamp(ts) => {
assert_eq!(ts.to_rfc3339(), "2026-09-02T00:00:00+00:00");
}
other => panic!("must bind the period end as a timestamp: {other:?}"),
}
}

#[test]
fn text_or_list_placeholders_are_gap_free() {
// Two terms OR together, and a blank one must not consume a placeholder
Expand Down
13 changes: 8 additions & 5 deletions crates/persistence/src/core/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ impl SearchResult {
bundle = bundle.with_entry(
BundleEntry::match_entry(
format!("{}/{}", base_url, url),
resource.content().clone(),
resource.content_with_meta(),
)
.with_score(score),
);
Expand All @@ -127,7 +127,7 @@ impl SearchResult {
for resource in &self.included {
bundle = bundle.with_entry(BundleEntry::include_entry(
format!("{}/{}", base_url, resource.url()),
resource.content().clone(),
resource.content_with_meta(),
));
}

Expand Down Expand Up @@ -158,16 +158,19 @@ impl SearchResult {
let url = resource.url();
let score = scores.get(&url).copied();
bundle = bundle.with_entry(
BundleEntry::match_entry(format!("{}/{}", base_url, url), resource.into_content())
.with_score(score),
BundleEntry::match_entry(
format!("{}/{}", base_url, url),
resource.into_content_with_meta(),
)
.with_score(score),
);
}

for resource in included {
let url = resource.url();
bundle = bundle.with_entry(BundleEntry::include_entry(
format!("{}/{}", base_url, url),
resource.into_content(),
resource.into_content_with_meta(),
));
}

Expand Down
4 changes: 2 additions & 2 deletions crates/persistence/src/core/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ impl BundleEntryResult {
location: Some(resource.versioned_url()),
etag: Some(resource.etag().to_string()),
last_modified: Some(resource.last_modified().to_rfc3339()),
resource: Some(resource.into_content()),
resource: Some(resource.content_with_meta()),
outcome: None,
}
}
Expand All @@ -341,7 +341,7 @@ impl BundleEntryResult {
location: None,
etag: Some(resource.etag().to_string()),
last_modified: Some(resource.last_modified().to_rfc3339()),
resource: Some(resource.into_content()),
resource: Some(resource.content_with_meta()),
outcome: None,
}
}
Expand Down
41 changes: 41 additions & 0 deletions crates/persistence/src/types/stored_resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,47 @@ impl StoredResource {
&self.content
}

/// Returns the content with server-populated `meta.versionId` and
/// `meta.lastUpdated` merged in from this row's version and timestamp.
///
/// Stored resources persist the content as submitted — the version and
/// timestamp live in their own columns — so bodies read back without this
/// carry no server metadata even though the ETag/Last-Modified headers do
/// (#873). Client-supplied `meta` members (`profile`, `tag`, `security`)
/// are preserved; `versionId`/`lastUpdated` are overwritten because the
/// server's row is authoritative for both.
pub fn content_with_meta(&self) -> Value {
Self::merge_meta(self.content.clone(), &self.version_id, self.last_modified)
}

/// Consumes self and returns the content with server-populated
/// `meta.versionId` / `meta.lastUpdated` merged in.
///
/// This is [`Self::content_with_meta`] without the clone — for callers
/// that are about to drop the `StoredResource` anyway (see
/// `SearchResult::into_bundle`).
pub fn into_content_with_meta(self) -> Value {
Self::merge_meta(self.content, &self.version_id, self.last_modified)
}

fn merge_meta(mut content: Value, version_id: &str, last_modified: DateTime<Utc>) -> Value {
if let Value::Object(obj) = &mut content {
let meta = obj
.entry("meta")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Value::Object(meta) = meta {
meta.insert("versionId".into(), Value::String(version_id.to_string()));
meta.insert(
"lastUpdated".into(),
Value::String(
last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
),
);
}
}
content
}

/// Returns a mutable reference to the resource content.
pub fn content_mut(&mut self) -> &mut Value {
&mut self.content
Expand Down
Loading
Loading