Developer forum

Forum » Development » Extend Smart Search options in DW10

Extend Smart Search options in DW10

Marie Louise Veigert
Reply

Hi,

I have client who would like to create a smart search where one criteria is ex "watched 'product A' 7 days ago"
I know the info is possible to get from the trackingservice, but the client would like to setup these queries by themselves when needed.

The options in here - are it possible to extend that? Or any other ideas to solve the request?

I know its possible to extend the index in repositories themselves - so I thought it could be that we could do the same on smart search query.
Or do I need to do it through the "normal" queries with extending the index?

 

Best Regards
Marie Louise


Replies

 
Nicolai Pedersen Dynamicweb Employee
Nicolai Pedersen
Reply
This post has been marked as an answer

Yes, it is extensible. Here’s the full picture:

Extension mechanism The field list comes from UserSmartSearchDataProvider.OnGetRulesDataFields(), which aggregates three sources:

Predefined fields — hardcoded in the provider itself Custom user fields — dynamic user-defined fields Extender fields — from any class implementing UserSmartSearchRule, loaded automatically via AddInManager How to add new fields Create a class that extends UserSmartSearchRule. The AddInManager will discover and load it automatically — no registration needed.

public sealed class MyCustomUserSmartSearchRule : UserSmartSearchRule

{

    public override List<SmartSearchRuleField> GetRuleFields()

    {

        return new List<SmartSearchRuleField>

        {

            new SmartSearchRuleField(

                id: "MyField",

                name: "My Custom Field",

                typeName: typeof(string).FullName,

                controlType: SmartSearchRuleControlType.TextBox,

                groupName: "My Group"

            )

        };

    }


    public override string GetFilter(SmartSearchRule rule)

    {

        // Return SQL WHERE clause fragment based on rule.Operator and rule.Value

        return $"[SomeColumn] = '{rule.Value}'";

    }


    // Optionally override GetTableNames() if you need extra JOIN tables

}

The GetFilter method is responsible for translating the rule into a SQL filter. Look at EcommerceUserSmartSearchRule in Dynamicweb.Ecommerce/SmartSearchProviders/EcommerceUserSmartSearchRule.cs for a full reference implementation with multiple fields, operators, and table joins.


Example

Attached the implementation running the ecommerce related fields in smart search that you can base an implementation on.
 

Additional information:
 

Here's a concrete walkthrough using a simplified but realistic example --- imagine you want to add a "Last login was before/after date" and "Login count >= N" rule:

How GetFilter and GetTableNames work

The contract

The SQL engine builds a query roughly like:

SELECT AccessUserID FROM AccessUser
  {joins from GetTableNames()}
WHERE
  {filter from GetFilter()}
  AND {filter from GetFilter()}   -- one per rule in the smart search

So GetTableNames adds LEFT OUTER JOINs to bring in extra tables, and GetFilter returns the WHERE fragment for one rule.

Simple case --- filtering a column already on AccessUser

No join needed. GetTableNames returns an empty list, GetFilter reads directly from the AccessUser table.

public sealed class LoginActivitySmartSearchRule : UserSmartSearchRule
{
    public LoginActivitySmartSearchRule()
    {
        PREFIX = "_custom_login_";
    }

    private const string LastLogin = "last_login";
    private const string LoginCount = "login_count";

    public override List<SmartSearchRuleField> GetRuleFields() =>
    [
        new SmartSearchRuleField(
            PREFIX + LastLogin,
            "Last Login Date",
            typeof(DateTime).FullName!,
            controlType: SmartSearchRuleControlType.DateCtrl,
            groupName: "Login Activity"),

        new SmartSearchRuleField(
            PREFIX + LoginCount,
            "Total Login Count",
            typeof(int).FullName!,
            controlType: SmartSearchRuleControlType.NumericBox,
            groupName: "Login Activity"),
    ];

    // No extra tables needed --- both columns live on AccessUser already
    public override List<string> GetTableNames(SmartSearchRule rule) => [];

    public override string GetFilter(SmartSearchRule rule)
    {
        var action = GetAction(rule);
        var sql = action switch
        {
            // AccessUser.AccessUserLastLogin is the column name in the DB
            LastLogin  => SmartSearchSqlQueryHelper.GetDateWherePart("AccessUser", "AccessUserLastLogin", rule),
            LoginCount => SmartSearchSqlQueryHelper.GetIntegerWherePart("AccessUserLoginCount", rule),
            _          => "0 = 1"   // fallback: never match unknown actions
        };

        return $"({sql})";
    }
}

GetDateWherePart("AccessUser", "AccessUserLastLogin", rule) translates the rule operator + value into e.g. AccessUser.AccessUserLastLogin > CONVERT(datetime, '2024-01-01', 120).


Complex case --- filtering via a JOIN to another table

When the data you need lives in a separate table, GetTableNames adds the join and GetFilter references the aliased table name.

public sealed class NewsletterSmartSearchRule : UserSmartSearchRule
{
    public NewsletterSmartSearchRule()
    {
        PREFIX = "_custom_newsletter_";
    }

    private const string SubscribedDate = "subscribed_date";

    public override List<SmartSearchRuleField> GetRuleFields() =>
    [
        new SmartSearchRuleField(
            PREFIX + SubscribedDate,
            "Newsletter Subscription Date",
            typeof(DateTime).FullName!,
            controlType: SmartSearchRuleControlType.DateCtrl,
            groupName: "Newsletter"),
    ];

    public override List<string> GetTableNames(SmartSearchRule rule)
    {
        if (GetAction(rule) != SubscribedDate)
            return [];

        // GetTableName(actionName, realTableName, addAlias: true) produces
        // "NewsletterSubscriptions as _custom_newsletter_subscribed_dateNewsletterSubscriptions"
        // which is a unique alias that won't clash with other joins.
        var aliasedTable = GetTableName(SubscribedDate, "NewsletterSubscriptions", addAlias: true);
        var alias        = GetTableName(SubscribedDate, "NewsletterSubscriptions");  // just the alias

        return [$"LEFT OUTER JOIN {aliasedTable} ON {alias}.UserID = AccessUser.AccessUserID"];
    }

    public override string GetFilter(SmartSearchRule rule)
    {
        var alias = GetTableName(SubscribedDate, "NewsletterSubscriptions");  // same alias as above
        var sql = SmartSearchSqlQueryHelper.GetDateWherePart(alias, "SubscribedOn", rule);
        return $"({sql})";
    }
}

The key insight: GetTableName(action, tableName) produces a deterministic alias from your PREFIX + action + real table name. You call it in both GetTableNames (to declare the join) and GetFilter (to reference the alias in the WHERE clause) --- they must match exactly.


Subquery case --- when you need aggregation

If you need a value like "total spend", you can't just join --- you need a subquery that aggregates per user. The join becomes a derived table:

case TotalSpend:
{
    // Subquery aggregates in GetTableNames, result column referenced in GetFilter
    var alias = GetTableName(TotalSpend, "EcomOrders");   // alias for the subquery
    return [$@"LEFT OUTER JOIN (
        SELECT OrderCustomerAccessUserID, SUM(OrderPriceWithVAT) AS total_spend
        FROM EcomOrders
        WHERE OrderComplete = 1
        GROUP BY OrderCustomerAccessUserID
    ) AS {alias} ON {alias}.OrderCustomerAccessUserID = AccessUser.AccessUserID"];
}

Then in GetFilter:

case TotalSpend:
{
    var alias = GetTableName(TotalSpend, "EcomOrders");
    return $"({SmartSearchSqlQueryHelper.GetFloatWherePart(alias, "total_spend", rule)})";
}

Key helpers at a glance

Helper Use for
GetDateWherePart(table, column, rule) Date columns with operator + value
GetIntegerWherePart(column, rule) Integer columns (no table prefix)
GetIntegerWherePart(table, column, rule) Integer columns on a joined table
GetFloatWherePart(column, rule) Float/decimal columns
GetStringWherePart(column, rule) Text columns, supports LIKE
GetBooleanWherePart(column, rule) Bit/bool columns
RenderValue(value) Safely escape a value for inline SQL
RenderOperator(rule.Operator) Translate operator enum to =>< etc.

All helpers handle the full set of SmartSearchRuleOperator values (Equals, NotEqualTo, IsEmpty, IsInRange, IsBefore, IsAfter, etc.) so you don't have to write the operator switch yourself.

Votes for this answer: 1
 
Marie Louise Veigert
Reply

Works !

I just tried with this and it worked on viewed product + viewed within x days:

 

public sealed class ProductViewedSmartSearchRule : UserSmartSearchRule
{
    public ProductViewedSmartSearchRule()
    {
        PREFIX = "_custom_productviewed_";
    }

    private const string ViewedProduct = "viewed_product";
    private const string ViewedWithinDays = "viewed_within_days";

    public override List<SmartSearchRuleField> GetRuleFields() =>
    [
        new SmartSearchRuleField(
            PREFIX + ViewedProduct,
            "Viewed Product",
            typeof(string).FullName!,
            controlType: SmartSearchRuleControlType.ProductCtrl,
            groupName: "Product Activity"),

        new SmartSearchRuleField(
            PREFIX + ViewedWithinDays,
            "Viewed Product Within Days",
            typeof(int).FullName!,
            controlType: SmartSearchRuleControlType.NumericBox,
            groupName: "Product Activity")
    ];

    public override List<string> GetTableNames(SmartSearchRule rule) => [];

    public override string GetFilter(SmartSearchRule rule)
    {
        return GetAction(rule) switch
        {
            ViewedProduct => GetViewedProductFilter(rule),
            ViewedWithinDays => GetViewedWithinDaysFilter(rule),
            _ => "0 = 1"
        };
    }

    private string GetViewedProductFilter(SmartSearchRule rule)
    {
        var notOperator = rule.Operator == SmartSearchRuleOperator.NotEqualTo ? "not " : "";

        var rawValues = JsonSerializer.Deserialize<List<string>>(rule.Value) ?? [];
        var productKeys = rawValues
            .Select(v => v.Split('|'))
            .Where(parts => parts.Length >= 1 && !string.IsNullOrEmpty(parts[0]))
            .Select(parts => new
            {
                ProductId = parts[0],
                VariantId = parts.Length >= 3 ? parts[2] : ""
            })
            .ToList();

        if (productKeys.Count == 0)
            return "0 = 1";

        var sql = new StringBuilder();
        sql.Append($"(AccessUser.AccessUserID {notOperator}IN (");
        sql.Append(" SELECT DISTINCT tv.TrackingViewUserId");
        sql.Append(" FROM TrackingView tv");
        sql.Append(" JOIN TrackingSession ts ON tv.TrackingViewSessionId = ts.TrackingSessionId");
        sql.Append(" WHERE tv.TrackingViewEntityType = 'Product'");
        sql.Append(" AND tv.TrackingViewUserId <> 0");
        sql.Append(" AND tv.TrackingViewUserId IS NOT NULL");
        sql.Append(" AND (");

        var isFirst = true;
        foreach (var key in productKeys)
        {
            if (!isFirst) sql.Append(" OR ");
            else isFirst = false;

            sql.AppendFormat(CultureInfo.InvariantCulture,
                "(tv.TrackingViewEntityKey = {0} AND (tv.TrackingViewEntitySubKey = {1}",
                SmartSearchSqlQueryHelper.RenderValue(key.ProductId),
                SmartSearchSqlQueryHelper.RenderValue(key.VariantId));

            if (string.IsNullOrEmpty(key.VariantId))
                sql.Append(" OR tv.TrackingViewEntitySubKey IS NULL))");
            else
                sql.Append("))");
        }

        sql.Append(")))");
        return sql.ToString();
    }

    private string GetViewedWithinDaysFilter(SmartSearchRule rule)
    {
        if (!int.TryParse(rule.Value, out var days) || days <= 0)
            return "0 = 1";

        var notOperator = rule.Operator == SmartSearchRuleOperator.NotEqualTo ? "not " : "";

        return $@"(AccessUser.AccessUserID {notOperator}IN (
            SELECT DISTINCT tv.TrackingViewUserId
            FROM TrackingView tv
            JOIN TrackingSession ts ON tv.TrackingViewSessionId = ts.TrackingSessionId
            WHERE tv.TrackingViewEntityType = 'Product'
              AND tv.TrackingViewUserId <> 0
              AND tv.TrackingViewUserId IS NOT NULL
              AND tv.TrackingViewTimestamp >= DATEADD(day, -{days}, GETDATE())
        ))";
    }
}

 
Marie Louise Veigert
Reply

New question:
Can you extend the controltype enum? or create your own?

I need to do the same for productgroup and would like the user to be able to select a group instead of typing groupid themselves.

 
Nicolai Pedersen Dynamicweb Employee
Nicolai Pedersen
Reply

You cannot make your own control type.

What are you looking at adding?

 
Marie Louise Veigert
Reply

I would like for the user to be able to select a product group like they can select a product :) 

 
Morten Bengtson Dynamicweb Employee
Morten Bengtson
Reply

You might be able to use a dropdown control and a custom data source which returns product group options.
However, if you have thousands of product groups then it might not be the right solution :)

The following shows how the user group selector is implemented in the standard user smart search and you should be able to implement something similar for product groups...

1. Implement a data source based on ISmartSearchRuleFieldDatasource:

public class UserGroupDataSource : ISmartSearchRuleFieldDatasource
{
    public void SetData(IDictionary<string, string> OwnerData)
    {
    }

    public IDictionary<string, string> GetData()
    {
        Dictionary<string, string> result = new Dictionary<string, string>();

        foreach (UserGroup group in UserManagementServices.UserGroups.GetGroups()) 
        {
            result.Add(Convert.ToString(group.ID, CultureInfo.InvariantCulture), group.Name);
        }

        return result;
    }
}

Modify this to use Dynamicweb.Ecommerce.Services.ProductGroups.GetGroups() and return options based on Group.Id and Group.GetDisplayName().

2. Return a field definition which references the data source and sets the control type to SmartSearchRuleControlType.DropDownList:

new SmartSearchRuleField("Groups", "Group", typeof(int).FullName!, new UserGroupDataSource(), SmartSearchRuleControlType.DropDownList, "User information")

Modify this to use your product group data source from step 1, use typeof(string).FullName and change the field identifier, label and group to what you want.

If you have a lot of data or need more advanced queries then it will probably be better to use index based queries.

 
Marie Louise Veigert
Reply

Thanks for your suggestion. They have A LOT of products and product groups. So I might to test if its to heavy. :) 

 

You must be logged in to post in the forum