Implementing Faceted Search with Field Key Filtering in PropertyWebBuilder

Faceted search in PropertyWebBuilder relies on tenant-scoped field keys stored in the pwb_field_keys table, parsed through SearchParamsService, and rendered via the Search::FormSetup concern.

PropertyWebBuilder implements faceted search using field keys as the canonical source for every searchable attribute, from property types to amenities. Each field key resides in the pwb_field_keys table and is automatically scoped to the current tenant (website), enabling multi-site deployments with isolated data sets. This architecture separates data configuration from application code, allowing property managers to add or modify search filters without touching Ruby source files.

Understanding Field Keys as the Foundation

Field keys serve as the backbone of PropertyWebBuilder's search system. Stored in the pwb_field_keys table, each record defines a selectable option with a global_key that acts as the canonical identifier across the application.

The system provides three distinct advantages through this approach:

  • Tenant isolationPwbTenant::FieldKey automatically scopes queries to ActsAsTenant.current_tenant, ensuring each website maintains its own independent set of options
  • Internationalization – Labels are stored in a JSONB translations column and exposed through the Mobility gem via field_key.label, automatically rendering in the visitor's locale
  • Configuration-driven changes – Adding, removing, or reordering facet options requires only data updates via seed files or the admin UI, eliminating the need for code deployments

The Three-Layer Architecture

Faceted search operates through three coordinated layers that transform URL parameters into filtered results.

Data Definition Layer

The Pwb::FieldKey model in app/models/pwb/field_key.rb defines the structure and retrieval methods for facet options. The critical method get_options_by_tag(tag) returns an array of OpenStruct objects containing value, label, and sort_order for populating select boxes and checkbox lists.


# Returns tenant-scoped options for a specific facet category

@property_types = PwbTenant::FieldKey.get_options_by_tag('property-types')

# => [{ value: 'types.apartment', label: 'Apartment', sort_order: 0 }, ...]

Request Processing Layer

Pwb::SearchParamsService in app/services/pwb/search_params_service.rb handles the normalization of URL parameters into a clean criteria hash. The from_url_params method parses incoming requests like ?type=apartment&features=pool,garden into structured Ruby hashes suitable for database querying.

params = ActionController::Parameters.new(
  type: 'apartment',
  features: 'pool,garden',
  sort: 'price-asc',
  page: '2'
)

criteria = Pwb::SearchParamsService.new.from_url_params(params)
#=> { property_type: "apartment",

#     features: ["pool", "garden"],

#     sort: "price-asc",

#     page: 2 }

Controller and View Layer

The Search::FormSetup concern in app/controllers/concerns/search/form_setup.rb bridges the data and presentation layers. Controllers include this concern to preload facet options into instance variables (e.g., @property_types, @property_features) that views render as search forms.

Parsing URL Parameters into Search Criteria

When a user selects facets, values transmit as URL parameters and require normalization. The SearchParamsService#from_url_params method (lines 41-58) handles type coercion, array splitting for multi-select fields, and pagination defaults.

For SEO optimization, the service also provides canonical_url (lines 100-108) to strip unnecessary parameters like page=1 and generate clean, indexable URLs:

criteria = { property_type: 'apartment', features: ['pool', 'garden'], page: 1 }
url = Pwb::SearchParamsService.new.canonical_url(
  criteria,
  locale: :en,
  operation: :buy,
  host: 'example.com'
)
#=> "https://example.com/en/buy?type=apartment&features=pool,garden"

Loading Facet Options for the UI

Controllers populate search forms by invoking get_options_by_tag with specific category tags. In app/controllers/concerns/search/form_setup.rb (lines 21-25), the standard implementation loads common facets:

@property_types   = PwbTenant::FieldKey.get_options_by_tag('property-types')
@property_states  = PwbTenant::FieldKey.get_options_by_tag('property-states')
@property_features = PwbTenant::FieldKey.get_options_by_tag('property-features')

Each option object provides both the database value (the global_key) and the translated label, enabling the view layer to render localized interfaces without additional queries.

Applying Filters to Property Queries

The final step applies the normalized criteria to Pwb::RealtyAsset records. Because field keys store canonical identifiers in foreign key columns (prop_type_key, prop_state_key, feature_key), filtering reduces to simple equality or IN clauses:

criteria = Pwb::SearchParamsService.new.from_url_params(params)

assets = Pwb::RealtyAsset.all
assets = assets.where(prop_type_key: criteria[:property_type]) if criteria[:property_type]
assets = assets.where(prop_state_key: criteria[:prop_state_key]) if criteria[:prop_state_key]
assets = assets.joins(:features)
               .where(pwb_features: { feature_key: criteria[:features] }) if criteria[:features].present?

Extending Facets with New Field Keys

Adding a new faceted filter (e.g., "energy-label") requires minimal code changes:

  1. Insert a new row into pwb_field_keys via the admin UI or seed files, tagging it with energy-labels

  2. Add the loading line to app/controllers/concerns/search/form_setup.rb:

    @energy_labels = PwbTenant::FieldKey.get_options_by_tag('energy-labels')
  3. Create or modify a view partial to render the new facet using @energy_labels

No additional Ruby business logic is required; the existing SearchParamsService automatically handles unknown parameters, and the database schema supports arbitrary facet keys through the field key associations.

Summary

  • Field keys in the pwb_field_keys table provide the data source for all faceted search options, scoped per tenant via PwbTenant::FieldKey
  • SearchParamsService normalizes URL parameters into query criteria and generates canonical URLs for SEO
  • Search::FormSetup concern loads facet options through get_options_by_tag for controller views
  • Foreign key matching against prop_type_key, prop_state_key, and feature_key columns enables efficient database filtering
  • Extending search requires only data entry and a single line in the form setup concern, maintaining separation between configuration and code

Frequently Asked Questions

What are field keys in PropertyWebBuilder?

Field keys are database records in the pwb_field_keys table that define selectable options for property attributes like type, state, and features. Each field key has a unique global_key used for database storage and filtering, plus JSONB translations for internationalized display labels. They serve as the single source of truth for both search facets and property attribute values.

How does tenant scoping work with field keys?

PropertyWebBuilder uses the acts_as_tenant gem to scope all PwbTenant::FieldKey queries to the current website automatically. When you call get_options_by_tag, the system filters by ActsAsTenant.current_tenant, ensuring that each property website sees only its own configured options. This enables multi-tenant deployments where different sites can have completely different facet options without code changes.

Can I add custom facets without modifying Ruby code?

Yes, for data-only changes. You can add new options to existing facets by inserting records into pwb_field_keys through the admin UI or seed files. However, exposing a completely new facet category (like "energy-label") requires adding one line to app/controllers/concerns/search/form_setup.rb to load the options, plus a view partial to render the UI. The search logic itself handles the new parameters without additional modifications.

How are field key labels internationalized?

Labels are stored in a JSONB translations column within the pwb_field_keys table. The application uses the Mobility gem to expose these through field_key.label, which returns the appropriate translation based on the current locale. When get_options_by_tag returns options to the controller, each object includes the localized label ready for rendering in the visitor's language.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →