dimanche 28 mars 2021

How to add extension points in my library?

I've started writing DataFilters a while ago after I discovered and used elastic search with it's wonderful lucene syntax. The idea behind that project was first to learn new stuff but I was also wondering if I could create something similar to work with other datasources.

Long story short, I now have something that work pretty well (I think) with the BCL classes and I now want to extend it to support third party libraries like NodaTime.

The main parts are IFilter interface

using System;

namespace DataFilters
{
    /// <summary>
    /// Defines the basic shape of a filter
    /// </summary>
    public interface IFilter : IEquatable<IFilter>
    {
        /// <summary>
        /// Gets the JSON representation of the filter
        /// </summary>
        /// <returns></returns>
        string ToJson();

        /// <summary>
        /// Computes a new <see cref="IFilter"/> instance which is the exact opposite of the current instance.
        /// </summary>
        /// <returns>The exact opposite of the current instance.</returns>
        IFilter Negate();

#if NETSTANDARD2_1
        public virtual void ToString() => ToJson();
#endif
    }
}

with two implementations : Filter

using DataFilters.Converters;

using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Schema;
using static Newtonsoft.Json.DefaultValueHandling;
using static Newtonsoft.Json.Required;
using System.Text.RegularExpressions;
#if !NETSTANDARD1_3
using System.Text.Json.Serialization;
#endif

namespace DataFilters
{
    /// <summary>
    /// An instance of this class holds a filter
    /// </summary>
#if NETSTANDARD1_3
    [JsonObject]
    [JsonConverter(typeof(FilterConverter))]
#else
    [System.Text.Json.Serialization.JsonConverter(typeof(FilterConverter))]
#endif
    public class Filter : IFilter, IEquatable<Filter>
    {
        /// <summary>
        /// Filter that always returns <c>true</c>
        /// </summary>
        public static Filter True => new Filter(default, default);

        /// <summary>
        /// Pattern that field name should respect.
        /// </summary>
        /// <returns></returns>
        public const string ValidFieldNamePattern = @"[a-zA-Z_]+((\[""[a-zA-Z0-9_]+""]|(\.[a-zA-Z0-9_]+))*)";

        /// <summary>
        /// Regular expression used to validate
        /// </summary>
        /// <returns></returns>
        public static readonly Regex ValidFieldNameRegex = new Regex(ValidFieldNamePattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));

        /// <summary>
        /// Name of the json property that holds the field name
        /// </summary>
        public const string FieldJsonPropertyName = "field";

        /// <summary>
        /// Name of the json property that holds the operator
        /// </summary>
        public const string OperatorJsonPropertyName = "op";

        /// <summary>
        /// Name of the json property that holds the value
        /// </summary>
        public const string ValueJsonPropertyName = "value";

        /// <summary>
        /// <see cref="FilterOperator"/>s that required <see cref="Value"/> to be null.
        /// </summary>
        public static IEnumerable<FilterOperator> UnaryOperators { get; } = new[]{
            FilterOperator.IsEmpty,
            FilterOperator.IsNotEmpty,
            FilterOperator.IsNotNull,
            FilterOperator.IsNull
        };

        /// <summary>
        /// Generates the <see cref="JSchema"/> for the specified <see cref="FilterOperator"/>.
        /// </summary>
        /// <param name="op"></param>
        /// <returns></returns>
        public static JSchema Schema(FilterOperator op)
        {
            JSchema schema;
            switch (op)
            {
                case FilterOperator.Contains:
                case FilterOperator.StartsWith:
                case FilterOperator.EndsWith:
                    schema = new JSchema
                    {
                        Type = JSchemaType.Object,
                        Properties =
                        {
                            [FieldJsonPropertyName] = new JSchema { Type = JSchemaType.String },
                            [OperatorJsonPropertyName] = new JSchema { Type = JSchemaType.String },
                            [ValueJsonPropertyName] = new JSchema { Type = JSchemaType.String }
                        },
                        Required = { FieldJsonPropertyName, OperatorJsonPropertyName }
                    };
                    break;
                case FilterOperator.IsEmpty:
                case FilterOperator.IsNotEmpty:
                case FilterOperator.IsNotNull:
                case FilterOperator.IsNull:
                    schema = new JSchema
                    {
                        Type = JSchemaType.Object,
                        Properties =
                        {
                            [FieldJsonPropertyName] = new JSchema { Type = JSchemaType.String },
                            [OperatorJsonPropertyName] = new JSchema { Type = JSchemaType.String }
                        },
                        Required = { FieldJsonPropertyName, OperatorJsonPropertyName }
                    };
                    break;
                default:
                    schema = new JSchema
                    {
                        Type = JSchemaType.Object,
                        Properties =
                        {
                            [FieldJsonPropertyName] = new JSchema { Type = JSchemaType.String,  },
                            [OperatorJsonPropertyName] = new JSchema { Type = JSchemaType.String },
                            [ValueJsonPropertyName] = new JSchema {
                                Not = new JSchema() { Type = JSchemaType.Null }
                            }
                        },
                        Required = { FieldJsonPropertyName, OperatorJsonPropertyName, ValueJsonPropertyName }
                    };
                    break;
            }
            schema.AllowAdditionalProperties = false;

            return schema;
        }

        /// <summary>
        /// Name of the field  the filter will be applied to
        /// </summary>
#if NETSTANDARD1_3
        [JsonProperty(FieldJsonPropertyName, Required = Always)]
#else
        [JsonPropertyName(FieldJsonPropertyName)]
#endif
        public string Field { get; }

        /// <summary>
        /// Operator to apply to the filter
        /// </summary>
#if NETSTANDARD1_3
        [JsonProperty(OperatorJsonPropertyName, Required = Always)]
        [JsonConverter(typeof(CamelCaseEnumTypeConverter))]
#else
        [JsonPropertyName(OperatorJsonPropertyName)]
        //[System.Text.Json.Serialization.JsonConverter(typeof(FilterOperatorConverter))]
#endif
        public FilterOperator Operator { get; }

        /// <summary>
        /// Value of the filter
        /// </summary>
#if NETSTANDARD1_3
        [JsonProperty(ValueJsonPropertyName,
            Required = AllowNull,
            DefaultValueHandling = IgnoreAndPopulate,
            NullValueHandling = NullValueHandling.Ignore)]
#else
        [JsonPropertyName(ValueJsonPropertyName)]
#endif
        public object Value { get; }

        /// <summary>
        /// Builds a new <see cref="Filter"/> instance.
        /// </summary>
        /// <param name="field">name of the field</param>
        /// <param name="operator"><see cref="Filter"/> to apply</param>
        /// <param name="value">value of the filter</param>
        /// <exception cref="ArgumentOutOfRangeException"><paramref name="field"/> does not conform with <see cref="ValidFieldNamePattern"/></exception>
        public Filter(string field, FilterOperator @operator, object value = null)
        {
            if (!string.IsNullOrEmpty(field) && !ValidFieldNameRegex.IsMatch(field))
            {
                throw new ArgumentOutOfRangeException(nameof(field), field, $"field name is not valid ({ValidFieldNamePattern}).");
            }

            Field = field;
            switch (@operator)
            {
                case FilterOperator.EqualTo when value is null:
                    Operator = FilterOperator.IsNull;
                    break;
                case FilterOperator.NotEqualTo when value is null:
                    Operator = FilterOperator.IsNotNull;
                    break;
                default:
                    Operator = @operator;
                    Value = value;
                    break;
            }
        }

#if NETSTANDARD1_3
        public string ToJson()
        {
            return this.Jsonify(new JsonSerializerSettings());
        }
#else
        public string ToJson() => this.Jsonify();
#endif

        public override string ToString() => ToJson();

        public bool Equals(Filter other)
            => other != null
            && (ReferenceEquals(other, this)
            || (Equals(other.Field, Field) && Equals(other.Operator, Operator) && Equals(other.Value, Value)));

        public override bool Equals(object obj) => Equals(obj as Filter);

#if NETSTANDARD1_3 || NETSTANDARD2_0
        public override int GetHashCode() => (Field, Operator, Value).GetHashCode();
#else
        public override int GetHashCode() => HashCode.Combine(Field, Operator, Value);
#endif

        public IFilter Negate()
        {
            FilterOperator @operator = Operator switch
            {
                FilterOperator.EqualTo => FilterOperator.NotEqualTo,
                FilterOperator.NotEqualTo => FilterOperator.EqualTo,
                FilterOperator.IsNull => FilterOperator.IsNotNull,
                FilterOperator.IsNotNull => FilterOperator.IsNull,
                FilterOperator.LessThan => FilterOperator.GreaterThan,
                FilterOperator.GreaterThan => FilterOperator.LessThan,
                FilterOperator.GreaterThanOrEqual => FilterOperator.LessThanOrEqualTo,
                FilterOperator.StartsWith => FilterOperator.NotStartsWith,
                FilterOperator.NotStartsWith => FilterOperator.StartsWith,
                FilterOperator.EndsWith => FilterOperator.NotEndsWith,
                FilterOperator.NotEndsWith => FilterOperator.EndsWith,
                FilterOperator.Contains => FilterOperator.NotContains,
                FilterOperator.IsEmpty => FilterOperator.IsNotEmpty,
                FilterOperator.IsNotEmpty => FilterOperator.IsEmpty,
                FilterOperator.LessThanOrEqualTo => FilterOperator.GreaterThanOrEqual,
                _ => throw new ArgumentOutOfRangeException(nameof(Operator), "Unknown operator"),
            };
            return new Filter(Field, @operator, Value);
        }

        public bool Equals(IFilter other) => Equals(other as Filter)
;

        public void Deconstruct(out string field, out FilterOperator @operator, out object value)
        {
            field = Field;
            @operator = Operator;
            value = Value;
        }
    }
}

MultiFilter

using DataFilters.Converters;
using Newtonsoft.Json;
using Newtonsoft.Json.Schema;
using System;
using System.Collections.Generic;
using System.Linq;
using static Newtonsoft.Json.DefaultValueHandling;
using static Newtonsoft.Json.Required;

#if !NETSTANDARD1_3
using System.Text.Json.Serialization;
#endif

namespace DataFilters
{
    /// <summary>
    /// An instance of this class holds combination of <see cref="IFilter"/>
    /// </summary>
    [JsonObject]
#if NETSTANDARD1_3
    [JsonConverter(typeof(MultiFilterConverter))]
#else
    [System.Text.Json.Serialization.JsonConverter(typeof(MultiFilterConverter))]
#endif
    public class MultiFilter : IFilter, IEquatable<MultiFilter>
    {
        /// <summary>
        /// Name of the json property that holds filter's filters collection.
        /// </summary>
        public const string FiltersJsonPropertyName = "filters";

        /// <summary>
        /// Name of the json property that holds the composite filter's logic
        /// </summary>
        public const string LogicJsonPropertyName = "logic";

        public static JSchema Schema => new JSchema
        {
            Type = JSchemaType.Object,
            Properties =
            {
                [FiltersJsonPropertyName] = new JSchema { Type = JSchemaType.Array, MinimumItems = 2 },
                [LogicJsonPropertyName] = new JSchema { Type = JSchemaType.String, Default = "and"}
            },
            Required = { FiltersJsonPropertyName },
            AllowAdditionalProperties = false
        };

        /// <summary>
        /// Collections of filters
        /// </summary>
#if NETSTANDARD1_3
        [JsonProperty(PropertyName = FiltersJsonPropertyName, Required = Always)]
#else
        [JsonPropertyName(FiltersJsonPropertyName)]
#endif
        public IEnumerable<IFilter> Filters { get; set; } = Enumerable.Empty<IFilter>();

        /// <summary>
        /// Operator to apply between <see cref="Filters"/>
        /// </summary>
#if NETSTANDARD1_3
        [JsonProperty(PropertyName = LogicJsonPropertyName, DefaultValueHandling = IgnoreAndPopulate)]
        [JsonConverter(typeof(CamelCaseEnumTypeConverter))]
#else
        [JsonPropertyName(LogicJsonPropertyName)]
#endif
        public FilterLogic Logic { get; set; }

        public virtual string ToJson() => this.Jsonify();


        public IFilter Negate()
        {
            MultiFilter filter = new MultiFilter
            {
                Logic = Logic switch
                {
                    FilterLogic.And => FilterLogic.Or,
                    FilterLogic.Or => FilterLogic.And,
                    _ => throw new ArgumentOutOfRangeException($"Unsupported {Logic}")
                },
                Filters = Filters.Select(f => f.Negate())
#if DEBUG
                .ToArray()
#endif
            };

            return filter;
        }
#if NETSTANDARD1_3 || NETSTANDARD2_0
        public override int GetHashCode() => (Logic, Filters).GetHashCode();
#else
        public override int GetHashCode()
        {
            HashCode hash = new HashCode();
            hash.Add(Logic);
            foreach (IFilter filter in Filters)
            {
                hash.Add(filter);
            }
            return hash.ToHashCode();
        }
#endif

        public bool Equals(IFilter other) => Equals(other as MultiFilter);

        public override bool Equals(object obj) => Equals(obj as MultiFilter);

        public bool Equals(MultiFilter other)
            => Logic == other?.Logic
            && Filters.Count() == other?.Filters?.Count()
            && Filters.All(filter => other?.Filters?.Contains(filter) ?? false)
            && (other?.Filters.All(filter => Filters.Contains(filter)) ?? false);
    
}
}

DataFilters.Expressions and DataFilters.Queries are two libraries that I also wrote and that allow to create C# Expressions or WHERE SQLs given an IFilter instance as a input (extension methods).

What i'm trying to do now is to provide an extension point so that I could write a new library (called DataFilters.NodaTime for example) that could handle NodaTime types somehow while deferring everything else to DataFilters.Expressions (a library that I already released).

That extension point should add ability to handle nodatime type but I have no clue how to get started on this

For now, I'm thinking about something like this :

  • create a new library DataFilters.Expressions.NodaTime
  • create a new IFilter extension method in it : it will be tailored to handle NodaTime types.

The goal is to be able to handle NodaTime types both with DataFilters.Expressions and DataFilters.Queries for example.

Could it be a good approach or is there a better way to handle this ?

Thanks in advance to anyone who could help me on this

Aucun commentaire:

Enregistrer un commentaire