diff --git a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs index 3e432e3eb..b21a9dfba 100644 --- a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs +++ b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs @@ -6,13 +6,11 @@ // ========================================================================== using System.Linq.Expressions; -using Google.Protobuf; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using PhenX.EntityFrameworkCore.BulkInsert.Extensions; using PhenX.EntityFrameworkCore.BulkInsert.Options; using Squidex.Domain.Apps.Entities; diff --git a/backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs b/backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs index 63f856588..b5bff4d65 100644 --- a/backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs +++ b/backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs @@ -7,7 +7,6 @@ using System.Collections.Concurrent; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Internal; #pragma warning disable EF1001 // Internal EF Core API usage. diff --git a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs index 072756f60..0e5795f5c 100644 --- a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs +++ b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs @@ -7,6 +7,7 @@ using System.Collections; using System.Text; +using Microsoft.EntityFrameworkCore; namespace Squidex.Infrastructure.Queries; @@ -19,6 +20,12 @@ public class SqlDialect return false; } + public virtual Task InitializeAsync(DbContext dbContext, + CancellationToken ct) + { + return Task.CompletedTask; + } + public virtual string BuildSelectStatement(SqlQuery request) { var sb = new StringBuilder("SELECT"); diff --git a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs new file mode 100644 index 000000000..13a5efe71 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.EntityFrameworkCore; +using Squidex.Hosting; + +namespace Squidex.Infrastructure.Queries; + +public sealed class SqlDialectInitializer(IDbContextFactory dbContextFactory) + : IInitializable where TContext : DbContext +{ + public async Task InitializeAsync(CancellationToken ct) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(ct); + if (dbContext is not IDbContextWithDialect withDialect) + { + return; + } + + await withDialect.Dialect.InitializeAsync(dbContext, ct); + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs new file mode 100644 index 000000000..dbaf01ef4 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs @@ -0,0 +1,5 @@ +namespace Squidex.Providers.MySql.Content; + +public static class JsonFunction +{ +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs index e028ca64f..b35787055 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs @@ -17,7 +17,14 @@ internal static class Extensions { sb.Append('`'); sb.Append(path[0]); - sb.Append("`, \'$"); + sb.Append("`, "); + sb.AppendJsonPropertyPath(path); + return sb; + } + + public static StringBuilder AppendJsonPropertyPath(this StringBuilder sb, PropertyPath path) + { + sb.Append("\'$"); foreach (var property in path.Skip(1)) { @@ -38,6 +45,11 @@ internal static class Extensions return sb; } + public static string JsonSubPath(this PropertyPath path) + { + return new StringBuilder().AppendJsonPropertyPath(path).ToString(); + } + public static string JsonPath(this PropertyPath path) { return new StringBuilder().AppendJsonPath(path).ToString(); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs new file mode 100644 index 000000000..1f5736189 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs @@ -0,0 +1,121 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.EntityFrameworkCore; +using Squidex.Infrastructure.Queries; + +namespace Squidex.Providers.MySql; + +public static class JsonFunction +{ + private const int TypeAny = 0; + private const int TypeNull = 1; + private const int TypeText = 2; + private const int TypeBoolean = 3; + private const int TypeNumber = 4; + + private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new() + { + [(TypeAny, CompareOperator.Empty)] = "json_empty", + [(TypeAny, CompareOperator.Exists)] = "json_exists", + [(TypeNull, CompareOperator.Equals)] = "json_null_equals", + [(TypeNull, CompareOperator.NotEquals)] = "json_null_notequals", + [(TypeText, CompareOperator.Contains)] = "json_text_contains", + [(TypeText, CompareOperator.EndsWith)] = "json_text_endswith", + [(TypeText, CompareOperator.Equals)] = "json_text_equals", + [(TypeText, CompareOperator.GreaterThan)] = "json_text_greaterthan", + [(TypeText, CompareOperator.GreaterThanOrEqual)] = "json_text_greaterthanorequal", + [(TypeText, CompareOperator.In)] = "json_text_in", + [(TypeText, CompareOperator.LessThan)] = "json_text_lessthan", + [(TypeText, CompareOperator.LessThanOrEqual)] = "json_text_lessthanorequal", + [(TypeText, CompareOperator.Matchs)] = "json_text_matchs", + [(TypeText, CompareOperator.NotEquals)] = "json_text_notequals", + [(TypeText, CompareOperator.StartsWith)] = "json_text_startswith", + [(TypeBoolean, CompareOperator.Equals)] = "json_boolean_equals", + [(TypeBoolean, CompareOperator.In)] = "json_boolean_in", + [(TypeBoolean, CompareOperator.NotEquals)] = "json_boolean_notequals", + [(TypeNumber, CompareOperator.Equals)] = "json_number_equals", + [(TypeNumber, CompareOperator.GreaterThan)] = "json_number_greaterthan", + [(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "json_number_greaterthanorequal", + [(TypeNumber, CompareOperator.In)] = "json_number_in", + [(TypeNumber, CompareOperator.LessThan)] = "json_number_lessthan", + [(TypeNumber, CompareOperator.LessThanOrEqual)] = "json_number_lessthanorequal", + [(TypeNumber, CompareOperator.NotEquals)] = "json_number_notequals", + }; + + public static async Task InitializeAsync(DbContext dbContext, + CancellationToken ct) + { + var sqlStream = typeof(MySqlDialect).Assembly.GetManifestResourceStream("Squidex.Providers.MySql.json_function.sql"); + var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct); + + sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal); + sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal); + + var statements = sqlText.Split(";;", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + // We want to filter out the drop statements and multiple function creations are not supported. + foreach (var statement in statements) + { +#if RELEASE + if (statement.StartsWith("DROP", StringComparison.Ordinal)) + { + continue; + } +#endif + await dbContext.Database.ExecuteSqlRawAsync(statement, ct); + } + } + + public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue) + { + var type = -1; + if (op is CompareOperator.Exists or CompareOperator.Empty) + { + type = TypeAny; + } + else + { + switch (value.ValueType) + { + case ClrValueType.Single: + case ClrValueType.Double: + case ClrValueType.Int32: + case ClrValueType.Int64: + type = TypeNumber; + break; + case ClrValueType.Instant: + case ClrValueType.Guid: + case ClrValueType.String: + type = TypeText; + break; + case ClrValueType.Boolean: + type = TypeBoolean; + break; + case ClrValueType.Null: + type = TypeNull; + break; + } + } + + if (!Functions.TryGetValue((type, op), out var fn)) + { + throw new NotSupportedException($"No jsonb function for type={value.ValueType}, operator={op}."); + } + + if (type is TypeNull or TypeAny) + { + return $"{fn}(`{path[0]}`, {path.JsonSubPath()})"; + } + + if (value.IsList) + { + return $"{fn}(`{path[0]}`, {path.JsonSubPath()}, JSON_ARRAY({formattedValue}))"; + } + + return $"{fn}(`{path[0]}`, {path.JsonSubPath()}, {formattedValue})"; + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs index adb26b01f..f63936899 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Text; +using Microsoft.EntityFrameworkCore; using MySqlConnector; using Squidex.Infrastructure.Queries; @@ -19,6 +19,12 @@ public sealed class MySqlDialect : SqlDialect { } + public override Task InitializeAsync(DbContext dbContext, + CancellationToken ct) + { + return JsonFunction.InitializeAsync(dbContext, ct); + } + public override bool IsDuplicateIndexException(Exception exception, string name) { return exception is MySqlException ex && ex.Number == 1061; @@ -107,69 +113,7 @@ public sealed class MySqlDialect : SqlDialect { if (isJson) { - var sqlPath = path.JsonPath(); - var sqlOp = FormatOperator(op, value); - var sqlRhs = FormatValues(op, value, queryParameters); - - string BuildCondition(string path) - { - var isNumeric = value.ValueType is - ClrValueType.Single or - ClrValueType.Double or - ClrValueType.Int32 or - ClrValueType.Int64; - if (isNumeric) - { - return $""" - (CASE WHEN JSON_TYPE({path}) IN ('INTEGER', 'DOUBLE', 'DECIMAL') - THEN CAST(JSON_UNQUOTE({path}) AS DECIMAL(65,10)) {sqlOp} {sqlRhs} - ELSE FALSE - END) - """; - } - - var isBoolean = value.ValueType is ClrValueType.Boolean; - if (isBoolean) - { - return $""" - (CASE WHEN JSON_TYPE({path}) = 'BOOLEAN' - THEN IF(JSON_UNQUOTE({path}) = 'true', TRUE, FALSE) {sqlOp} {sqlRhs} - ELSE FALSE - END) - """; - } - - var isString = value.ValueType is - ClrValueType.Instant or - ClrValueType.Guid or - ClrValueType.String; - if (isString) - { - return $"JSON_UNQUOTE({path}) {sqlOp} {sqlRhs}"; - } - - var isNull = value.ValueType is ClrValueType.Null; - if (isNull) - { - var nullOp = FormatOperator(op, "null"); - return $"COALESCE(JSON_TYPE({path}), 'NULL') {nullOp} 'NULL'"; - } - - return base.Where(path, op, value, queryParameters, false); - } - - return $""" - CASE WHEN JSON_TYPE(JSON_EXTRACT({sqlPath})) = 'ARRAY' - THEN EXISTS ( - SELECT 1 - FROM JSON_TABLE(JSON_EXTRACT({sqlPath}), '$[*]' COLUMNS ( - __element JSON PATH '$' - )) AS __jt - WHERE {BuildCondition("__element")} - ) - ELSE {BuildCondition($"JSON_EXTRACT({sqlPath})")} - END - """; + return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true)); } return base.Where(path, op, value, queryParameters, isJson); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql new file mode 100644 index 000000000..39e89b7d7 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql @@ -0,0 +1,409 @@ +-- ============================================================================= +-- TYPE-AGNOSTIC +-- ============================================================================= +DROP FUNCTION IF EXISTS json_empty;; +CREATE FUNCTION json_empty(col JSON, path VARCHAR(500)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF val IS NULL THEN RETURN 1; END IF; + IF JSON_TYPE(val) = 'NULL' THEN RETURN 1; END IF; + IF JSON_TYPE(val) = 'ARRAY' AND JSON_LENGTH(val) = 0 THEN RETURN 1; END IF; + IF JSON_TYPE(val) = 'STRING' AND JSON_UNQUOTE(val) = '' THEN RETURN 1; END IF; + RETURN 0; +END;; + +DROP FUNCTION IF EXISTS json_exists;; +CREATE FUNCTION json_exists(col JSON, path VARCHAR(500)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + RETURN NOT json_empty(col, path); +END;; + + +-- ============================================================================= +-- NULL +-- ============================================================================= +DROP FUNCTION IF EXISTS json_null_equals;; +CREATE FUNCTION json_null_equals(col JSON, path VARCHAR(500)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE COALESCE(JSON_TYPE(jt.elem), 'NULL') = 'NULL' + ); + END IF; + RETURN COALESCE(JSON_TYPE(val), 'NULL') = 'NULL'; +END;; + +DROP FUNCTION IF EXISTS json_null_notequals;; +CREATE FUNCTION json_null_notequals(col JSON, path VARCHAR(500)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + RETURN NOT json_null_equals(col, path); +END;; + + +-- ============================================================================= +-- TEXT +-- ============================================================================= +DROP FUNCTION IF EXISTS json_text_equals;; +CREATE FUNCTION json_text_equals(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) = target + ); + END IF; + RETURN JSON_UNQUOTE(val) = target; +END;; + +DROP FUNCTION IF EXISTS json_text_notequals;; +CREATE FUNCTION json_text_notequals(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN NOT EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) = target + ); + END IF; + RETURN JSON_UNQUOTE(val) != target; +END;; + +DROP FUNCTION IF EXISTS json_text_lessthan;; +CREATE FUNCTION json_text_lessthan(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) < target + ); + END IF; + RETURN JSON_UNQUOTE(val) < target; +END;; + +DROP FUNCTION IF EXISTS json_text_lessthanorequal;; +CREATE FUNCTION json_text_lessthanorequal(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) <= target + ); + END IF; + RETURN JSON_UNQUOTE(val) <= target; +END;; + +DROP FUNCTION IF EXISTS json_text_greaterthan;; +CREATE FUNCTION json_text_greaterthan(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) > target + ); + END IF; + RETURN JSON_UNQUOTE(val) > target; +END;; + +DROP FUNCTION IF EXISTS json_text_greaterthanorequal;; +CREATE FUNCTION json_text_greaterthanorequal(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) >= target + ); + END IF; + RETURN JSON_UNQUOTE(val) >= target; +END;; + +DROP FUNCTION IF EXISTS json_text_contains;; +CREATE FUNCTION json_text_contains(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT('%', target, '%') + ); + END IF; + RETURN JSON_UNQUOTE(val) LIKE CONCAT('%', target, '%'); +END;; + +DROP FUNCTION IF EXISTS json_text_startswith;; +CREATE FUNCTION json_text_startswith(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT(target, '%') + ); + END IF; + RETURN JSON_UNQUOTE(val) LIKE CONCAT(target, '%'); +END;; + +DROP FUNCTION IF EXISTS json_text_endswith;; +CREATE FUNCTION json_text_endswith(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT('%', target) + ); + END IF; + RETURN JSON_UNQUOTE(val) LIKE CONCAT('%', target); +END;; + +DROP FUNCTION IF EXISTS json_text_matchs;; +CREATE FUNCTION json_text_matchs(col JSON, path VARCHAR(500), target VARCHAR(2000)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_UNQUOTE(jt.elem) REGEXP target + ); + END IF; + RETURN JSON_UNQUOTE(val) REGEXP target; +END;; + +DROP FUNCTION IF EXISTS json_text_in;; +CREATE FUNCTION json_text_in(col JSON, path VARCHAR(500), targets JSON) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_CONTAINS(targets, jt.elem) + ); + END IF; + RETURN JSON_CONTAINS(targets, val); +END;; + + +-- ============================================================================= +-- NUMBER +-- ============================================================================= +DROP FUNCTION IF EXISTS json_number_equals;; +CREATE FUNCTION json_number_equals(col JSON, path VARCHAR(500), target DECIMAL(65, 10)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL') + THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10)) + END = target + ); + END IF; + IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF; + RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) = target; +END;; + +DROP FUNCTION IF EXISTS json_number_notequals;; +CREATE FUNCTION json_number_notequals(col JSON, path VARCHAR(500), target DECIMAL(65, 10)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN NOT EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL') + THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10)) + END = target + ); + END IF; + IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF; + RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) != target; +END;; + +DROP FUNCTION IF EXISTS json_number_lessthan;; +CREATE FUNCTION json_number_lessthan(col JSON, path VARCHAR(500), target DECIMAL(65, 10)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL') + THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10)) + END < target + ); + END IF; + IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF; + RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) < target; +END;; + +DROP FUNCTION IF EXISTS json_number_lessthanorequal;; +CREATE FUNCTION json_number_lessthanorequal(col JSON, path VARCHAR(500), target DECIMAL(65, 10)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL') + THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10)) + END <= target + ); + END IF; + IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF; + RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) <= target; +END;; + +DROP FUNCTION IF EXISTS json_number_greaterthan;; +CREATE FUNCTION json_number_greaterthan(col JSON, path VARCHAR(500), target DECIMAL(65, 10)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL') + THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10)) + END > target + ); + END IF; + IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF; + RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) > target; +END;; + +DROP FUNCTION IF EXISTS json_number_greaterthanorequal;; +CREATE FUNCTION json_number_greaterthanorequal(col JSON, path VARCHAR(500), target DECIMAL(65, 10)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL') + THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10)) + END >= target + ); + END IF; + IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF; + RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) >= target; +END;; + +DROP FUNCTION IF EXISTS json_number_in;; +CREATE FUNCTION json_number_in(col JSON, path VARCHAR(500), targets JSON) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL') + AND JSON_CONTAINS(targets, jt.elem) + ); + END IF; + IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF; + RETURN JSON_CONTAINS(targets, val); +END;; + + +-- ============================================================================= +-- BOOLEAN +-- ============================================================================= +DROP FUNCTION IF EXISTS json_boolean_equals;; +CREATE FUNCTION json_boolean_equals(col JSON, path VARCHAR(500), target TINYINT(1)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + DECLARE target_json JSON; + SET val = JSON_EXTRACT(col, path); + SET target_json = IF(target, CAST('true' AS JSON), CAST('false' AS JSON)); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE CASE WHEN JSON_TYPE(jt.elem) = 'BOOLEAN' THEN jt.elem END = target_json + ); + END IF; + IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF; + RETURN val = target_json; +END;; + +DROP FUNCTION IF EXISTS json_boolean_notequals;; +CREATE FUNCTION json_boolean_notequals(col JSON, path VARCHAR(500), target TINYINT(1)) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + DECLARE target_json JSON; + SET val = JSON_EXTRACT(col, path); + SET target_json = IF(target, CAST('true' AS JSON), CAST('false' AS JSON)); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN NOT EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE CASE WHEN JSON_TYPE(jt.elem) = 'BOOLEAN' THEN jt.elem END = target_json + ); + END IF; + IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF; + RETURN val != target_json; +END;; + +DROP FUNCTION IF EXISTS json_boolean_in;; +CREATE FUNCTION json_boolean_in(col JSON, path VARCHAR(500), targets JSON) +RETURNS TINYINT(1) DETERMINISTIC +BEGIN + DECLARE val JSON; + SET val = JSON_EXTRACT(col, path); + IF JSON_TYPE(val) = 'ARRAY' THEN + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt + WHERE JSON_TYPE(jt.elem) = 'BOOLEAN' + AND EXISTS ( + SELECT 1 FROM JSON_TABLE(targets, '$[*]' COLUMNS (t JSON PATH '$')) AS tt + WHERE IF(jt.elem = CAST('true' AS JSON), 1, 0) = CAST(JSON_UNQUOTE(tt.t) AS UNSIGNED) + ) + ); + END IF; + IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF; + RETURN EXISTS ( + SELECT 1 FROM JSON_TABLE(targets, '$[*]' COLUMNS (t JSON PATH '$')) AS tt + WHERE IF(val = CAST('true' AS JSON), 1, 0) = CAST(JSON_UNQUOTE(tt.t) AS UNSIGNED) + ); +END;; \ No newline at end of file diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs new file mode 100644 index 000000000..84b529465 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs @@ -0,0 +1,111 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.EntityFrameworkCore; +using Squidex.Infrastructure.Queries; +using Squidex.Providers.Postgres.App; + +namespace Squidex.Providers.Postgres; + +public static class JsonFunction +{ + private const int TypeAny = 0; + private const int TypeNull = 1; + private const int TypeText = 2; + private const int TypeBoolean = 3; + private const int TypeNumber = 4; + + private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new() + { + [(TypeAny, CompareOperator.Empty)] = "jsonb_empty", + [(TypeAny, CompareOperator.Exists)] = "jsonb_exists", + [(TypeNull, CompareOperator.Equals)] = "jsonb_null_equals", + [(TypeNull, CompareOperator.NotEquals)] = "jsonb_null_notequals", + [(TypeText, CompareOperator.Contains)] = "jsonb_text_contains", + [(TypeText, CompareOperator.EndsWith)] = "jsonb_text_endswith", + [(TypeText, CompareOperator.Equals)] = "jsonb_text_equals", + [(TypeText, CompareOperator.GreaterThan)] = "jsonb_text_greaterthan", + [(TypeText, CompareOperator.GreaterThanOrEqual)] = "jsonb_text_greaterthanorequal", + [(TypeText, CompareOperator.In)] = "jsonb_text_in", + [(TypeText, CompareOperator.LessThan)] = "jsonb_text_lessthan", + [(TypeText, CompareOperator.LessThanOrEqual)] = "jsonb_text_lessthanorequal", + [(TypeText, CompareOperator.Matchs)] = "jsonb_text_matchs", + [(TypeText, CompareOperator.NotEquals)] = "jsonb_text_notequals", + [(TypeText, CompareOperator.StartsWith)] = "jsonb_text_startswith", + [(TypeBoolean, CompareOperator.Equals)] = "jsonb_boolean_equals", + [(TypeBoolean, CompareOperator.In)] = "jsonb_boolean_in", + [(TypeBoolean, CompareOperator.NotEquals)] = "jsonb_boolean_notequals", + [(TypeNumber, CompareOperator.Equals)] = "jsonb_number_equals", + [(TypeNumber, CompareOperator.GreaterThan)] = "jsonb_number_greaterthan", + [(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "jsonb_number_greaterthanorequal", + [(TypeNumber, CompareOperator.In)] = "jsonb_number_in", + [(TypeNumber, CompareOperator.LessThan)] = "jsonb_number_lessthan", + [(TypeNumber, CompareOperator.LessThanOrEqual)] = "jsonb_number_lessthanorequal", + [(TypeNumber, CompareOperator.NotEquals)] = "jsonb_number_notequals", + }; + + public static async Task InitializeAsync(DbContext dbContext, + CancellationToken ct) + { + var sqlStream = typeof(PostgresDialect).Assembly.GetManifestResourceStream("Squidex.Providers.Postgres.json_function.sql"); + var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct); + + sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal); + sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal); + + await dbContext.Database.ExecuteSqlRawAsync(sqlText, ct); + } + + public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue) + { + var type = -1; + if (op is CompareOperator.Exists or CompareOperator.Empty) + { + type = TypeAny; + } + else + { + switch (value.ValueType) + { + case ClrValueType.Single: + case ClrValueType.Double: + case ClrValueType.Int32: + case ClrValueType.Int64: + type = TypeNumber; + break; + case ClrValueType.Instant: + case ClrValueType.Guid: + case ClrValueType.String: + type = TypeText; + break; + case ClrValueType.Boolean: + type = TypeBoolean; + break; + case ClrValueType.Null: + type = TypeNull; + break; + } + } + + if (!Functions.TryGetValue((type, op), out var fn)) + { + throw new NotSupportedException($"No jsonb function for type={value.ValueType}, operator={op}."); + } + + if (type is TypeNull or TypeAny) + { + return $"{fn}({path.JsonPath(false)})"; + } + + if (value.IsList) + { + return $"{fn}({path.JsonPath(false)}, ARRAY[{formattedValue}])"; + } + + return $"{fn}({path.JsonPath(false)}, {formattedValue})"; + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs index dc439fbb3..af4542a5f 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Microsoft.EntityFrameworkCore; using Npgsql; using Squidex.Infrastructure.Queries; using Squidex.Providers.Postgres.App; @@ -19,6 +20,12 @@ public class PostgresDialect : SqlDialect { } + public override Task InitializeAsync(DbContext dbContext, + CancellationToken ct) + { + return JsonFunction.InitializeAsync(dbContext, ct); + } + public override bool IsDuplicateIndexException(Exception exception, string name) { return exception is PostgresException ex && ex.SqlState == "42P07"; @@ -71,59 +78,7 @@ public class PostgresDialect : SqlDialect { if (isJson) { - var sqlPathCast = path.JsonPath(true); - var sqlPath = path.JsonPath(false); - var sqlOp = FormatOperator(op, value); - var sqlRhs = FormatValues(op, value, queryParameters); - - string BuildCondition(string path, string castPath) - { - var isNumeric = value.ValueType is - ClrValueType.Single or - ClrValueType.Double or - ClrValueType.Int32 or - ClrValueType.Int64; - - if (isNumeric) - { - return $"(CASE WHEN jsonb_typeof({path}) = 'number' THEN ({castPath})::numeric {sqlOp} {sqlRhs} ELSE FALSE END)"; - } - - var isBoolean = value.ValueType is ClrValueType.Boolean; - if (isBoolean) - { - return $"(CASE WHEN jsonb_typeof({path}) = 'boolean' THEN ({castPath})::boolean {sqlOp} {sqlRhs} ELSE FALSE END)"; - } - - var isString = value.ValueType is - ClrValueType.Instant or - ClrValueType.Guid or - ClrValueType.String; - if (isString) - { - return $"{path} #>> '{{{{}}}}' {sqlOp} {sqlRhs}"; - } - - var isNull = value.ValueType is ClrValueType.Null; - if (isNull) - { - var nullOp = FormatOperator(op, "null"); - return $"jsonb_typeof({path}) {nullOp} 'null'"; - } - - return base.Where(path, op, value, queryParameters, false); - } - - return $""" - CASE WHEN jsonb_typeof({sqlPath}) = 'array' - THEN EXISTS ( - SELECT 1 - FROM jsonb_array_elements({sqlPath}) AS __element - WHERE {BuildCondition("__element", "__element")} - ) - ELSE {BuildCondition(sqlPath, sqlPathCast)} - END - """; + return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true)); } return base.Where(path, op, value, queryParameters, isJson); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql new file mode 100644 index 000000000..df44e001c --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql @@ -0,0 +1,330 @@ +-- ============================================================================= +-- TYPE-AGNOSTIC +-- ============================================================================= +CREATE OR REPLACE FUNCTION jsonb_empty(val jsonb) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + RETURN val IS NULL + OR (jsonb_typeof(val) = 'null') + OR (jsonb_typeof(val) = 'array' AND jsonb_array_length(val) = 0) + OR (jsonb_typeof(val) = 'string' AND val #>> '{}' = ''); +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_exists(val jsonb) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + RETURN NOT jsonb_empty(val); +END; +$$; + + +-- ============================================================================= +-- TEXT +-- ============================================================================= +CREATE OR REPLACE FUNCTION jsonb_text_equals(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' = target + ); + END IF; + RETURN val #>> '{}' = target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_notequals(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' = target + ); + END IF; + RETURN val #>> '{}' != target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_lessthan(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' < target + ); + END IF; + RETURN val #>> '{}' < target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_lessthanorequal(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' <= target + ); + END IF; + RETURN val #>> '{}' <= target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_greaterthan(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' > target + ); + END IF; + RETURN val #>> '{}' > target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_greaterthanorequal(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' >= target + ); + END IF; + RETURN val #>> '{}' >= target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_contains(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE e #>> '{}' LIKE '%' || target || '%' + ); + END IF; + RETURN val #>> '{}' LIKE '%' || target || '%'; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_startswith(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE e #>> '{}' LIKE target || '%' + ); + END IF; + RETURN val #>> '{}' LIKE target || '%'; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_endswith(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE e #>> '{}' LIKE '%' || target + ); + END IF; + RETURN val #>> '{}' LIKE '%' || target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_matchs(val jsonb, target text) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE e #>> '{}' ~ target + ); + END IF; + RETURN val #>> '{}' ~ target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_text_in(val jsonb, targets text[]) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE e #>> '{}' = ANY(targets) + ); + END IF; + RETURN val #>> '{}' = ANY(targets); +END; +$$; + + +-- ============================================================================= +-- NUMBER +-- ============================================================================= +CREATE OR REPLACE FUNCTION jsonb_number_equals(val jsonb, target numeric) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = target + ); + END IF; + IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::numeric = target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_number_notequals(val jsonb, target numeric) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = target + ); + END IF; + IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::numeric != target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_number_lessthan(val jsonb, target numeric) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric < target + ); + END IF; + IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::numeric < target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_number_lessthanorequal(val jsonb, target numeric) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric <= target + ); + END IF; + IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::numeric <= target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_number_greaterthan(val jsonb, target numeric) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric > target + ); + END IF; + IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::numeric > target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_number_greaterthanorequal(val jsonb, target numeric) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric >= target + ); + END IF; + IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::numeric >= target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_number_in(val jsonb, targets numeric[]) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = ANY(targets) + ); + END IF; + IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::numeric = ANY(targets); +END; +$$; + +-- ============================================================================= +-- NULL +-- ============================================================================= +CREATE OR REPLACE FUNCTION jsonb_null_equals(val jsonb) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'null' + ); + END IF; + RETURN val IS NULL OR jsonb_typeof(val) = 'null'; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_null_notequals(val jsonb) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + RETURN NOT jsonb_null_equals(val); +END; +$$; + +-- ============================================================================= +-- BOOLEAN +-- ============================================================================= +CREATE OR REPLACE FUNCTION jsonb_boolean_equals(val jsonb, target boolean) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = target + ); + END IF; + IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::boolean = target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_boolean_notequals(val jsonb, target boolean) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = target + ); + END IF; + IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::boolean != target; +END; +$$; + +CREATE OR REPLACE FUNCTION jsonb_boolean_in(val jsonb, targets boolean[]) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF jsonb_typeof(val) = 'array' THEN + RETURN EXISTS ( + SELECT 1 FROM jsonb_array_elements(val) AS e + WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = ANY(targets) + ); + END IF; + IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF; + RETURN (val #>> '{}')::boolean = ANY(targets); +END; +$$; \ No newline at end of file diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs index 87cfa1375..f2e4bdb8e 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs @@ -17,7 +17,14 @@ internal static class Extensions { sb.Append('['); sb.Append(path[0]); - sb.Append("], \'$"); + sb.Append("], "); + sb.AppendJsonSubPath(path); + return sb; + } + + public static StringBuilder AppendJsonSubPath(this StringBuilder sb, PropertyPath path) + { + sb.Append("\'$"); foreach (var property in path.Skip(1)) { @@ -38,6 +45,11 @@ internal static class Extensions return sb; } + public static string JsonSubPath(this PropertyPath path) + { + return new StringBuilder().AppendJsonSubPath(path).ToString(); + } + public static string JsonPath(this PropertyPath path) { return new StringBuilder().AppendJsonPath(path).ToString(); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs new file mode 100644 index 000000000..522f29ef6 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs @@ -0,0 +1,153 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.EntityFrameworkCore; +using Squidex.Infrastructure.Queries; + +namespace Squidex.Providers.SqlServer; + +public static class JsonFunction +{ + private const int TypeAny = 0; + private const int TypeNull = 1; + private const int TypeText = 2; + private const int TypeBoolean = 3; + private const int TypeNumber = 4; + + private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new () + { + [(TypeAny, CompareOperator.Empty)] = "dbo.json_empty", + [(TypeAny, CompareOperator.Exists)] = "dbo.json_exists", + [(TypeNull, CompareOperator.Equals)] = "dbo.json_null_equals", + [(TypeNull, CompareOperator.NotEquals)] = "dbo.json_null_notequals", + [(TypeText, CompareOperator.Contains)] = "dbo.json_text_contains", + [(TypeText, CompareOperator.EndsWith)] = "dbo.json_text_endswith", + [(TypeText, CompareOperator.Equals)] = "dbo.json_text_equals", + [(TypeText, CompareOperator.GreaterThan)] = "dbo.json_text_greaterthan", + [(TypeText, CompareOperator.GreaterThanOrEqual)] = "dbo.json_text_greaterthanorequal", + [(TypeText, CompareOperator.LessThan)] = "dbo.json_text_lessthan", + [(TypeText, CompareOperator.LessThanOrEqual)] = "dbo.json_text_lessthanorequal", + [(TypeText, CompareOperator.Matchs)] = "dbo.json_text_matchs", + [(TypeText, CompareOperator.NotEquals)] = "dbo.json_text_notequals", + [(TypeText, CompareOperator.StartsWith)] = "dbo.json_text_startswith", + [(TypeBoolean, CompareOperator.Equals)] = "dbo.json_boolean_equals", + [(TypeBoolean, CompareOperator.NotEquals)] = "dbo.json_boolean_notequals", + [(TypeNumber, CompareOperator.Equals)] = "dbo.json_number_equals", + [(TypeNumber, CompareOperator.GreaterThan)] = "dbo.json_number_greaterthan", + [(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "dbo.json_number_greaterthanorequal", + [(TypeNumber, CompareOperator.LessThan)] = "dbo.json_number_lessthan", + [(TypeNumber, CompareOperator.LessThanOrEqual)] = "dbo.json_number_lessthanorequal", + [(TypeNumber, CompareOperator.NotEquals)] = "dbo.json_number_notequals", + }; + + public static async Task InitializeAsync(DbContext dbContext, + CancellationToken ct) + { + var sqlStream = typeof(SqlServerDialect).Assembly.GetManifestResourceStream("Squidex.Providers.SqlServer.json_function.sql"); + var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct); + + sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal); + sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal); + + var statements = sqlText.Split(";;", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + // We want to filter out the drop statements and multiple function creations are not supported. + foreach (var statement in statements) + { +#if RELEASE + if (statement.StartsWith("DROP", StringComparison.Ordinal)) + { + continue; + } +#endif + await dbContext.Database.ExecuteSqlRawAsync(statement, ct); + } + } + + public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue) + { + var type = -1; + if (op is CompareOperator.Exists or CompareOperator.Empty) + { + type = TypeAny; + } + else + { + switch (value.ValueType) + { + case ClrValueType.Single: + case ClrValueType.Double: + case ClrValueType.Int32: + case ClrValueType.Int64: + type = TypeNumber; + break; + case ClrValueType.Instant: + case ClrValueType.Guid: + case ClrValueType.String: + type = TypeText; + break; + case ClrValueType.Boolean: + type = TypeBoolean; + break; + case ClrValueType.Null: + type = TypeNull; + break; + } + } + + var jsonPath = path.JsonPath(); + + // SQL Server scalar functions cannot accept parameterized arrays, embedding + // SQL parameter references (@p0, @p1) inside a JSON string literal + // produces invalid JSON at execution time. IN is therefore handled inline so + // parameters remain proper SQL parameters. + if (value.IsList) + { + return type switch + { + TypeText => + $""" + (CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL + THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 1 AND j.[value] IN ({formattedValue})) THEN 1 ELSE 0 END + ELSE CASE WHEN JSON_VALUE({jsonPath}) IN ({formattedValue}) THEN 1 ELSE 0 END + END) = 1 + """, + TypeNumber => + $""" + (CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL + THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 2 AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) IN ({formattedValue})) THEN 1 ELSE 0 END + ELSE CASE WHEN TRY_CAST(JSON_VALUE({jsonPath}) AS DECIMAL(38, 10)) IN ({formattedValue}) THEN 1 ELSE 0 END + END) = 1 + """, + TypeBoolean => + $""" + (CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL + THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 3 AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) IN ({formattedValue})) THEN 1 ELSE 0 END + ELSE CASE WHEN IIF(JSON_VALUE({jsonPath}) = 'true', 1, IIF(JSON_VALUE({jsonPath}) = 'false', 0, NULL)) IN ({formattedValue}) THEN 1 ELSE 0 END + END) = 1 + """, + _ => throw new NotSupportedException($"No json function for type={type}, operator={op}."), + }; + } + + if (!Functions.TryGetValue((type, op), out var fn)) + { + throw new NotSupportedException($"No json function for type={type}, operator={op}."); + } + + if (type is TypeNull or TypeAny) + { + return $"{fn}({jsonPath}) = 1"; + } + + if (value.IsList) + { + return $"{fn}({jsonPath}, N'[{formattedValue}]') = 1"; + } + + return $"{fn}({jsonPath}, {formattedValue}) = 1"; + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs index 843a03184..62899ea99 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Text; +using Microsoft.EntityFrameworkCore; using Squidex.Infrastructure.Queries; using SqlException = Microsoft.Data.SqlClient.SqlException; @@ -19,6 +19,12 @@ public sealed class SqlServerDialect : SqlDialect { } + public override Task InitializeAsync(DbContext dbContext, + CancellationToken ct) + { + return JsonFunction.InitializeAsync(dbContext, ct); + } + public override bool IsDuplicateIndexException(Exception exception, string name) { return exception is SqlException ex && ex.Number is 1913 or 7642 or 7652; @@ -88,66 +94,7 @@ public sealed class SqlServerDialect : SqlDialect { if (isJson) { - var sqlPath = path.JsonPath(); - var sqlOp = FormatOperator(op, value); - var sqlRhs = FormatValues(op, value, queryParameters); - - string BuildCondition(string path, string queryPath) - { - var isNumeric = value.ValueType is - ClrValueType.Single or - ClrValueType.Double or - ClrValueType.Int32 or - ClrValueType.Int64; - if (isNumeric) - { - return $"TRY_CAST({path} AS NUMERIC) {sqlOp} {sqlRhs}"; - } - - var isBoolean = value.ValueType is ClrValueType.Boolean; - if (isBoolean) - { - return $"IIF({path} = 'true', 1, IIF({path} = 'false', 0, NULL)) {sqlOp} {sqlRhs}"; - } - - var isString = value.ValueType is - ClrValueType.Instant or - ClrValueType.Guid or - ClrValueType.String; - if (isString) - { - return $"{path} {sqlOp} {sqlRhs}"; - } - - var isNull = value.ValueType is ClrValueType.Null; - if (isNull) - { - if (op == CompareOperator.Equals) - { - return $"{queryPath} IS NULL AND {path} IS NULL"; - } - - if (op == CompareOperator.NotEquals) - { - return $"{queryPath} IS NOT NULL OR {path} IS NOT NULL"; - } - } - - return base.Where(path, op, value, queryParameters, false); - } - - return $""" - ( - CASE WHEN LEFT(LTRIM(JSON_QUERY({sqlPath})), 1) = '[' - THEN CASE WHEN EXISTS ( - SELECT 1 - FROM OPENJSON({sqlPath}) AS __element - WHERE {BuildCondition("__element.[value]", "__element.[value]")} - ) THEN 1 ELSE 0 END - ELSE CASE WHEN {BuildCondition($"JSON_VALUE({sqlPath})", $"JSON_QUERY({sqlPath})")} THEN 1 ELSE 0 END - END - ) = 1 - """; + return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true)); } return base.Where(path, op, value, queryParameters, isJson); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql new file mode 100644 index 000000000..4f198081c --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql @@ -0,0 +1,313 @@ +-- ============================================================================= +-- TYPE-AGNOSTIC +-- ============================================================================= +DROP FUNCTION IF EXISTS dbo.json_empty;; +CREATE FUNCTION dbo.json_empty(@col NVARCHAR(MAX), @path NVARCHAR(500)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + DECLARE @val NVARCHAR(MAX) = JSON_VALUE(@col, @path); + IF @query IS NOT NULL AND LEFT(LTRIM(@query), 1) = '[' + RETURN CASE WHEN (SELECT COUNT(*) FROM OPENJSON(@query)) = 0 THEN 1 ELSE 0 END; + IF @query IS NOT NULL AND LEFT(LTRIM(@query), 1) = '{' + RETURN 0; + IF @val IS NULL RETURN 1; + IF @val = '' RETURN 1; + RETURN 0; +END;; + +DROP FUNCTION IF EXISTS dbo.json_exists;; +CREATE FUNCTION dbo.json_exists(@col NVARCHAR(MAX), @path NVARCHAR(500)) +RETURNS BIT +AS +BEGIN + RETURN 1 - dbo.json_empty(@col, @path); +END;; + + +-- ============================================================================= +-- NULL +-- ============================================================================= +DROP FUNCTION IF EXISTS dbo.json_null_equals;; +CREATE FUNCTION dbo.json_null_equals(@col NVARCHAR(MAX), @path NVARCHAR(500)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 0 + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) IS NULL THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_null_notequals;; +CREATE FUNCTION dbo.json_null_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500)) +RETURNS BIT +AS +BEGIN + RETURN 1 - dbo.json_null_equals(@col, @path); +END;; + + +-- ============================================================================= +-- TEXT +-- ============================================================================= +DROP FUNCTION IF EXISTS dbo.json_text_equals;; +CREATE FUNCTION dbo.json_text_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] = @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) = @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_notequals;; +CREATE FUNCTION dbo.json_text_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN NOT EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] = @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) != @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_lessthan;; +CREATE FUNCTION dbo.json_text_lessthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] < @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) < @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_lessthanorequal;; +CREATE FUNCTION dbo.json_text_lessthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] <= @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) <= @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_greaterthan;; +CREATE FUNCTION dbo.json_text_greaterthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] > @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) > @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_greaterthanorequal;; +CREATE FUNCTION dbo.json_text_greaterthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] >= @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) >= @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_contains;; +CREATE FUNCTION dbo.json_text_contains(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE '%' + @target + '%' + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE '%' + @target + '%' THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_startswith;; +CREATE FUNCTION dbo.json_text_startswith(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE @target + '%' + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE @target + '%' THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_endswith;; +CREATE FUNCTION dbo.json_text_endswith(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE '%' + @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE '%' + @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_text_matchs;; +CREATE FUNCTION dbo.json_text_matchs(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE @target THEN 1 ELSE 0 END; +END;; + + +-- ============================================================================= +-- NUMBER +-- ============================================================================= +DROP FUNCTION IF EXISTS dbo.json_number_equals;; +CREATE FUNCTION dbo.json_number_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j + WHERE j.[type] = 2 + AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) = @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) = @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_number_notequals;; +CREATE FUNCTION dbo.json_number_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN NOT EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j + WHERE j.[type] = 2 + AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) = @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) != @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_number_lessthan;; +CREATE FUNCTION dbo.json_number_lessthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j + WHERE j.[type] = 2 + AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) < @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) < @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_number_lessthanorequal;; +CREATE FUNCTION dbo.json_number_lessthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j + WHERE j.[type] = 2 + AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) <= @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) <= @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_number_greaterthan;; +CREATE FUNCTION dbo.json_number_greaterthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j + WHERE j.[type] = 2 + AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) > @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) > @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_number_greaterthanorequal;; +CREATE FUNCTION dbo.json_number_greaterthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10)) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j + WHERE j.[type] = 2 + AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) >= @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) >= @target THEN 1 ELSE 0 END; +END;; + + +-- ============================================================================= +-- BOOLEAN +-- ============================================================================= +DROP FUNCTION IF EXISTS dbo.json_boolean_equals;; +CREATE FUNCTION dbo.json_boolean_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target BIT) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j + WHERE j.[type] = 3 + AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) = @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN IIF(JSON_VALUE(@col, @path) = 'true', 1, IIF(JSON_VALUE(@col, @path) = 'false', 0, NULL)) = @target THEN 1 ELSE 0 END; +END;; + +DROP FUNCTION IF EXISTS dbo.json_boolean_notequals;; +CREATE FUNCTION dbo.json_boolean_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target BIT) +RETURNS BIT +AS +BEGIN + DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path); + IF @query IS NOT NULL + RETURN CASE WHEN NOT EXISTS ( + SELECT 1 FROM OPENJSON(@query) AS j + WHERE j.[type] = 3 + AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) = @target + ) THEN 1 ELSE 0 END; + RETURN CASE WHEN IIF(JSON_VALUE(@col, @path) = 'true', 1, IIF(JSON_VALUE(@col, @path) = 'false', 0, NULL)) != @target THEN 1 ELSE 0 END; +END;; \ No newline at end of file diff --git a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj index dfc60795b..a44891558 100644 --- a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj +++ b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj @@ -10,6 +10,10 @@ full True + + + + @@ -55,4 +59,9 @@ + + + + + diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs index 0fb0e3710..5d6bb73fb 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs +++ b/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs @@ -6,7 +6,6 @@ // ========================================================================== using Microsoft.EntityFrameworkCore; -using MongoDB.Driver; using NetTopologySuite.Geometries; using Squidex.EntityFramework.TestHelpers; using Squidex.Infrastructure; @@ -94,6 +93,31 @@ public abstract class EFQueryTests(ISqlFixture fixture) }); } + set.Add(new TestEntity + { + Boolean = false, + BooleanOrNull = null, + Number = 21, + NumberOrNull = null, + FullText = string.Empty, + Text = string.Empty, + Json = new TestJson + { + Boolean = false, + BooleanArray = [], + BooleanOrNull = null, + Mixed = null, + MixedArray = [], + Number = 21, + NumberArray = [], + NumberOrNull = null, + Text = string.Empty, + TextArray = [], + TextOrNull = null, + }, + Point = new Point(0, 0) { SRID = 4326 }, + }); + await dbContext.SaveChangesAsync(); return dbContext; } @@ -155,7 +179,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) Sort = [new SortNode("Number", SortOrder.Descending)], }); - Assert.Equal(Range(15, 11), actual); + Assert.Equal(Range(16, 12), actual); } [Fact] @@ -210,7 +234,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) Sort = [new SortNode("Text", SortOrder.Ascending)], }); - Assert.Equal([.. Range(10, 19), 1, 2, 20, .. Range(3, 9)], actual); + Assert.Equal([.. Range(10, 19), 1, 20, 2, .. Range(3, 9)], actual); } [Fact] @@ -232,7 +256,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) Sort = [new SortNode("Json.text", SortOrder.Ascending)], }); - Assert.Equal([.. Range(10, 19), 1, 2, 20, .. Range(3, 9)], actual); + Assert.Equal([.. Range(10, 19), 1, 20, 2, .. Range(3, 9)], actual); } [Fact] @@ -415,6 +439,17 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(1, 5), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_number_in() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Number", new List { 3, 7, 15 }), + }); + + Assert.Equal([3, 7, 15], actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_number_equal_in_json() { @@ -481,6 +516,17 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(1, 5), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_number_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.number", new List { 3, 7, 15 }), + }); + + Assert.Equal([3, 7, 15], actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_number_null_in_nullable_json() { @@ -536,6 +582,28 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal([1, 2, 3], actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_number_empty_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.numberOrNull"), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_exists_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.numberOrNull"), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_number_equal_in_mixed_json() { @@ -580,6 +648,17 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal([7], actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_number_not_equal_in_json_number_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.numberArray", 7), + }); + + Assert.Equal(AllExept(7), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_number_greater_than_in_json_number_array() { @@ -602,6 +681,28 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal([18, 19, 20], actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_number_less_than_in_json_number_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Lt("Json.numberArray", 0), + }); + + Assert.Empty(actual); + } + + [Fact] + public async Task Should_filter_by_number_less_than_or_equal_in_json_number_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Le("Json.numberArray", 0), + }); + + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_number_in_json_number_array() { @@ -624,6 +725,28 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(6, 15), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_number_empty_in_json_number_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.numberArray"), + }, includeSpecialCase: true); + + Assert.Equal([21], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_exists_in_json_number_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.numberArray"), + }); + + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_number_equal_in_json_mixed_array() { @@ -723,6 +846,17 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal([5, 15], actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_string_in() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Text", new List { "Prefix3Suffix", "Prefix7Suffix" }), + }); + + Assert.Equal([3, 7], actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_string_equal_in_json() { @@ -778,6 +912,39 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal([5, 15], actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_string_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.text", new List { "Prefix3Suffix", "Prefix7Suffix" }), + }); + + Assert.Equal([3, 7], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_empty_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.text"), + }, includeSpecialCase: true); + + Assert.Equal([21], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_exists_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.text"), + }); + + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_string_null_in_nullable_json() { @@ -833,6 +1000,28 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(1, 10), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_string_empty_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.textOrNull"), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_exists_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.textOrNull"), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_string_contains_in_mixed_json() { @@ -885,7 +1074,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) Filter = ClrFilter.Ne("Json.textArray", "X5"), }); - Assert.Equal(20, actual.Count); + Assert.Equal(19, actual.Count); } [Fact] @@ -921,6 +1110,39 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal([5, 15], actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_string_in_json_text_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.textArray", new List { "X5", "Y7" }), + }); + + Assert.Equal([5, 7], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_empty_in_json_text_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.textArray"), + }, includeSpecialCase: true); + + Assert.Equal([21], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_exists_in_json_text_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.textArray"), + }); + + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_string_contains_in_json_mixed_array() { @@ -943,6 +1165,17 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal([5], actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_string_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.mixedArray", new List { "T5", "T7" }), + }); + + Assert.Equal([5, 7], actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_boolean_true() { @@ -965,6 +1198,17 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(1, 10), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_boolean_in() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Boolean", new List { true }), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_boolean_true_in_json() { @@ -987,6 +1231,50 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(1, 10), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_boolean_not_equal_true_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.boolean", true), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_not_equal_false_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.boolean", false), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.boolean", new List { true }), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_in_json_returns_all_when_both_values() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.boolean", new List { true, false }), + }); + + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_boolean_null_in_nullable_json() { @@ -1020,6 +1308,28 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(11, 20), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_boolean_empty_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.booleanOrNull"), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_exists_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.booleanOrNull"), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_boolean_true_in_mixed_json() { @@ -1064,6 +1374,72 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(1, 15), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_boolean_not_equal_true_in_json_boolean_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.booleanArray", true), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_not_equal_false_in_json_boolean_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.booleanArray", false), + }); + + Assert.Equal(Range(16, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_in_true_in_json_boolean_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.booleanArray", new List { true }), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_in_false_in_json_boolean_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.booleanArray", new List { false }), + }); + + Assert.Equal(Range(1, 15), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_empty_in_json_boolean_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.booleanArray"), + }, includeSpecialCase: true); + + Assert.Equal([21], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_exists_in_json_boolean_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.booleanArray"), + }); + + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_boolean_true_in_json_mixed_array() { @@ -1086,6 +1462,28 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(Range(1, 10), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_boolean_not_equal_true_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.mixedArray", true), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.mixedArray", new List { true }), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_null_on_number() { @@ -1196,6 +1594,28 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(AllExept(6, 12, 18), actual.Order().ToArray()); } + [Fact] + public async Task Should_filter_by_empty_in_mixed_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.mixed"), + }, includeSpecialCase: true); + + Assert.Equal([6, 12, 18, 21], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_exists_in_mixed_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.mixed"), + }, includeSpecialCase: true); + + Assert.Equal(AllExept(6, 12, 18, 21), actual.Order().ToArray()); + } + [Fact] public async Task Should_filter_by_null_in_json_mixed_array() { @@ -1215,6 +1635,28 @@ public abstract class EFQueryTests(ISqlFixture fixture) Filter = ClrFilter.Ne("Json.mixedArray", ClrValue.Null), }); + Assert.Equal(14, actual.Count); + } + + [Fact] + public async Task Should_filter_by_empty_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Empty("Json.mixedArray"), + }, includeSpecialCase: true); + + Assert.Equal([21], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_exists_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.mixedArray"), + }, includeSpecialCase: true); + Assert.Equal(20, actual.Count); } @@ -1230,7 +1672,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) var (sql, parameters) = builder.Compile(); var dbResult = await dbContext.Database.SqlQueryRaw(sql, parameters).FirstOrDefaultAsync(); - Assert.Equal(20, dbResult); + Assert.Equal(21, dbResult); } [Fact] @@ -1251,7 +1693,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) var (sql, parameters) = builder.Compile(); var dbResult = await dbContext.Database.SqlQueryRaw(sql, parameters).FirstOrDefaultAsync(); - Assert.Equal(10, dbResult); + Assert.Equal(11, dbResult); } [Fact] @@ -1266,6 +1708,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) } [Fact] + [Trait("Category", "Slow")] public async Task Should_query_full_text() { var dbContext = await CreateAndPrepareDbContextAsync(); @@ -1281,6 +1724,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) } [Fact] + [Trait("Category", "Slow")] public async Task Should_query_full_text_with_space() { var dbContext = await CreateAndPrepareDbContextAsync(); @@ -1321,7 +1765,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) return result.ToArray(); } - private async Task> QueryAsync(ClrQuery query) + private async Task> QueryAsync(ClrQuery query, bool includeSpecialCase = false) { var dbContext = await CreateAndPrepareDbContextAsync(); @@ -1336,7 +1780,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) File.WriteAllText("D:\\last.json", sql); var dbResult = await dbContext.Set().FromSqlRaw(sql, parameters).ToListAsync(); - return dbResult.Select(x => x.Number).ToList(); + return dbResult.Select(x => x.Number).Where(x => includeSpecialCase || x != 21).ToList(); } private static async Task> PollAsync(TContext dbContext, string sql, object[] parameters, int expectedCount) diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs index 5d98aa4e9..74962a409 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs +++ b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs @@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Hosting; using Squidex.Infrastructure; using Squidex.Infrastructure.Migrations; +using Squidex.Infrastructure.Queries; using Squidex.Providers.MySql; using Squidex.Providers.MySql.Content; using Testcontainers.MySql; @@ -63,6 +64,7 @@ public class MySqlFixture(string? reuseId = null) : IAsyncLifetime, ISqlContentF .AddSingleton() .AddSingletonAs>().Done() .AddSingleton(TestUtils.DefaultSerializer) + .AddSingleton>() .BuildServiceProvider(); foreach (var service in services.GetRequiredService>()) diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs index 3ecbd2e99..d75322fcf 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs +++ b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs @@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Hosting; using Squidex.Infrastructure; using Squidex.Infrastructure.Migrations; +using Squidex.Infrastructure.Queries; using Squidex.Providers.Postgres; using Squidex.Providers.Postgres.Content; using Testcontainers.PostgreSql; @@ -59,6 +60,7 @@ public class PostgresFixture(string? reuseId) : IAsyncLifetime, ISqlContentFixtu .AddSingleton() .AddSingletonAs>().Done() .AddSingleton(TestUtils.DefaultSerializer) + .AddSingleton>() .BuildServiceProvider(); foreach (var service in services.GetRequiredService>()) diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs index 0d5c573c6..06d5d4fb0 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs +++ b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs @@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Hosting; using Squidex.Infrastructure; using Squidex.Infrastructure.Migrations; +using Squidex.Infrastructure.Queries; using Squidex.Providers.SqlServer; using Squidex.Providers.SqlServer.Content; using Testcontainers.MsSql; @@ -61,6 +62,7 @@ public class SqlServerFixture(string? reuseId = null) : IAsyncLifetime, ISqlCont .AddSingleton() .AddSingletonAs>().Done() .AddSingleton(TestUtils.DefaultSerializer) + .AddSingleton>() .BuildServiceProvider(); foreach (var service in services.GetRequiredService>()) diff --git a/frontend/refactor.js b/frontend/refactor.js new file mode 100644 index 000000000..c7caa59c9 --- /dev/null +++ b/frontend/refactor.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); + +const ROOT_DIR = "."; +const SKIP_DIRS = ["node_modules", ".git", "dist", "build", ".cache"]; +const BUTTON_TAG_RE = /]*?)?>/gi; + +function fixButtons(html) { + return html.replace(BUTTON_TAG_RE, (match, attrs) => { + if (attrs && /\btype\s*=/i.test(attrs)) return match; + return attrs ? `