This topic applies to XperienCentral versions 10.12.0 and higher.
XperienCentral allows you to make fields of custom content item facets searchable via the Advanced Search UI. This topic explains how the mechanism works and what its restrictions and possibilities are. Note that the API is not specifically about creating custom facets, but rather about how to add custom fields to the index. The ability to turn a field into a facet is just one of the possible options.
The API is annotation-based which means that you do not have to register adapters or anything else at the OSGi level (although it is possible to some extent), rather you have to annotate your content item using the following annotations:
Indexable
Field/ReferField
Document/ReferDocument
The JCR Property annotation is supported as well, but because it is not as powerful and merely exists for backwards compatibility purposes, we recommend you do not explicitly use it to index data.
In This Topic
Tutorial
The API is explained on the basis of a simple example. The following shows the custom media item definition:
public interface RecipeVersion extends MediaItemArticleVersion { public String getName(); public String getDescription(); public Locale getLanguage(); public Country getCountry(); public List<Ingredient> getIngredients(); } public interface Country { public String getName(); public String getCapital(); public String getMotto(); } public interface Ingredient { public String getName(); }
The implementation will return the following:
getName() -> "French Fries"
getDescription -> "Long and yellow deliciousness."
getLanguage() -> Locale("en")
getCountry() ->
getName() -> "France"
getCapital() -> "Paris"
getMotto() -> "Liberté, Egalité, Fraternité"
getIngredients() ->
0: getName() -> "Potatoes"
1: getName() -> "Sunflower oil"
2: getName() -> "Salt"
The following are our index/search requirements:
- The name, description, language, country name, and ingredients should be indexed.
- We should be able to search on name, description and ingredients
- A query that matches the name should cause the recipe to appear higher in the search results.
- The language should be displayed in the user's language (for example "en" should display as either "English" or "Engels").
- We want to have the following custom facets:
- Language
- Country name
- Ingredient names
Let's assume the implementation of this recipe interface annotates getName()
with @Property
but nothing else. If we were to index this content item now, the result of getName()
will be added to the content item's body, but nothing else will happen. This is the same behavior as in XperienCentral versions 10.0.0 through 10.11.1. Because this is very limited, we are going to go further. We start by indexing the simple properties and then index the more complex properties, and finally we'll turn some of the properties into facets.
@Indexable
@Field
@Indexable public interface RecipeVersion extends MediaItemArticleVersion { @Field public String getName(); @Field public String getDescription(); public Locale getLanguage(); public Country getCountry(); public List<Ingredient> getIngredients(); }
stored=true
- Specifies whether this field should be stored. A field that is not stored is not retrievable and usable on the client, but it can be used when searching. The possible values aretrue
andfalse
.indexed=false
-Specifies whether this field should be indexed. A field is indexed by default if it is either boosted, or a facet. The possible values aretrue
andfalse
. There is typically no reason to explicitly set this parameter totrue
.body=false
- By default, a value is not indexed in an aggregate field. Setting this property causes the values to be indexed to "body which makes it possible for the document to be found when searching on the value. The possible values aretrue
andfalse
.heading=false
- This is the same asbody
, except that setting this property totrue
promotes "body" to "heading" which gives the value a slightly bigger boost. The possible values aretrue
andfalse
.boost=0.0
- Setting this property allows this field to influence the containing document's score based on the relevance of the field compared to other fields in the document. A positive boost makes the document score higher, while a negative boost lowers the score. Note that a boost between 0 and 1 sounds like a negative boost, but it is not. Reasonable values for boost are between -1 and 1. A boost of 0 means no boost is used.
@Indexable public interface RecipeVersion extends MediaItemArticleVersion { @Field(heading = true, boost = 1) public String getName(); @Field(stored = false, body = true) public String getDescription(); public Locale getLanguage(); public Country getCountry(); public List<Ingredient> getIngredients(); }
adapter=null
- Adapters allow developers to change the value returned by theannotated
method before it is indexed/stored. Adapters should also be used if a value should be indexed in a language-specific way. An adapter may be- a concrete class, in which case it should have a zero-arguments constructor;
- an interface of which an implementation is exposed via OSGi (whiteboard pattern).
- An adapter may decide to adapt a value to nothing (that is, return
null
), causing no value to be indexed/stored. This is also what happens if an adapt method terminates exceptionally, although in this case a warning is logged as well. - If an annotated method returns
null
, this value is given to the adapter anyway in order to give it the chance to make something out of it.
The adapter interface looks like this:
/** * Class that takes specific values and transforms them into (possibly language specific) other values. * * @see Field */ public interface FieldAdapter<T> { /** * Whether the adapted value can depend on the language. * * @return true or false */ boolean isLanguageSpecific(); /** * Adapts a T to return the value in the specified language. The specified language may be null, * in which case no specific language may be used. An implementation may return null, which is * equivalent to "do not index this field for this language". The returned value is the one that * is stored and is thus what is returned and displayed in Advanced Search. If this adapter is * language specific, its adapt methods will be called for each language and for a null language * to index the value in a language agnostic way (if possible). If it is not language specific, * only the latter is the case. This method is only invoked if this adapter's field is stored. * The returned value should match one of the registered {@link FieldTypeDefinition SOLR types}. * * @param value the value to adapt * @param forLocale the target locale to represent the value in * @return the adapted value, or null if no value should be stored * @see FieldAdapter#adapt(Object, Locale) * @see Field#stored() */ Object adapt(T value, Locale forLocale); }
so, the implementation for a locale looks like this:
public class LocaleFieldAdapter implements FieldAdapter<Locale> { @Override public boolean isLanguageSpecific() { return true; } @Override public Object adapt(Locale value, Locale forLocale) { if (value == null) { // If the language is missing, we can't do anything return null; } if (forLocale == null) { // If there is no target language, just return the language tag return value.getLanguage(); } return value.getDisplayLanguage(forLocale); // Else, return the display language } }
Now we update our recipe to index the language as well:
@Indexable public interface RecipeVersion extends MediaItemArticleVersion { @Field(heading = true, boost = 1) public String getName(); @Field(stored = false, body = true) public String getDescription(); @Field(adapter = LocaleFieldAdapter.class) public Locale getLanguage(); public Country getCountry(); public List<Ingredient> getIngredients(); }
At this point, the following custom data would be indexed:
@Document
@Indexable public interface RecipeVersion extends MediaItemArticleVersion { @Field(heading = true, boost = 1) public String getName(); @Field(stored = false, body = true) public String getDescription(); @Field(adapter = LocaleFieldAdapter.class) public Locale getLanguage(); @Document public Country getCountry(); @Document public List<Ingredient> getIngredients(); } @Indexable // Don't forget to use this annotation here! public interface Country { @ReferField public String getName(); public String getCapital(); public String getMotto(); } @Indexable // Don't forget to use this annotation here! public interface Ingredient { @ReferField(body = true) public String getName(); }
This is straight-forward; two things should be noted:
getIngredients()
returns a list, but you do not need to take care of this explicitly because XperienCentral iterates it into lists for you and considers allIngredient
s in the list instead of the list itself.@Indexable public interface Country { @Field( ... ) @ReferField public String getName(); @Field( ... ) public String getCapital(); @Field( ... ) public String getMotto(); }
At this point, the following custom data would be indexed:
body: ["Long and yellow deliciousness.", "Potatoes", "Sunflower oil", "Salt"]
ingredient names: ["Potatoes", "Sunflower oil", "Salt"] (indexed)
/** * Class that can read specific documents and determine their language. */ public interface LanguageGetter<T> { /** * Extracts the language from the given document. If the document cannot be associated * with a language, null should be returned. * * @param document the document * @return the document's language */ Locale getLanguage(T document); }
So what we would do is create a language getter and add the parameter.
public class RecipeLanguageGetter implements LanguageGetter<RecipeVersion> { @Override public Locale getLanguage(RecipeVersion document) { return document.getLanguage(); } } @Indexable(languageGetter = RecipeLanguageGetter.class) public interface RecipeVersion extends MediaItemArticleVersion { ... }
Now we will index the following:
body: ["Long and yellow deliciousness.", "Potatoes", "Sunflower oil", "Salt"]
body (EN): ["Long and yellow deliciousness.", "Potatoes", "Sunflower oil", "Salt"]
ingredient names: ["Potatoes", "Sunflower oil", "Salt"] (indexed)
Facets
You can turn any field into a facet. All you have to do for this is use the following parameter.
facet=false
- Specifies whether the field should be a facet.
@Indexable(languageGetter = RecipeLanguageGetter.class) public interface RecipeVersion extends MediaItemArticleVersion { @Field(heading = true, boost = 1) public String getName(); @Field(stored = false, body = true) public String getDescription(); @Field(facet = true, adapter = LocaleFieldAdapter.class) public Locale getLanguage(); @Document(inheritLanguage = true) public Country getCountry(); @Document(inheritLanguage = true) public List<Ingredient> getIngredients(); } @Indexable // Don't forget to use this annotation here! public interface Country { @ReferField(facet = true) public String getName(); public String getCapital(); public String getMotto(); } @Indexable // Don't forget to use this annotation here! public interface Ingredient { @ReferField(body = true, facet = true) public String getName(); }
Once you have created a RecipeVersion
with a language, country, and ingredients, the facets will not yet appear in XperienCentral's Advanced Search. This is because we have not yet defined the facets for the Advanced Search. To do this, we need to create a SearchFacetComponentDefinition
:
/** * Extended {@link ComponentDefinition} for components of type {@link SearchFacetComponent} that provides GX * WebManager with configuration information. */ public interface SearchFacetComponentDefinition extends ComponentDefinition { /** * Returns the id of the widget that should be used to display this facet. * * @return the id of the widget to use * @see DojoModule.ModuleType#SEARCH_FACET */ String getWidgetId(); /** * Returns the user friendly title of this facet. If null is returned, * we generate a (user unfriendly) title. * * @param forLocale the locale to get the title in * @return the title */ String getTitle(Locale forLocale); /** * Returns the position the facet should have in the search UI as a number between * 0 and {@link Integer#MAX_VALUE} (both inclusive). All defined facets are ordered * using this number. If two facets define the same position, the order is alphabetical. * * @return the position */ int getPosition(); /** * A map of properties that is handed to the widget. * * @return the properties to send to the widget, may be null */ public Map<String, String> getFacetProperties(); /** * Accepts or denies a specific facet with a certain priority. In the former * case, the facet will be displayed to the user using the properties defined * by this definition. To accept or deny a facet, use the facet's type, owner, * and path. The definition that accepts a facet with the highest priority is * the winner. A negative priority means "I don't want it, go away", a positive * priority says "I want this facet", and a priority of zero means "I don't * really care whether I get it, but I can handle it." * * @param facetDescriptor the descriptor of the facet * @return the priority, positive to accept, negative to reject, zero to don't care */ int accept(SearchFacetDescriptor facetDescriptor); }
This gives XperienCentral some information about which facet widget it should use in the UI and how it should handle it. The title is the text that is shown in the Advanced Search at the top of the widget. The position is used to determine the order of all facets. The default facets have a position of 0 (type), 100 (labels), 200, 300, and so forth. Two facets with the same position are ordered alphabetically, by title. The facet properties are optional and used to transfer server side additional properties to the client. The only property supported is for dateFacetWidget
, whose widget responds to a property future
. If this property is set to true
, the "Future/In de toekomst" radio button is displayed in addition to the default radio buttons.
The method accept()
is used to link a facet to a widget. It can either reject it (returning a negative integer) or accept it with a certain priority. The definition that accepts a facet with the highest priority wins and gets to provide the widget. The data to accept can be extracted from the SearchFacetDescriptor
parameter.
/** * Contains information that can be used to uniquely identify a facet. The facet type is the class * its values have. The owners are the types the facet belongs to. The path consists of the derived * names of the methods annotated that eventually lead to the facet. <i>An example:</i> we annotated * {@link PageVersion#getWebsite()}, which leads to {@link Website#getTitle()}, which returns a * {@link String}. This gives us the following results: <code>type: String.class</code>, <code> * owner: PageVersion.class</code>, <code>path: ["website", "title"]</code>. The owning type is always * the type in which the annotation is present. */ public interface SearchFacetDescriptor { /** * Returns the owning type of the facet. * * @return the owner */ public Class<?> getOwnerType(); /** * Returns the facet values' type. * * @return the type of the values */ public Class<?> getValuesType(); /** * Returns the facet path. * * @return the method path to the facet */ public List<String> getPath(); }
Our implementation of the SearchFacetComponentDefinition
looks like this:
public class RecipeFacetComponentDefinition extends ComponentDefinitionImpl implements SearchFacetComponentDefinition { private final List<String> myPath; private final Map<Locale, String> myTitles; private final int myPosition; public RecipeFacetComponentDefinition(List<String> path, Map<Locale, String> titles, int position) { super(false); myPath = path; myTitles = titles; myPosition = position; } @Override public String getWidgetId() { return "stringFacetWidget"; } @Override public String getTitle(Locale forLocale) { return myTitles.get(forLocale); } @Override public int getPosition() { return myPosition; } @Override public Map<String, String> getFacetProperties() { return null; } @Override public int accept(SearchFacetDescriptor facet) { if (facet.getOwnerType().getName().equals(RecipeVersion.class.getName()) && Collections.indexOfSubList(facet.getPath(), myPath) != -1) { return 10; } return -1; } }
And to actually create the facet definitions, we do the following:
public class Activator extends ComponentBundleActivatorBase { ... private ComponentDefinition[] getComponentDefinitions() { return new ComponentDefinition[] { createFacetComponentDefinition("recipelanguage", Arrays.asList("language"), titleMap("Recipe Language", "Recepttaal"), 1), createFacetComponentDefinition("recipecountry", Arrays.asList("country", "name"), titleMap("Country", "Keuken"), 2), createFacetComponentDefinition("recipeingredient", Arrays.asList("ingredients", "name"), titleMap("Ingredient", "Ingrediënt"), 3) } } private Map<Locale, String> titleMap(final String english, final String dutch) { return new HashMap<Locale, String>(2, 1f) {{ put(Locale.forLanguageTag("en"), english); put(Locale.forLanguageTag("nl"), dutch); }}; } private static RecipeFacetComponentDefinition createFacetComponentDefinition(String facet, List<String> path, Map<Locale, String> titles, int position) { RecipeFacetComponentDefinition definition = new RecipeFacetComponentDefinition(path, titles, position); definition.setId(WCBConstants.BUNDLE_ID + "-" + facet); definition.setName(WCBConstants.BUNDLE_NAME + "-" + facet); definition.setDescription(WCBConstants.BUNDLE_DESCRIPTION + " " + facet); definition.setTypeId(SearchFacetComponentType.class.getName()); definition.setProperties(new Hashtable<String, String>()); definition.setInterfaceClassNames(new String[]{SearchFacetComponent.class.getName()}); definition.setImplementationClassName(SimpleSearchFacetComponent.class.getName()); definition.setDependencies(new ComponentDependency[]{}); definition.setWrapperClassNames(new String[]{}); return definition; } }
Now we're finished. This is what we see after opening the Advanced Search after our recipe
is indexed (we titled it "My recipe"). You will find that if you open the search dialog in Dutch, the facet titles will be the ones we added via the facet definition. Furthermore, "Recepttaal" will have the option "Engels" instead of "English". The complete source and deployable jar can be found in the attachments.
Notes
- One parameter not described is the
extension
parameter:extension=null
- By default, an annotated class is, except for the referred classes, the only class that is scanned for annotations. In most cases this is fine, but in some cases it is not sufficient. For example, if one needs to invoke an external service that is not accessible from within the object or should not be a public/API method. To address, it is possible to define an extension of a class; a class with the annotated class asconstructor
parameter. This extension is scanned in addition to the class and comes therefore with more flexibility. It is possible to define an extension per type in the hierarchy level.- Extensions are treated like any other class, so if an extension is not annotated with
@Indexable
, it is not scanned. - The owner of a field originating from an extension is not the extension class, but rather the class requiring the extension.
- An extension must be a concrete class with a single-argument constructor of the type the extension is for. For example:
public RecipeVersionExtension(RecipeVersion recipe)
.
- Extensions are treated like any other class, so if an extension is not annotated with
It is possible to index result sets from a query as well. To do so, you should create a custom media item that invokes the SQL command that returns the
ResultSet
you want to index. Then, you should create aMap
based on this result set and return this - the returned values will then be indexed with their key as part of the field name. You can use an extension for this. For example:@Field(body = true) public Map<String, String> getFields() { Map<String, String> fields = new HashMap<>(); DataSource dataSource = ...; String selectQuery = ...; try(Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(selectQuery)) { ResultSet rs = stmt.executeQuery(); for (int i = 0; rs.next(); ++i) { fields.put(rs.getMetaData().getColumnName(i), rs.getString(i)); } } catch (SQLException e) { LOG.log(Level.WARNING, "Could not (fully) index resultset", e); } return fields; }
- Both interfaces and classes (including implementations) can be indexed, so implementation-specific data is allowed as well.
- P(ackage-p)rivate/protected methods can be indexed but will be made accessible.
- Annotated methods may not have parameters and may not return
void
. - If a class that is not exported through the
Export-Package
directive in thepom.xml
adds a facet, this facet'sSearchFacetDescriptor
will have owner void.class.
Overview
Packages
nl.gx.webmanager.services.contentindex.annotation
nl.gx.webmanager.services.contentindex.adapter
@Indexable
Use @Indexable
on classes and interfaces.
- This annotation is not inherited.
- There are two parameters.
languageGetter=null
- By default, a document is language agnostic. It is possible to make document indexing website language specific by specifying aLanguageGetter
here.extension=null
- By default, an annotated class is, except for the referred classes, the only class that is scanned for annotations. In most cases this is fine, but in some cases it is not sufficient. For example, if one needs to invoke an external service that is not accessible from within the object, or should not be a public/API method. To fix this, it is possible to define an extension of a class; a class with the annotated class as constructor parameter. This extension is scanned in addition to the class, and comes therefore with more flexibility. It is possible to define an extension per type in the hierarchy level.- Extensions are treated like any other class, so if an extension is not annotated with
@Indexable
, it is not scanned. - The owner of a field originating from an extension is not the extension class, but the class requiring the extension.
- An extension must be a concrete class with a single-argument constructor of the type the extension is for. For example:
public PageVersionExtension(PageVersion page)
.
- Extensions are treated like any other class, so if an extension is not annotated with
@Field
and @ReferField
Use @Field
and @ReferField
on methods to index the return value(s).
- This annotation is inherited and may be overridden.
- Use
@Field
to annotate methods that should be taken into account of the owner class itself is indexed, and@ReferField
for when the declaring class will be part of the indexed document. - They have the following parameters:
stored=true
- Specifies whether this field should be stored. A field that is not stored is not retrievable and usable on the client, but it can be used when searching.indexed=false
- Specifies whether this field should be indexed. A field is indexed by default if it is either boosted or it is a facet. There is typically no reason to explicitly set this parameter totrue
.body=false
- By default, a value is not indexed in an aggregate field. Setting this property causes the values to be indexed tobody
, which makes it so that they can cause a document to be found when searching on the value.heading=false
- This is the same asbody
, except that setting this property to true promotesbody
toheading
, giving the value a slightly bigger boost.boost=0.0
- Setting this property allows this field to influence the containing document's score, based on the relevance of the field compared to other fields in the document. A positive boost makes the document score higher, while a negative boost lowers the score. Note that a boost between 0 and 1 sounds like a negative boost, but it is still a boost. Reasonable values for boost lie between -1 and 1. A boost of 0 means no explicit boost is used.facet=false
- Specifies whether the field should be a facet.adapter=null
- AFieldAdapter
. Adapters allow developers to change the value returned by the annotated method before it is indexed/stored. Adapters should also be used if a value should be indexed in a language-specific way.- An adapter may be
- a concrete class, in which case it should have a zero-arguments constructor;
- an interface of which an implementation is exposed via OSGi (whiteboard pattern).
- An adapter may decide to adapt a value to nothing (that is, return
null
), causing no value to be indexed/stored. This is also what happens if an adapt method terminates exceptionally, although in this case a warning is logged as well. - If an annotated method returns
null
, this value is given to the adapter anyway in order to give it the chance to make something out of it.
- An adapter may be
@Document
and @ReferDocument
Use @Document
and @ReferDocument
on methods to tell the indexer to scan the class of the return value(s).
- This annotation is inherited and may be overridden.
- Use
@Document
to annotate methods that should be taken into account if the owner class itself is indexed, and@ReferDocument
for when the declaring class will be part of the indexed document. - They have the following parameters:
inheritLanguage=false
- If this is set totrue
, the language of the target document will be set/overridden to the language of the current document. This may either be the language explicitly defined for the object declaring the annotation or the language inherited from a parent document. This setting is useful if referred documents should be indexed with the same language as the root document. For example, elements on pages.adapter=null
- ADocumentAdapter
that changes the object whose class will be scanned for indexable properties.- An adapter may be
- a concrete class, in which case it should have a zero-arguments constructor;
- an interface of which an implementation is exposed via OSGi (whiteboard pattern).
- An adapter may decide to adapt a value to nothing (that is, return
null
), causing no value to be scanned. This is also what happens if an adapt method terminates exceptionally, although in this case a warning is logged as well. - If an annotated method returns
null
, this value is given to the adapter anyway in order to give it the chance to make something out of it.
- An adapter may be
A Few More Details
- Multiple (unique) annotations may be used on a single method.
- Annotated methods may not have parameters, and may not return
void
. - Both interfaces and classes (including implementations) can be indexed, so implementation-specific data is allowed as well.
- P(ackage-p)rivate/protected methods can be indexed, but will be made accessible.
- The new method annotations are ignored if their class does not have the
@Indexable
annotation.- If it does, only methods annotated with
@Property
will be indexed. Properties are not stored or indexed, but their value is added to thebody
.- A
@Property
annotated method is only used for indexing if the return value's class is String.class.
- A
- If it does, only methods annotated with
- Methods/adapters may return any value. Primitives are always boxed, and arrays, collections, and maps are interpreted as multiple values. Each value is treated separately and handed to an adapter.
- Adapter instances and language getters are cached in an OSGi-aware cache, therefore reloading your bundle will invalidate the old entries but not the extensions.
- The default XperienCentral widgets have a position of 0, 100, 200, and so forth.
- Supported Solr types are:
int, float, long, double, boolean
(note thatshort
s are not supported)String
Date
Do not use the
@Property
annotation.- It exists only for the sake of backwards compatibility
- It is ignored if the
@Indexable
annotation is present on the declaring class.
- Page metadata will be indexed as well - it will be included in the page version's Solr document providing it is annotated.
Debugging
- If your facet does not appear in the Advanced Search, ensure that:
- you have created one of your annotated content items;
- the value you expect to see is actually used and does not return
null
; - the content item is indexed;
- your path is fully lowercased.
- If a class that is not exported through the
Export-Package
directive in thepom.xml
adds a facet, this facet'sSearchFacetDescriptor
will have ownervoid.class
. - Many things are logged at log level
FINE
. This should give you detailed information about what values are indexed for a specific method, and why.