diff --git a/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorAnnotation.cs b/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorAnnotation.cs new file mode 100644 index 000000000..df70a104e --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorAnnotation.cs @@ -0,0 +1,172 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +using System; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Migrations +{ + public class SearchVectorAnnotation + { + public string Name { get; set; } + public TextSearchRegconfig Config { get; set; } + public KeyedCollection ComponentGroupsByLabel { get; set; } + + public SearchVectorAnnotation() + { + ComponentGroupsByLabel = new SearchVectorComponentGroup.KeyedCollection(); + } + + public string Serialize() + { + var builder = new StringBuilder(); + builder.Append(Name); + builder.Append(':'); + builder.Append(Config.Name); + builder.Append(':'); + builder.Append(Config.IsPropertyOrColumnName); + + foreach (var group in ComponentGroupsByLabel) + { + builder.Append(':'); + builder.Append(group.Label); + builder.Append('-'); + + foreach (var component in group.Components) + { + builder.Append(component.Name); + builder.Append('!'); + builder.Append(component.DefaultSqlValue); + builder.Append(','); + } + + builder.Length--; + } + + return builder.ToString(); + } + + public static SearchVectorAnnotation Deserialize([NotNull] string serialized) + { + if (string.IsNullOrWhiteSpace(serialized)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(serialized)); + } + + var annotationParts = serialized.Split(new[] { ':' }, 5); + if (annotationParts.Length != 4) + { + throw new ArgumentException("Invalid serialized value: " + serialized); + } + + var searchVectorProperty = new SearchVectorAnnotation + { + Name = annotationParts[0], + Config = new TextSearchRegconfig(annotationParts[1], annotationParts[2] == true.ToString()), + }; + + var componentGroups = annotationParts[3].Split(':').Select( + x => + { + var componentGroupParts = x.Split(new[] { '-' }, 2); + if (componentGroupParts.Length != 2) + { + throw new ArgumentException("Invalid serialized value: " + serialized); + } + + var components = componentGroupParts[1].Split(',') + .Select( + s => + { + var componentParts = s.Split(new[] { '!' }, 2); + if (componentParts.Length != 2) + { + throw new ArgumentException("Invalid serialized value: " + serialized); + } + + return new SearchVectorComponent(componentParts[0], componentParts[1]); + }); + + var componentGroup = new SearchVectorComponentGroup(componentGroupParts[0][0]); + foreach (var component in components) + { + componentGroup.Components.Add(component); + } + + return componentGroup; + }); + + foreach (var group in componentGroups) + { + searchVectorProperty.ComponentGroupsByLabel.Add(group); + } + + return searchVectorProperty; + } + } + + [SuppressMessage("ReSharper", "UnusedTypeParameter", Justification = "Used by extension methods.")] + public class SearchVectorAnnotation : SearchVectorAnnotation where TEntity : class { } + + public static class SearchVectorPropertyExtensions + { + public static SearchVectorAnnotation Add( + this SearchVectorAnnotation searchVector, + Expression> propertyExpression) where TEntity : class => + searchVector.Add(propertyExpression, NpgsqlFullTextSearchLabel.Default); + + public static SearchVectorAnnotation Add( + this SearchVectorAnnotation searchVector, + Expression> propertyExpression, + NpgsqlFullTextSearchLabel label) where TEntity : class => + searchVector.Add(propertyExpression.GetPropertyAccess().Name, label); + + public static TSearchVectorProperty Add( + this TSearchVectorProperty searchVector, + string propertyName) where TSearchVectorProperty : SearchVectorAnnotation => + searchVector.Add(propertyName, NpgsqlFullTextSearchLabel.Default); + + public static TSearchVectorProperty Add( + this TSearchVectorProperty searchVector, + string propertyName, + NpgsqlFullTextSearchLabel label) where TSearchVectorProperty : SearchVectorAnnotation + { + var searchVectorComponentGroup = searchVector.ComponentGroupsByLabel[label]; + if (searchVectorComponentGroup == null) + { + searchVectorComponentGroup = new SearchVectorComponentGroup(label); + searchVector.ComponentGroupsByLabel.Add(searchVectorComponentGroup); + } + + searchVectorComponentGroup.Components.Add(new SearchVectorComponent(propertyName, string.Empty)); + return searchVector; + } + } +} diff --git a/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorComponent.cs b/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorComponent.cs new file mode 100644 index 000000000..a1ae59933 --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorComponent.cs @@ -0,0 +1,37 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Migrations +{ + public readonly struct SearchVectorComponent + { + public string Name { get; } + public string DefaultSqlValue { get; } + + public SearchVectorComponent(string name, string defaultSqlValue) + { + Name = name; + DefaultSqlValue = defaultSqlValue ?? string.Empty; + } + } +} diff --git a/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorComponentGroup.cs b/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorComponentGroup.cs new file mode 100644 index 000000000..80f2354f1 --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/Migrations/SearchVectorComponentGroup.cs @@ -0,0 +1,45 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Migrations +{ + public class SearchVectorComponentGroup + { + public char Label { get; } + public IList Components { get; set; } + + public SearchVectorComponentGroup(char label) + { + Label = label; + Components = new List(); + } + + internal class KeyedCollection : KeyedCollection + { + protected override char GetKeyForItem(SearchVectorComponentGroup item) => item.Label; + } + } +} diff --git a/src/EFCore.PG/FullTextSearch/Migrations/TextSearchRegconfig.cs b/src/EFCore.PG/FullTextSearch/Migrations/TextSearchRegconfig.cs new file mode 100644 index 000000000..664f959cf --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/Migrations/TextSearchRegconfig.cs @@ -0,0 +1,50 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +using System; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Migrations +{ + public struct TextSearchRegconfig + { + public string Name { get; } + public bool IsPropertyOrColumnName { get; } + + internal TextSearchRegconfig(string name, bool isPropertyOrColumnName) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); + } + + Name = name; + IsPropertyOrColumnName = isPropertyOrColumnName; + } + + public static TextSearchRegconfig FromRegistered(string name) => new TextSearchRegconfig(name, false); + + public static TextSearchRegconfig FromProperty(string propertyName) => new TextSearchRegconfig(propertyName, true); + + public static implicit operator TextSearchRegconfig(string name) => FromRegistered(name); + } +} diff --git a/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchEntityTypeBuilderExtensions.cs b/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchEntityTypeBuilderExtensions.cs new file mode 100644 index 000000000..ae177a097 --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchEntityTypeBuilderExtensions.cs @@ -0,0 +1,98 @@ +#region License +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +#endregion + +using System; +using System.Linq.Expressions; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations; +using NpgsqlTypes; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore +{ + public static class NpgsqlFullTextSearchEntityTypeBuilderExtensions + { + public static EntityTypeBuilder ForNpgsqlHasTextSearchVector( + [NotNull] this EntityTypeBuilder builder, + [NotNull] Expression> expression, + Expression> textSearchConfigExpression, + string indexMethod, + [NotNull] Action> configureSearchVector) where TEntity : class => + builder.ForNpgsqlHasTextSearchVector( + expression, + TextSearchRegconfig.FromProperty(textSearchConfigExpression.GetPropertyAccess().Name), + indexMethod, + configureSearchVector); + + public static EntityTypeBuilder ForNpgsqlHasTextSearchVector( + [NotNull] this EntityTypeBuilder builder, + [NotNull] Expression> expression, + TextSearchRegconfig textSearchConfig, + string indexMethod, + [NotNull] Action> configureSearchVector) where TEntity : class => + builder.ForNpgsqlHasTextSearchVector( + expression.GetPropertyAccess().Name, + textSearchConfig, + indexMethod, + configureSearchVector) as EntityTypeBuilder; + + public static EntityTypeBuilder ForNpgsqlHasTextSearchVector( + [NotNull] this EntityTypeBuilder builder, + [NotNull] string propertyName, + TextSearchRegconfig textSearchConfig, + string indexMethod, + [NotNull] Action configureSearchVector) => + builder.ForNpgsqlHasTextSearchVector( + propertyName, + textSearchConfig, + indexMethod, + configureSearchVector); + + static EntityTypeBuilder ForNpgsqlHasTextSearchVector( + [NotNull] this EntityTypeBuilder builder, + [NotNull] string propertyName, + TextSearchRegconfig textSearchConfig, + string indexMethod, + [NotNull] Action configureSearchVector) + where TSearchVectorAnnotation : SearchVectorAnnotation, new() + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + if (configureSearchVector == null) throw new ArgumentNullException(nameof(configureSearchVector)); + + var annotation = new TSearchVectorAnnotation + { + Name = propertyName, + Config = textSearchConfig + }; + configureSearchVector(annotation); + + builder.Property(propertyName); + builder.Metadata.Npgsql().SearchVectors[propertyName] = annotation; + builder.HasIndex(propertyName).ForNpgsqlHasMethod(indexMethod); + + return builder; + } + } +} diff --git a/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchLabel.cs b/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchLabel.cs new file mode 100644 index 000000000..3733ca3cb --- /dev/null +++ b/src/EFCore.PG/FullTextSearch/NpgsqlFullTextSearchLabel.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore +{ + public readonly struct NpgsqlFullTextSearchLabel + { + public static readonly NpgsqlFullTextSearchLabel A = 'A'; + public static readonly NpgsqlFullTextSearchLabel B = 'B'; + public static readonly NpgsqlFullTextSearchLabel C = 'C'; + public static readonly NpgsqlFullTextSearchLabel D = 'D'; + public static readonly NpgsqlFullTextSearchLabel Default = D; + + public char Label { get; } + + public NpgsqlFullTextSearchLabel(char label) + { + Label = label; + } + + public static implicit operator NpgsqlFullTextSearchLabel(char label) => new NpgsqlFullTextSearchLabel(label); + public static implicit operator char(NpgsqlFullTextSearchLabel label) => label.Label; + } +} diff --git a/src/EFCore.PG/Metadata/INpgsqlEntityTypeAnnotations.cs b/src/EFCore.PG/Metadata/INpgsqlEntityTypeAnnotations.cs index 1081ef1f1..b7e68468e 100644 --- a/src/EFCore.PG/Metadata/INpgsqlEntityTypeAnnotations.cs +++ b/src/EFCore.PG/Metadata/INpgsqlEntityTypeAnnotations.cs @@ -23,6 +23,7 @@ using System.Collections.Generic; using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata { @@ -32,5 +33,6 @@ public interface INpgsqlEntityTypeAnnotations : IRelationalEntityTypeAnnotations Dictionary GetStorageParameters(); string Comment { get; } CockroachDbInterleaveInParent CockroachDbInterleaveInParent { get; } + Dictionary SearchVectors { get; } } } diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs index 5bf3aa631..b2f166dad 100644 --- a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs +++ b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs @@ -39,6 +39,7 @@ public static class NpgsqlAnnotationNames public const string Tablespace = Prefix + "Tablespace"; public const string StorageParameterPrefix = Prefix + "StorageParameter:"; public const string Comment = Prefix + "Comment"; + public const string SearchVectorPrefix = Prefix + "SearchVector:"; // Database model annotations diff --git a/src/EFCore.PG/Metadata/NpgsqlEntityTypeAnnotations.cs b/src/EFCore.PG/Metadata/NpgsqlEntityTypeAnnotations.cs index 3a5b7f188..567a07566 100644 --- a/src/EFCore.PG/Metadata/NpgsqlEntityTypeAnnotations.cs +++ b/src/EFCore.PG/Metadata/NpgsqlEntityTypeAnnotations.cs @@ -26,6 +26,7 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Utilities; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata @@ -35,11 +36,13 @@ public class NpgsqlEntityTypeAnnotations : RelationalEntityTypeAnnotations, INpg public NpgsqlEntityTypeAnnotations([NotNull] IEntityType entityType) : base(entityType) { + SearchVectors = new Dictionary(); } public NpgsqlEntityTypeAnnotations([NotNull] RelationalAnnotations annotations) : base(annotations) { + SearchVectors = new Dictionary(); } public bool SetStorageParameter(string parameterName, object parameterValue) @@ -56,8 +59,7 @@ public Dictionary GetStorageParameters() public virtual string Comment { get => (string)Annotations.Metadata[NpgsqlAnnotationNames.Comment]; - [param: CanBeNull] - set => SetComment(value); + [param: CanBeNull] set => SetComment(value); } protected virtual bool SetComment([CanBeNull] string value) @@ -67,5 +69,7 @@ protected virtual bool SetComment([CanBeNull] string value) public virtual CockroachDbInterleaveInParent CockroachDbInterleaveInParent => new CockroachDbInterleaveInParent(EntityType); + + public Dictionary SearchVectors { get; } } } diff --git a/src/EFCore.PG/Migrations/Internal/NpgsqlMigrationsAnnotationProvider.cs b/src/EFCore.PG/Migrations/Internal/NpgsqlMigrationsAnnotationProvider.cs index 0a33afb9a..84ca9747a 100644 --- a/src/EFCore.PG/Migrations/Internal/NpgsqlMigrationsAnnotationProvider.cs +++ b/src/EFCore.PG/Migrations/Internal/NpgsqlMigrationsAnnotationProvider.cs @@ -45,15 +45,77 @@ public override IEnumerable For(IEntityType entityType) { if (entityType.Npgsql().Comment != null) yield return new Annotation(NpgsqlAnnotationNames.Comment, entityType.Npgsql().Comment); + if (entityType[CockroachDbAnnotationNames.InterleaveInParent] != null) - yield return new Annotation(CockroachDbAnnotationNames.InterleaveInParent, entityType[CockroachDbAnnotationNames.InterleaveInParent]); + yield return new Annotation( + CockroachDbAnnotationNames.InterleaveInParent, + entityType[CockroachDbAnnotationNames.InterleaveInParent]); + foreach (var storageParamAnnotation in entityType.GetAnnotations() .Where(a => a.Name.StartsWith(NpgsqlAnnotationNames.StorageParameterPrefix))) { yield return storageParamAnnotation; } + + foreach (var annotation in GetSearchVectorAnnotations(entityType)) + { + yield return annotation; + } + } + + static IEnumerable GetSearchVectorAnnotations(IEntityType entityType) + { + var searchVectors = entityType.Npgsql().SearchVectors; + foreach (var searchVector in searchVectors) + { + var translatedSearchVector = new SearchVectorAnnotation + { + Name = FindColumnNameOrThrow(entityType, searchVector.Key), + Config = searchVector.Value.Config.IsPropertyOrColumnName + ? new TextSearchRegconfig( + FindColumnNameOrThrow(entityType, searchVector.Value.Config.Name), + true) + : searchVector.Value.Config + }; + + foreach (var group in searchVector.Value.ComponentGroupsByLabel.Select( + x => new SearchVectorComponentGroup(x.Label) + { + Components = x.Components.Select( + p => new SearchVectorComponent( + FindColumnNameOrThrow(entityType, p.Name), + FindDefaultSqlValueOrThrow(entityType, p.Name))).ToList() + })) + { + translatedSearchVector.ComponentGroupsByLabel.Add(group); + } + + yield return new Annotation( + NpgsqlAnnotationNames.SearchVectorPrefix + searchVector.Key, + translatedSearchVector.Serialize()); + } + } + + static string FindColumnNameOrThrow(IEntityType entityType, string propertyName) => + FindPropertyOrThrow(entityType, propertyName).Relational().ColumnName; + + static string FindDefaultSqlValueOrThrow(IEntityType entityType, string propertyName) + { + var property = FindPropertyOrThrow(entityType, propertyName).Relational(); + if (property.ColumnType.Equals("json", StringComparison.OrdinalIgnoreCase) + || property.ColumnType.Equals("jsonb", StringComparison.OrdinalIgnoreCase)) + { + return "{}"; + } + + return ""; } + static IProperty FindPropertyOrThrow(IEntityType entityType, string propertyName) => + entityType.FindProperty(propertyName) + ?? throw new InvalidOperationException( + $"Failed to find property '{propertyName}' in entity type '{entityType.Name}'"); + public override IEnumerable For(IProperty property) { if (property.Npgsql().ValueGenerationStrategy == NpgsqlValueGenerationStrategy.SerialColumn) diff --git a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs index cfd966b84..d04c81359 100644 --- a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs +++ b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs @@ -66,7 +66,72 @@ protected override void Generate(MigrationOperation operation, [CanBeNull] IMode return; } + Dictionary oldSearchVectorValuesByPropertyName = null; + Dictionary newSearchVectorValuesByPropertyName = null; + var alterTable = operation as AlterTableOperation; + if (alterTable != null) + { + newSearchVectorValuesByPropertyName = GetSearchVectorValuesByPropertyName(alterTable); + oldSearchVectorValuesByPropertyName = GetSearchVectorValuesByPropertyName(alterTable.OldTable); + + foreach (var oldSearchVectorPropertyNameAndValue in oldSearchVectorValuesByPropertyName) + { + if (!newSearchVectorValuesByPropertyName.ContainsKey(oldSearchVectorPropertyNameAndValue.Key)) + { + DropFullTextSearchVectorTriggerAndFunction( + alterTable.Schema, + alterTable.Name, + SearchVectorAnnotation.Deserialize(oldSearchVectorPropertyNameAndValue.Value), + builder); + } + } + } + base.Generate(operation, model, builder); + + if (operation is CreateTableOperation createTable) + { + var searchVectorValuesByPropertyName = GetSearchVectorValuesByPropertyName(createTable); + + foreach (var pair in searchVectorValuesByPropertyName) + { + var searchVector = SearchVectorAnnotation.Deserialize(pair.Value); + CreateFullTextSearchVector(createTable.Schema, createTable.Name, searchVector, builder); + } + } + + if (alterTable != null) + { + foreach (var newSearchVectorPropertyNameAndValue in newSearchVectorValuesByPropertyName) + { + if (!oldSearchVectorValuesByPropertyName.TryGetValue( + newSearchVectorPropertyNameAndValue.Key, + out var oldSearchVectorValue)) + { + CreateFullTextSearchVector( + alterTable.Schema, + alterTable.Name, + SearchVectorAnnotation.Deserialize(newSearchVectorPropertyNameAndValue.Value), + builder); + } + else if (!oldSearchVectorValue.Equals( + newSearchVectorPropertyNameAndValue.Value, + StringComparison.OrdinalIgnoreCase)) + { + DropFullTextSearchVectorTriggerAndFunction( + alterTable.Schema, + alterTable.Name, + SearchVectorAnnotation.Deserialize(oldSearchVectorValue), + builder); + + CreateFullTextSearchVector( + alterTable.Schema, + alterTable.Name, + SearchVectorAnnotation.Deserialize(newSearchVectorPropertyNameAndValue.Value), + builder); + } + } + } } #region Standard migrations @@ -983,5 +1048,144 @@ static string GenerateStorageParameterValue(object value) } #endregion Storage parameter utilities + + #region Full text search vector utilities + + static Dictionary GetSearchVectorValuesByPropertyName(IAnnotatable annotatable) => annotatable + .GetAnnotations() + .Where(a => a.Name.StartsWith(NpgsqlAnnotationNames.SearchVectorPrefix)) + .ToDictionary( + a => a.Name.Substring(NpgsqlAnnotationNames.SearchVectorPrefix.Length), + a => a.Value?.ToString() ?? string.Empty); + + void CreateFullTextSearchVector( + string schema, + [NotNull] string tableName, + [NotNull] SearchVectorAnnotation searchVector, + [NotNull] MigrationCommandListBuilder builder) + { + if (tableName == null) throw new ArgumentNullException(nameof(tableName)); + if (searchVector == null) throw new ArgumentNullException(nameof(searchVector)); + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + var triggerFunctionName = GetFullTextSearchTriggerFunctionName(tableName, searchVector.Name); + CreateTsvectorTrigger(schema, tableName, triggerFunctionName, searchVector, builder); + } + + void CreateTsvectorTrigger( + string schema, + string tableName, + string functionName, + SearchVectorAnnotation searchVector, + MigrationCommandListBuilder builder) + { + // Trigger function + builder.Append("CREATE FUNCTION "); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(functionName, schema)); + builder.Append("() RETURNS trigger AS $$"); + builder.AppendLine(); + builder.AppendLine("BEGIN"); + + using (builder.Indent()) + { + builder.Append("new."); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(searchVector.Name)); + builder.Append(" := "); + builder.AppendLine(); + + using (builder.Indent()) + { + AppendColumnTsvectorStatements(searchVector, builder); + } + + builder.AppendLine(";"); + builder.AppendLine("return new;"); + } + + builder.AppendLine("END"); + builder.AppendLine("$$ LANGUAGE plpgsql;"); + builder.EndCommand(); + + // Trigger + builder.Append("CREATE TRIGGER "); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(functionName + "_update")); + builder.Append(" BEFORE INSERT OR UPDATE ON "); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema)); + builder.Append(" FOR EACH ROW EXECUTE PROCEDURE "); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(functionName, schema)); + builder.AppendLine("();"); + builder.EndCommand(); + + // Update to populate the search vector via the trigger + var chosenColumn = searchVector.ComponentGroupsByLabel.First(x => x.Components.Count > 0).Components.First().Name; + builder.Append("UPDATE "); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema)); + builder.Append(" SET "); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(chosenColumn)); + builder.Append(" = "); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(chosenColumn)); + builder.Append(";"); + builder.EndCommand(); + } + + void AppendColumnTsvectorStatements(SearchVectorAnnotation searchVector, MigrationCommandListBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + const string separator = " || "; + var stringBuilder = new StringBuilder(); + + var anyFound = false; + foreach (var group in searchVector.ComponentGroupsByLabel.Where(x => x.Components != null)) + { + foreach (var column in group.Components) + { + var configName = searchVector.Config.Name; + stringBuilder.AppendFormat( + "setweight(to_tsvector({0}::regconfig, coalesce(new.{1}, {2})), {3})", + searchVector.Config.IsPropertyOrColumnName + ? $"new.{Dependencies.SqlGenerationHelper.DelimitIdentifier(searchVector.Config.Name)}" + : GenerateSqlStringLiteral(configName), + Dependencies.SqlGenerationHelper.DelimitIdentifier(column.Name), + GenerateSqlStringLiteral(column.DefaultSqlValue ?? string.Empty), + GenerateSqlStringLiteral(group.Label.ToString())); + stringBuilder.Append(separator); + anyFound = true; + } + } + + if (!anyFound) + { + throw new ArgumentException( + "At least one column must be specified in any search vector component group.", + nameof(searchVector)); + } + + stringBuilder.Length -= separator.Length; + builder.Append(stringBuilder.ToString()); + } + + void DropFullTextSearchVectorTriggerAndFunction( + string schema, + [NotNull] string tableName, + [NotNull] SearchVectorAnnotation searchVector, + [NotNull] MigrationCommandListBuilder builder) + { + var triggerFunctionName = GetFullTextSearchTriggerFunctionName(tableName, searchVector.Name); + builder.Append("DROP FUNCTION IF EXISTS "); + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(triggerFunctionName, schema)); + builder.AppendLine("() CASCADE;"); + builder.EndCommand(); + } + + static string GetFullTextSearchTriggerFunctionName(string tableName, string searchVectorColumnName) => + $"{tableName.ToLowerInvariant()}_{searchVectorColumnName.ToLowerInvariant()}_trigger"; + + string GenerateSqlStringLiteral(string value) + { + return Dependencies.TypeMappingSource.GetMapping(typeof(string)).GenerateSqlLiteral(value); + } + + #endregion } } diff --git a/test/EFCore.PG.FunctionalTests/NpgsqlMigrationSqlGeneratorTest.cs b/test/EFCore.PG.FunctionalTests/NpgsqlMigrationSqlGeneratorTest.cs index aa0907793..3bbdf6e7f 100644 --- a/test/EFCore.PG.FunctionalTests/NpgsqlMigrationSqlGeneratorTest.cs +++ b/test/EFCore.PG.FunctionalTests/NpgsqlMigrationSqlGeneratorTest.cs @@ -6,8 +6,10 @@ using Microsoft.EntityFrameworkCore.Migrations.Operations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Operations; using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; +using NpgsqlTypes; using Xunit; namespace Npgsql.EntityFrameworkCore.PostgreSQL @@ -712,6 +714,220 @@ public void AlterTable_change_storage_parameters() #endregion + #region PostgreSQL Search Vectors + + [Fact] + public void CreateTableOperation_with_SearchVector() + { + var createTable = new CreateTableOperation + { + Name = "Customers", + Schema = "dbo", + Columns = + { + new AddColumnOperation + { + Name = "Id", + Table = "Customers", + ClrType = typeof(int), + IsNullable = false + }, + new AddColumnOperation + { + Name = "Name", + Table = "Customers", + ClrType = typeof(string), + IsNullable = false + }, + new AddColumnOperation + { + Name = "SearchVector", + Table = "Customers", + ClrType = typeof(NpgsqlTsVector), + IsNullable = false + } + } + }; + + var searchVectorProperty = new SearchVectorAnnotation + { + Name = "SearchVector", + Config = TextSearchRegconfig.FromRegistered("english"), + ComponentGroupsByLabel = + { + new SearchVectorComponentGroup('A') + { + Components = new[] + { + new SearchVectorComponent("Name", string.Empty), + } + } + } + }; + createTable.AddAnnotation( + NpgsqlAnnotationNames.SearchVectorPrefix + searchVectorProperty.Name, + searchVectorProperty.Serialize()); + + Generate(createTable); + Assert.Equal( + @"CREATE TABLE dbo.""Customers"" ( + ""Id"" integer NOT NULL, + ""Name"" text NOT NULL, + ""SearchVector"" tsvector NOT NULL +); +GO + +CREATE FUNCTION dbo.customers_searchvector_trigger() RETURNS trigger AS $$ +BEGIN + new.""SearchVector"" := + setweight(to_tsvector('english'::regconfig, coalesce(new.""Name"", '')), 'A'); + return new; +END +$$ LANGUAGE plpgsql; +GO + +CREATE TRIGGER customers_searchvector_trigger_update BEFORE INSERT OR UPDATE ON dbo.""Customers"" FOR EACH ROW EXECUTE PROCEDURE dbo.customers_searchvector_trigger(); +GO + +UPDATE dbo.""Customers"" SET ""Name"" = ""Name"";", + Sql); + } + + [Fact] + public void AlterTableOperation_with_SearchVector_create() + { + var alterTable = new AlterTableOperation + { + Name = "Customers", + Schema = "dbo" + }; + + var searchVectorProperty = new SearchVectorAnnotation + { + Name = "SearchVector", + Config = TextSearchRegconfig.FromRegistered("english"), + ComponentGroupsByLabel = + { + new SearchVectorComponentGroup('A') + { + Components = new[] + { + new SearchVectorComponent("Name", string.Empty), + } + } + } + }; + alterTable.AddAnnotation( + NpgsqlAnnotationNames.SearchVectorPrefix + searchVectorProperty.Name, + searchVectorProperty.Serialize()); + + Generate(alterTable); + Assert.Equal( + @"CREATE FUNCTION dbo.customers_searchvector_trigger() RETURNS trigger AS $$ +BEGIN + new.""SearchVector"" := + setweight(to_tsvector('english'::regconfig, coalesce(new.""Name"", '')), 'A'); + return new; +END +$$ LANGUAGE plpgsql; +GO + +CREATE TRIGGER customers_searchvector_trigger_update BEFORE INSERT OR UPDATE ON dbo.""Customers"" FOR EACH ROW EXECUTE PROCEDURE dbo.customers_searchvector_trigger(); +GO + +UPDATE dbo.""Customers"" SET ""Name"" = ""Name"";", + Sql); + } + + [Fact] + public void AlterTableOperation_with_SearchVector_drop() + { + var alterTable = new AlterTableOperation + { + Name = "Customers", + Schema = "dbo" + }; + + var searchVectorProperty = new SearchVectorAnnotation + { + Name = "SearchVector", + Config = TextSearchRegconfig.FromRegistered("english"), + ComponentGroupsByLabel = + { + new SearchVectorComponentGroup('A') + { + Components = new[] + { + new SearchVectorComponent("Name", string.Empty), + } + } + } + }; + alterTable.OldTable.AddAnnotation( + NpgsqlAnnotationNames.SearchVectorPrefix + searchVectorProperty.Name, + searchVectorProperty.Serialize()); + + Generate(alterTable); + Assert.Equal("DROP FUNCTION IF EXISTS dbo.customers_searchvector_trigger() CASCADE;", Sql.Trim()); + } + + [Fact] + public void AlterTableOperation_with_SearchVector_update() + { + var alterTable = new AlterTableOperation + { + Name = "Customers", + Schema = "dbo" + }; + + var searchVectorProperty = new SearchVectorAnnotation + { + Name = "SearchVector", + Config = TextSearchRegconfig.FromRegistered("english"), + ComponentGroupsByLabel = + { + new SearchVectorComponentGroup('A') + { + Components = new[] + { + new SearchVectorComponent("Address", string.Empty), + } + } + } + }; + alterTable.AddAnnotation( + NpgsqlAnnotationNames.SearchVectorPrefix + searchVectorProperty.Name, + searchVectorProperty.Serialize()); + + searchVectorProperty.ComponentGroupsByLabel[0].Components[0] = + new SearchVectorComponent("Name", string.Empty); + alterTable.OldTable.AddAnnotation( + NpgsqlAnnotationNames.SearchVectorPrefix + searchVectorProperty.Name, + searchVectorProperty.Serialize()); + + Generate(alterTable); + Assert.Equal( + @"DROP FUNCTION IF EXISTS dbo.customers_searchvector_trigger() CASCADE; +GO + +CREATE FUNCTION dbo.customers_searchvector_trigger() RETURNS trigger AS $$ +BEGIN + new.""SearchVector"" := + setweight(to_tsvector('english'::regconfig, coalesce(new.""Address"", '')), 'A'); + return new; +END +$$ LANGUAGE plpgsql; +GO + +CREATE TRIGGER customers_searchvector_trigger_update BEFORE INSERT OR UPDATE ON dbo.""Customers"" FOR EACH ROW EXECUTE PROCEDURE dbo.customers_searchvector_trigger(); +GO + +UPDATE dbo.""Customers"" SET ""Address"" = ""Address"";", + Sql); + } + + #endregion + #region System columns [Fact]