Skip to content

feat(search): composite token+quantity search via dedicated table#29

Draft
nirmal070125 wants to merge 3 commits into
wso2:mainfrom
nirmal070125:feat/composite-token-quantity
Draft

feat(search): composite token+quantity search via dedicated table#29
nirmal070125 wants to merge 3 commits into
wso2:mainfrom
nirmal070125:feat/composite-token-quantity

Conversation

@nirmal070125

@nirmal070125 nirmal070125 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What

Token+quantity composite searches (code-value-quantity, component-code-value-quantity, …) currently walk sp_token newest-first and run a correlated EXISTS against sp_quantity for each candidate, which can't early-exit when the intersection is sparse (a common code, a rare value). This adds a dedicated sp_composite_token_quantity table that holds the token+quantity pairs materialised at index time, so those searches resolve with a single index scan.

It also fixes a correctness bug. FHIR composite semantics require both components to match within the same element, but the resource-level EXISTS correlation let a multi-component Observation match component A's code against component B's value. Storing one row per element pairing makes same-element matching the only match.

How

  • Schema — new sp_composite_token_quantity table with code/value btree, recency, range-GiST, and per-resource source indexes, plus planner statistics, autovacuum tuning, and the tenant RLS policy. The GiST predicate is byte-for-byte identical to the quantity path so the range-overlap index stays reachable.
  • Indexer — writes the composite rows alongside the existing sp_token/sp_quantity rows, pairing each element's token codings with that element's quantity value only. Update/delete re-index and the FK cascade cover the new table.
  • Search — token+quantity composites drive off the single table through the existing direct-drive path, so the planner chooses between the value-driven and recency-driven plans. Handles system|code / bare-code tokens and unit-scoped quantities; other composite types keep the current path.

Tests

Same-element regression, all six comparators with boundary cases, token and unit-scoped forms, update/delete re-index, a SQL-shape unit test, and an EXPLAIN check that the composite indexes are used.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4fee772d-84c0-42aa-92e7-e5d73a255aef

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds a dedicated composite token-quantity table and indexes, materializes paired values during extraction, and removes them during deletion. Search generation now uses the table for token and quantity predicates with direct-drive support. Planner statistics, autovacuum, and tenant RLS configuration are updated. Tests cover matching semantics, comparators, token and unit forms, lifecycle updates, SQL generation, and index plans.

Sequence Diagram(s)

sequenceDiagram
  participant SearchParams
  participant Extractor
  participant PostgreSQL
  participant queryBuilder
  SearchParams->>Extractor: index resource composite values
  Extractor->>PostgreSQL: store token-quantity pairs
  SearchParams->>queryBuilder: submit composite search
  queryBuilder->>PostgreSQL: query composite predicates and indexes
  PostgreSQL-->>SearchParams: return matching resources
Loading

Suggested reviewers: sameeragunarathne

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: dedicated table support for composite token+quantity search.
Description check ✅ Passed The description accurately matches the changeset and covers the new table, indexing, search routing, and tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
internal/store/search_idfirst_test.go (1)

292-299: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a genuinely nested token+quantity composite case.

The remaining cases only exercise nested plain quantities. Add a chained or _has composite case that verifies numericTable stays empty, covering the composite path’s separate direct-drive suppression logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/search_idfirst_test.go` around lines 292 - 299, Add a test
case to TestNestedNumericNotDirectDrive using a chained target or _has key whose
value is a token+quantity composite, and assert that numericTable remains empty.
Keep the existing plain-quantity cases and verify the composite path’s
direct-drive suppression separately.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/db/schema.sql`:
- Around line 660-677: Do not mark the built-in numrange constructor LEAKPROOF
globally. Introduce a dedicated wrapper for the bounded quantity range
construction, mark only that wrapper leakproof after preserving the required
behavior, and update both the idx_sp_qty_range_gist index expression and its RLS
predicate to use it; otherwise leave the range overlap as a post-filter.
- Around line 4-6: Update the schema prerequisite comment near resource_json to
state that PostgreSQL must be built with --with-lz4 for COMPRESSION lz4 support,
or change the compression setting to pglz/default if that requirement should not
be imposed.

In `@internal/store/search_composite_plan_test.go`:
- Around line 94-99: Update the bounded gePlan assertion in the search composite
plan test to require the exact GiST index name idx_sp_comp_tokqty_range_gist,
replacing the broader idx_sp_comp_tokqty substring check while preserving the
existing failure message and plan logging.

In `@internal/store/search.go`:
- Line 609: Validate quantity components in compositeQtyCond and
buildNumberExists before constructing predicates: propagate ParseFloat errors
and reject non-finite values instead of defaulting them to zero. Update their
callers, including the flow assigning qtyCond, to return or handle these errors
while preserving normal finite-value behavior.

In `@internal/store/store_integration_test.go`:
- Around line 776-789: Check and handle the error returned by each setup call to
s.Create in the composite-test setup: the same-element Observation creation at
internal/store/store_integration_test.go:776-789, every comparator Observation
creation at 811-817, and the token/unit Observation creation at 847-851. Fail
the test immediately on any creation error so setup failures are reported
directly rather than as misleading search-count failures.

---

Nitpick comments:
In `@internal/store/search_idfirst_test.go`:
- Around line 292-299: Add a test case to TestNestedNumericNotDirectDrive using
a chained target or _has key whose value is a token+quantity composite, and
assert that numericTable remains empty. Keep the existing plain-quantity cases
and verify the composite path’s direct-drive suppression separately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 73846a79-bec5-41c6-b6d9-10c67d0f09ce

📥 Commits

Reviewing files that changed from the base of the PR and between dc822ed and 9fc8a78.

📒 Files selected for processing (7)
  • internal/db/schema.sql
  • internal/index/extractor.go
  • internal/store/search.go
  • internal/store/search_composite_plan_test.go
  • internal/store/search_idfirst_test.go
  • internal/store/search_paramsweep_test.go
  • internal/store/store_integration_test.go

Comment thread internal/db/schema.sql Outdated
Comment thread internal/db/schema.sql
Comment on lines +94 to +99
// Bounded ge search: the GiST range-overlap index must be reachable.
gePlan := plan("8867-4$ge60")
t.Logf("ge plan:\n%s", gePlan)
if !strings.Contains(gePlan, "idx_sp_comp_tokqty") {
t.Errorf("ge: expected a composite index scan (GiST for overlap), got:\n%s", gePlan)
}

@coderabbitai coderabbitai Bot Jul 18, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Assert the specific GiST index name.

This currently accepts any idx_sp_comp_tokqty* index, so it would not detect the overlap predicate falling back to another index plus filtering. Check for idx_sp_comp_tokqty_range_gist explicitly.

Proposed change
-	if !strings.Contains(gePlan, "idx_sp_comp_tokqty") {
+	if !strings.Contains(gePlan, "idx_sp_comp_tokqty_range_gist") {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Bounded ge search: the GiST range-overlap index must be reachable.
gePlan := plan("8867-4$ge60")
t.Logf("ge plan:\n%s", gePlan)
if !strings.Contains(gePlan, "idx_sp_comp_tokqty") {
t.Errorf("ge: expected a composite index scan (GiST for overlap), got:\n%s", gePlan)
}
// Bounded ge search: the GiST range-overlap index must be reachable.
gePlan := plan("8867-4$ge60")
t.Logf("ge plan:\n%s", gePlan)
if !strings.Contains(gePlan, "idx_sp_comp_tokqty_range_gist") {
t.Errorf("ge: expected a composite index scan (GiST for overlap), got:\n%s", gePlan)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/search_composite_plan_test.go` around lines 94 - 99, Update
the bounded gePlan assertion in the search composite plan test to require the
exact GiST index name idx_sp_comp_tokqty_range_gist, replacing the broader
idx_sp_comp_tokqty substring check while preserving the existing failure message
and plan logging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed, but not by pinning the index name — that would be flaky. On small data the planner legitimately serves ge via the recency index (idx_sp_comp_tokqty_recent) rather than the range-GiST; both are composite indexes. Instead the test now also asserts the range-overlap operator && appears in the plan, so a scalar-bound fallback that drops the overlap is still caught.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

test_file="$(fd -t f '^search_composite_plan_test\.go$' internal/store | head -n1)"
search_file="$(fd -t f '^search\.go$' internal/store | head -n1)"

printf '%s\n' '--- plan test ---'
sed -n '80,120p' "$test_file"

printf '%s\n' '--- composite quantity predicate locations ---'
rg -n -A35 -B8 'func \(.*\) compositeQtyCond|numrange\(.*&&|buildCompositeTokenQuantityExists' "$search_file"

Length of output: 9249


@nirmal070125, confirmed from the current code: compositeQtyCond emits the && overlap predicate for ge, and the updated plan test checks for that predicate while allowing a valid composite-index choice. The revised assertion is appropriate.

Comment thread internal/store/search.go
Comment thread internal/store/store_integration_test.go Outdated
@nirmal070125
nirmal070125 force-pushed the feat/composite-token-quantity branch from 9fc8a78 to 3b24490 Compare July 18, 2026 04:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
internal/db/schema.sql (2)

4-6: 📐 Maintainability & Code Quality | 🟡 Minor | 💤 Low value

Document the LZ4 build requirement.

PostgreSQL 14+ alone does not guarantee COMPRESSION lz4 is available. Update the comment to explicitly call out the --with-lz4 build requirement, or change the compression setting to pglz or default if this requirement should not be imposed.

📝 Proposed comment update
--- Requires PostgreSQL 14+ (resource_json uses COMPRESSION lz4). For Location
+-- Requires PostgreSQL 14+ (built with --with-lz4 for COMPRESSION lz4). For Location
 -- near-search, install PostGIS. The btree_gist extension is created by this
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/schema.sql` around lines 4 - 6, Update the header comment in
schema.sql to state that PostgreSQL must be built with --with-lz4 for the
resource_json compression setting to work; alternatively, change that setting to
pglz or default if an LZ4 build requirement is not intended.

660-677: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Avoid marking the built-in numrange constructor LEAKPROOF globally.

LEAKPROOF is a function-wide security contract. The built-in numrange constructor can raise argument-dependent errors outside the indexed path. Introduce a dedicated wrapper for bounded quantity range construction, mark only the wrapper as LEAKPROOF, and update both the index expressions and Row-Level Security predicates to use it. If a wrapper is not implemented, leave the range overlap as a post-filter.

As per path instructions, this validates safety using neutral, high-level language without exposing sensitive context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/schema.sql` around lines 660 - 677, The index path must not mark
the built-in numrange constructor LEAKPROOF globally. Add a dedicated
bounded-quantity range wrapper, mark only that wrapper LEAKPROOF, and replace
direct numrange usage in idx_sp_qty_range_gist and the related Row-Level
Security predicates with it; otherwise keep range overlap as a post-filter.

Source: Path instructions

🧹 Nitpick comments (1)
internal/db/schema.sql (1)

328-330: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Include trailing columns to ensure index-only EXISTS probes.

To fulfill the documented intent of the _source indexes (resolving per-resource probes without heap-fetching), include the system, qty_system, and qty_code columns. Without these, composite queries filtering on the token system or quantity system/unit will require a heap fetch during the correlated scan.

⚡ Proposed index refinement
 -- Per-resource source probe + reindex DELETE / FK cascade support.
 CREATE INDEX IF NOT EXISTS idx_sp_comp_tokqty_source ON sp_composite_token_quantity
-    (tenant_id, resource_id, resource_type, param_name, code, value_low, value_high);
+    (tenant_id, resource_id, resource_type, param_name, code, value_low, value_high)
+    INCLUDE (system, qty_system, qty_code);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/db/schema.sql` around lines 328 - 330, Update the
idx_sp_comp_tokqty_source index definition on sp_composite_token_quantity to
include trailing system, qty_system, and qty_code columns, preserving the
existing leading-column order so per-resource EXISTS probes can be satisfied
index-only.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@internal/db/schema.sql`:
- Around line 4-6: Update the header comment in schema.sql to state that
PostgreSQL must be built with --with-lz4 for the resource_json compression
setting to work; alternatively, change that setting to pglz or default if an LZ4
build requirement is not intended.
- Around line 660-677: The index path must not mark the built-in numrange
constructor LEAKPROOF globally. Add a dedicated bounded-quantity range wrapper,
mark only that wrapper LEAKPROOF, and replace direct numrange usage in
idx_sp_qty_range_gist and the related Row-Level Security predicates with it;
otherwise keep range overlap as a post-filter.

---

Nitpick comments:
In `@internal/db/schema.sql`:
- Around line 328-330: Update the idx_sp_comp_tokqty_source index definition on
sp_composite_token_quantity to include trailing system, qty_system, and qty_code
columns, preserving the existing leading-column order so per-resource EXISTS
probes can be satisfied index-only.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5a7c7ef8-8bb5-4952-a3d2-ae7bb8e9c514

📥 Commits

Reviewing files that changed from the base of the PR and between 9fc8a78 and 3b24490.

📒 Files selected for processing (7)
  • internal/db/schema.sql
  • internal/index/extractor.go
  • internal/store/search.go
  • internal/store/search_composite_plan_test.go
  • internal/store/search_idfirst_test.go
  • internal/store/search_paramsweep_test.go
  • internal/store/store_integration_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • internal/store/store_integration_test.go
  • internal/store/search.go
  • internal/store/search_idfirst_test.go
  • internal/index/extractor.go

@nirmal070125
nirmal070125 force-pushed the feat/composite-token-quantity branch from 3b24490 to 28dafb8 Compare July 18, 2026 04:43
Add sp_composite_token_quantity, a tenant-scoped table holding token+quantity
composite pairs (code-value-quantity, component-code-value-quantity, ...)
materialised at index time — one row per composite param per element pairing
where both components co-occur in the SAME element. Token+quantity composite
searches route to this single table with one index scan; all other sp_* tables
and non-composite paths are unchanged.

This also fixes a spec-conformance bug: the legacy path correlated the two
components only at the resource level, so a multi-component Observation could
cross-match component A's code with component B's value. Per-element rows make
same-element pairing the only match.

Schema: new table + code_value / recency / range-GiST / source indexes,
planner statistics, autovacuum tuning, RLS policy. The GiST predicate stays
byte-for-byte identical to buildQuantityExists so the range-overlap index is
reachable; existing numrange/range_overlaps LEAKPROOF markings cover it.

Indexer: dual-writes composite rows alongside the component sp_token/sp_quantity
rows, pairing token codings with the quantity value within each element only;
reindex DELETE and FK cascade include the new table.

Query path: token+quantity composites reuse the existing direct-drive machinery
so the planner chooses between the value-driven (code_value / GiST) and
recency-driven plan shapes.

Tests: same-element false-positive regression, all comparators with boundaries,
token system|code / unit-scoped forms, update/delete reindex, and an EXPLAIN
plan check that the composite indexes are used.
@nirmal070125
nirmal070125 force-pushed the feat/composite-token-quantity branch from 28dafb8 to 1f63f1b Compare July 18, 2026 04:52
@nirmal070125
nirmal070125 marked this pull request as draft July 18, 2026 06:28
@nirmal070125
nirmal070125 force-pushed the feat/composite-token-quantity branch 3 times, most recently from ab2c47a to 1a65444 Compare July 19, 2026 02:19
Verify and document compatibility with PostgreSQL 18. The schema (lz4
COMPRESSION, btree_gist, GiST numrange/tstzrange, RLS + LEAKPROOF, extended
statistics) and all queries run unchanged on 18.

- Test containers (testutil.PostgresImage + conformance harness) default to
  postgres:18-alpine and are overridable via FHIR_TEST_POSTGRES_IMAGE so the
  whole supported range (14 through 18) stays testable in one command.
- docker-compose.yml bumped to postgres:18-alpine.
- Docs state the supported range as PostgreSQL 14+ (tested through 18); the
  stale "13+" floor is corrected to 14 (the actual floor — resource_json uses
  lz4 COMPRESSION, which requires 14+).

Verified: full integration suite and the FHIR R4 conformance suite pass on
PostgreSQL 18; schema DDL, force_custom_plan, and the range-overlap plan tests
also pass on the 14 floor via FHIR_TEST_POSTGRES_IMAGE=postgres:14-alpine.

(cherry picked from commit d09f07f)
@nirmal070125
nirmal070125 force-pushed the feat/composite-token-quantity branch from 1a65444 to 9a5bc97 Compare July 19, 2026 09:46
Half-bounded quantity comparators had no seekable access path: ge/le
emitted a numrange && half-open probe that overlapped nearly every GiST
leaf, and gt/lt rode a scalar on the wrong bound column, so every plan
degraded to a full scan of the (tenant, type, param) partition. On the
perf dataset value-quantity=ge99999 was the slowest query (~800ms p_max).

Emit the collapsed scalar predicate on the correct bound column and split
the comparators into two index-served families:

  value_high-driven: ge (>=), gt (>), eb (<)  -> idx_sp_qty_high (new)
  value_low-driven:  le (<=), lt (<), sa (>)  -> idx_sp_qty_raw (existing)

eq/ne/ap keep the numrange && overlap byte-for-byte so windowed queries
stay on the GiST index. sa/eb are now handled explicitly instead of
falling through to eq. Emitting on the correct column preserves straddling
correctness: a stored [50, 100050] matches ge99999 via value_high and
lt100 via value_low.

The search band is quantized to the value column scale (DECIMAL(20,6)) so
the strict comparators stay boundary-exact: a stored point value's
value_high/value_low is rounded to 6dp on write, and comparing against an
unrounded search edge would leak a boundary value in (gt65 matching a
stored 65 whose value_high rounds to 65.000007).

Schema: add idx_sp_qty_high and idx_sp_comp_tokqty_code_high covering
value_high after the equality prefix, and raise statistics targets on
value_low/value_high (1000 buckets) so the planner's per-column histogram
distinguishes sparse high-tail bounds from dense ones and picks the
value-driven seek vs. the recency walk accordingly.

Verified with EXPLAIN (ANALYZE, BUFFERS): ge99999 rides an Index Only
Scan on idx_sp_qty_high with value_high as an Index Cond (buffers scale
with the match set, not the partition); ge0 switches to the recency walk;
lt5 stays on idx_sp_qty_raw. All unit and integration tests pass.
@nirmal070125

Copy link
Copy Markdown
Contributor Author

Added commit 690272afast half-bounded quantity searches, building on the composite table introduced here.

Problem: half-bounded comparators had no seekable access path. ge/le emitted a numrange && half-open probe that overlapped nearly every GiST leaf, and gt/lt rode a scalar on the wrong bound column, so every plan degraded to a full scan of the (tenant, type, param) partition. value-quantity=ge99999 was the slowest query on the perf dataset (~800ms p_max).

Change:

  • Emit the collapsed scalar on the correct bound column, split into two index-served families — ge/gt/ebvalue_high, le/lt/savalue_low. sa/eb are now handled explicitly instead of falling through to eq.
  • eq/ne/ap keep the numrange && overlap byte-for-byte, so windowed queries stay on the GiST index.
  • Correct-column emission preserves straddling correctness: [50, 100050] matches ge99999 (via value_high) and lt100 (via value_low).
  • Search band is quantized to the column scale (DECIMAL(20,6)) so the strict comparators stay boundary-exact (gt65 must not match a stored 65 whose value_high rounds to 65.000007).
  • Schema: idx_sp_qty_high + idx_sp_comp_tokqty_code_high covering value_high after the equality prefix; SET STATISTICS 1000 on value_low/value_high for both tables.

Verified with EXPLAIN (ANALYZE, BUFFERS): ge99999 → Index Only Scan on idx_sp_qty_high with value_high as an Index Cond (buffers scale with the match set, not the partition); ge0 → recency walk; lt5idx_sp_qty_raw. Full unit + integration suites pass (testcontainers, PG18). New TestSearch_HalfBoundedQuantity_Straddle covers the straddling fixtures.

The k6 p_max/TPS numbers and the ingest write-amplification check (design §6.4–5) still need a run on the perf environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant