Xperience by Kentico's new Content Retrieval and Mapping API is powerful and flexible. But with all things powerful and flexible, there are some complexities, some differences from how things were done in the past, and some awesome hidden features.
Different scenarios require different approaches, so let's get into how to best leverage the API to accomplish your goals.
This post was written for Xperience by Kentico v30.1.0. Please consult the documentation for any feature or API changes when using a different version. Be sure to check the System Requirements in the product documentation.
Content Item and Reusable Schema Retrieval
With anything concerning the Content Item system you're going to need to use the ContentItemQueryBuilder
to retrieve the content. This has the systems in place to handle versioning, proper language fallbacks, and linked items.
You'll also be leveraging the IContentQueryExecutor
and its Results
and/or MappedResults
methods.
Scenario 1: Retrieve a single Content Item Type + Linked Items
The first scenario is probably the most common, you need to get your Content Items and the linked items associated with them.
var getBasicPages = new ContentItemQueryBuilder()
.ForContentType(BasicPage.CONTENT_TYPE_NAME,
query => query
// o=> o.IncludeWebPageData() if items may be WebPageItems and you need the data.
.WithLinkedItems(2, o => o.IncludeWebPageData())
)
.InLanguage(_preferredLanguageRetriever.Get());
var mappedToPage = await _contentQueryExecutor
.GetMappedWebPageResult<BasicPage>(
getBasicPages,
new ContentQueryExecutionOptions()
{
ForPreview = _previewFeature.Enabled
});
if (mappedToPage.Any())
{
var firstItem = mappedToPage.First();
var mappedToPageResult = $"{firstItem.SystemFields.WebPageItemName} - {firstItem.MetaData_Title}";
}
The WithLinkedItems
will make sure that you retrieve additional items in the model, and the GetMappedWebPageResult
will automatically type cast and include your data from your page.
Scenario 2: Retrieve Reusable Schema Data
Sometimes you don't know what specific content type you want, but you know you want the data found in a Reusable Schema. An example may be if you have an IBaseMetadata
that has the Title and description fields you want for a listing page of multiple content items. For this, you can use the GetMappedResult<IYourReusableSchema>
var getMetaData = new ContentItemQueryBuilder()
.ForContentTypes(
query => query
.OfReusableSchema([IBaseMetadata.REUSABLE_FIELD_SCHEMA_NAME])
// o => o.IncludeWebPageData() if items may be WebPageItems and you need the data.
.WithLinkedItems(2, o => o.IncludeWebPageData())
)
.InLanguage(_preferredLanguageRetriever.Get());
var mappedToReusableSchemaInterface = await _contentQueryExecutor
.GetMappedResult<IBaseMetadata>(
getMetaData,
new ContentQueryExecutionOptions()
{
ForPreview = _previewFeature.Enabled
});
if (mappedToReusableSchemaInterface.Any())
{
var firstItem = mappedToReusableSchemaInterface.First();
var mappedMetaDataResults = $"{firstItem.MetaData_Title}";
}
Scenario 3: Retrieve Reusable Schema Data + Check for Additional Model Types
Keep in mind that the Xperience model Mapper will still map each object to the actual registered C# Model created through the --kpx-codegen
scripts, even if you only are mapping to a Reusable Schema interface. Because of this, you can do additional type checking and casting to retrieve additional fields.
In the below example we are retrieving any content item that uses our IBaseMetadata
Reusable Schema and retrieving its OG Image (which is of type IGenericHasImage
, an empty interface that just indicates it has an image). The OG Image can be any content type that has an image and inherits IGenericHasImage
so we need to do some type casting with it.
var getMetaDataWithOtherData = new ContentItemQueryBuilder()
.ForContentTypes(
query => query
.OfReusableSchema([IBaseMetadata.REUSABLE_FIELD_SCHEMA_NAME])
.WithWebPageData()
// o=> o.IncludeWebPageData() if items may be WebPageItems and you need the data.
.WithLinkedItems(2, o => o.IncludeWebPageData())
)
.InLanguage(_preferredLanguageRetriever.Get());
var mappedToReusableSchemaInterfaceThatInheritsFromIWebPageFieldSource = await _contentQueryExecutor
.GetMappedWebPageResult<IBaseMetadata>(
getMetaDataWithOtherData,
new ContentQueryExecutionOptions()
{
ForPreview = _previewFeature.Enabled
});
if (mappedToReusableSchemaInterfaceThatInheritsFromIWebPageFieldSource.Any())
{
var firstItem = mappedToReusableSchemaInterfaceThatInheritsFromIWebPageFieldSource.First();
var mappedMetaDataResults = $"{firstItem.MetaData_Title}";
// Use type casting to expose SystemFields
if (firstItem is IWebPageFieldsSource webPageData)
{
mappedMetaDataResults += $" - {webPageData.SystemFields.WebPageItemTreePath}";
}
// MetaData_OGImage is an empty interface, no fields, but should be assigned to Image Types
if (firstItem.MetaData_OGImage.Any())
{
// Use type checking to get data
if (firstItem.MetaData_OGImage.First() is IContentItemFieldsSource contentItemFieldsSource)
{
mappedMetaDataResults += $" - {contentItemFieldsSource.SystemFields.ContentItemGUID}";
}
if (firstItem.MetaData_OGImage.First() is Generic.Image image)
{
mappedMetaDataResults += $" - {image.ImageTitle}";
}
}
}
Additionally, although this is a little hacky, you can have your Reusable Schema inherit IContentItemFieldSource
or IWebPageFieldSource
to expose those fields if applicable.
if (mappedToReusableSchemaInterfaceThatInheritsFromIWebPageFieldSource.Any())
{
var firstItem = mappedToReusableSchemaInterfaceThatInheritsFromIWebPageFieldSource.First();
// MetaData_OGImage Inherits from IContentItemFieldSource
if (firstItem.MetaData_OGImage.Any())
{
var preMappedResult = $"{firstItem.MetaData_OGImage.First().SystemFields.ContentItemGUID}";
}
}
Section System Example
A common web design pattern for building out pages is the Section Model, where a section is a horizontal portion of a page, with strongly typed models (ex: Hero Banner Section, Card Section, etc.), and your page contains multiple of these sections.
You would have an Interface (ISectionSystem
) that contains a Content Items and Reusable Content selector field, with defined sections Content Types allowed, and from there the user can create/modify/remove/arrange the sections selected.
The Field will be rendered as the following on your model.
public IEnumerable<IContentItemFieldsSource> SectionsSystemSections { get; set; }
However, the mapper will map each section to its appropriate model (ex: HeroBannerSection
, CardSection
).
Here's the code:
var testPage = new ContentItemQueryBuilder()
.ForContentType(Testing.WebPage.CONTENT_TYPE_NAME,
query => query
.WithLinkedItems(100, o => o.IncludeWebPageData())
.ForWebsite(_websiteChannelContext.WebsiteChannelName, includeUrlPath: true)
.Where(where => where.WhereEquals(nameof(WebPageFields.WebPageItemID), currentWebPageItemID))
.TopN(1)
)
.InLanguage(_preferredLanguageRetriever.Get());
var mappedToPage = await _contentQueryExecutor
.GetMappedWebPageResult<Testing.WebPage>(
testPage,
new ContentQueryExecutionOptions()
{
ForPreview = _previewFeature.Enabled
});
if (mappedToPage.Any())
{
var firstItem = mappedToPage.First();
foreach (var section in firstItem.SectionsSystemSections)
{
if (section is Section.TestA testA)
{
var testing = testA.SectionTestAName;
}
else if (section is Section.TestB testB)
{
var testing = testB.SectionTestBName;
}
else if (section is Section.Widget widget)
{
// If you don't see the WebPageItem values,
// make sure your .WithLinkedItems has the optional
// ConfigureOptionsAction (query.WithLinkedItems(100, o=> o.IncludeWebPageData()))
// Partial Widget Page
// <inlinewidgetpage web-page-id="@webPageID" initialize-document-prior="true"></inlinewidgetpage>
// requires the WebPageItemID
var testing = widget.SystemFields.WebPageItemID;
}
}
}
Object / Data Retrieval
If you are retrieving custom module data, there are multiple ways to accomplish this. The primary method will be to use the IInfoProvider<TInfoClass>
interface, but additionally sometimes you will want to run a straight query (using the DataQuery
's CustomQueryText
) and then map.
Warning: Running queries directly against the database, while faster as it skips any of the API logic, also can lead to un-detectable errors.
Make sure use direct queries responsibly, with nameof(MyInfoType.MyFieldName)
so removing fields will result in compile time errors you can detect and correct, as well as make sure to level SQL query parameters instead of direct-injected or string interpolated input if anything is user-driven.
Scenario 1: Retrieve data from one source (not retrieving joined data)
The first scenario is simply retrieving data of a single AbstractInfo<>
model. You can still use the Source method to join for filtering, but you don't need any data except the primary.
// _mediaFileInfoProvider is IInfoProvider<MediaFileInfo> type
var itemsFromOneSource = await _mediaFileInfoProvider.Get()
.Source(
x => x.InnerJoin<MediaLibraryInfo>(
nameof(MediaFileInfo.FileLibraryID),
nameof(MediaLibraryInfo.LibraryID)))
.WhereEquals(nameof(MediaLibraryInfo.LibraryName), "MyLibrary")
.Columns(nameof(MediaFileInfo.FileName))
.GetEnumerableTypedResultAsync();
if (itemsFromOneSource.Any())
{
var firstItem = itemsFromOneSource.First();
var testOneSource = $"{firstItem.FileName}";
}
Scenario 2: Retrieve data from multiple sources
A major departure from previous versions of Kentico is that mapped Info Classes no longer contain any joined fields of other types. To overcome this, you'll have to return the IDataContainer
via the GetDataContainerResultAsync
instead of the GetEnumeratedTypedResultAsync
method.
The IDataContainer
is the equivalent of a DataRow
, and contains the raw data (including join data). You can then use the _____Info.New(myIDataContainer)
to map to multiple sources.
var itemsFromTwoSources = await _mediaFileInfoProvider
.Get()
.Source(
x => x.InnerJoin<MediaLibraryInfo>(
nameof(MediaFileInfo.FileLibraryID),
nameof(MediaLibraryInfo.LibraryID)))
.Columns(
nameof(MediaFileInfo.FileName),
nameof(MediaLibraryInfo.LibraryDisplayName))
.GetDataContainerResultAsync();
var parsedItemsFromTwoSources = itemsFromTwoSources
.Select(x => (mediaFile: MediaFileInfo.New(x), mediaLibrary: MediaLibraryInfo.New(x)));
if (parsedItemsFromTwoSources.Any())
{
var firstItem = parsedItemsFromTwoSources.First();
var testTwoSource = $"{firstItem.mediaFile.FileName} - {firstItem.mediaLibrary.LibraryDisplayName}";
}
Scenario 3: A custom Query (single table)
If you need to run a complex query, or something that returns values you can't easily map, you can leverage the DataQuery
along with the CustomQueryText
property (and optional parameters
), and use the GetDataContainerResultAsync
to retrieve your rows. From here you can leverage the GetValue
or TryGetValue
methods on the IDataContainer
results to get what you need:
string queryText = $"""
select
Count(*) over (partition by {nameof(MediaFileInfo.FileExtension)}) as TotalOfThisFileType,
* from Media_File
""";
var customQuery = new DataQuery
{
CustomQueryText = queryText
};
var dataContainers = await customQuery.GetDataContainerResultAsync();
var parsedItemsFromDataQuery = dataContainers
.Select(x => (MediaFile: MediaFileInfo.New(x), TotalOfType: (int)x.GetValue("TotalOfThisFileType")));
if (parsedItemsFromDataQuery.Any())
{
var firstItem = parsedItemsFromDataQuery.First();
var testsFromQuery = $"{firstItem.MediaFile.FileName} - {firstItem.TotalOfType}";
}
Querying From Different Database: Keep in mind that the DataQuery
has many properties, including the connection string name. So, if you need to run a query against another database all together, you'll want to leverage this class with that ConnectionStringName
property!
Scenario 4: A custom query (multiple tables)
The DataQuery
class is the go-to in almost all cases for running straight queries, except if you wish to return multiple tables in a dataset. From here, you will need to go back to the old ConnectionHelper
, which can be used to get your multiple datasets. From here you can still use the DataRow
to map to fields or get values.
string queryText = $"""
select
Count(*) over (partition by {nameof(MediaFileInfo.FileExtension)}) as TotalOfThisFileType,
* from Media_File
select * from Media_Library
""";
DbDataReader dataReader = await ConnectionHelper
.ExecuteReaderAsync(
queryText,
parameters: [],
queryType: QueryTypeEnum.SQLQuery,
commandBehavior: CommandBehavior.Default,
cancellationToken: default);
var dataSet = dataReader.ToDataSet();
var parsedMediaItemsFromQuery = dataSet
.Tables[0]
.Rows
.Cast<DataRow>()
.Select(x => (MediaFile: MediaFileInfo.New(x), TotalOfType: x.Field<int>("TotalOfThisFileType")));
if (parsedMediaItemsFromQuery.Any())
{
var firstItem = parsedMediaItemsFromQuery.First();
var testsFromQuery = $"{firstItem.MediaFile.FileName} - {firstItem.TotalOfType}";
}
var parsedLibraryItemsFromQuery = dataSet
.Tables[1]
.Rows
.Cast<DataRow>()
.Select(x => MediaLibraryInfo.New(x));
if (parsedLibraryItemsFromQuery.Any())
{
var firstItem = parsedLibraryItemsFromQuery.First();
var testsFromQuery = $"{firstItem.LibraryName}";
}
Here's the ToDataSet
extension I call above on DbDataReader
.
public static DataSet ToDataSet(this DbDataReader reader)
{
var ds = new DataSet();
if (reader is null)
{
ds.Tables.Add(new DataTable());
return ds;
}
// read each data result into a datatable
do
{
var table = new DataTable();
table.Load(reader);
ds.Tables.Add(table);
} while (!reader.IsClosed);
return ds;
}
Conclusion
I hope this little guide helps you in your data retrieval and modeling. Special thanks to Kentico's Principal Software Engineer, Jakub Oczko, who helped field a bunch of questions and these scenarios.