Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ The .NET package table is generated by `scripts/ci/generate-third-party-notices.
| Microsoft.AspNetCore.Authentication.JwtBearer | 11.0.0 | MIT | https://asp.net/ |
| Microsoft.AspNetCore.Authentication.OpenIdConnect | 11.0.0 | MIT | https://asp.net/ |
| Microsoft.AspNetCore.OpenApi | 11.0.0 | MIT | https://asp.net/ |
| Microsoft.AspNetCore.OutputCaching.StackExchangeRedis | 11.0.0 | MIT | https://asp.net/ |
| Microsoft.AspNetCore.SignalR.StackExchangeRedis | 11.0.0 | MIT | https://asp.net/ |
| Microsoft.AspNetCore.WebUtilities | 11.0.0 | MIT | https://asp.net/ |
| Microsoft.CodeAnalysis.CSharp | 4.14.0 | MIT | https://github.com/dotnet/roslyn |
| Microsoft.Data.SqlClient | 6.1.2 | MIT | https://aka.ms/sqlclientproject |
| Microsoft.Data.Sqlite | 11.0.0 | MIT | https://docs.microsoft.com/dotnet/standard/data/sqlite/ |
Expand Down
185 changes: 185 additions & 0 deletions src/Honua.Core/Core/Queries/Filters/LegacyWhereClauseParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) Honua Geo Inc. All rights reserved.
// Licensed under the Fair Core License, Version 1.0, ALv2 Future License. See LICENSE in the project root.

using System.Globalization;
using System.Text.RegularExpressions;

namespace Honua.Core.Queries.Filters;

internal enum LegacyWhereGrammar
{
Basic,
Warehouse,
Postgres,
StorageMapped,
}

internal enum LegacyWhereValuePolicy
{
NumericOrText,
Text,
NumericRequired,
}

internal enum LegacyWhereExpressionKind
{
True,
NullCheck,
In,
Comparison,
}

internal readonly record struct LegacyWhereExpression(LegacyWhereExpressionKind Kind, Match Match);

internal static partial class LegacyWhereClauseParser
{
internal static IEnumerable<LegacyWhereExpression> Parse(
List<string> expressions, LegacyWhereGrammar grammar, string unsupportedMessage = "WHERE clause format not supported.")
{
if (expressions.Count == 0)
{
throw new ArgumentException(unsupportedMessage);
}

// Keep parameter accumulation order when a later term fails.
foreach (var expression in expressions)
{
yield return ParseTerm(expression.Trim(), grammar, unsupportedMessage);
}
}

private static LegacyWhereExpression ParseTerm(string term, LegacyWhereGrammar grammar, string unsupportedMessage)
{
var isTrue = grammar == LegacyWhereGrammar.Postgres
? IsTrueLiteral(term)
: grammar != LegacyWhereGrammar.StorageMapped &&
(term.Equals("1=1", StringComparison.Ordinal) || term.Equals("TRUE", StringComparison.OrdinalIgnoreCase));
if (isTrue)
{
return new(LegacyWhereExpressionKind.True, Match.Empty);
}

var nullMatch = NullCheckPattern(grammar).Match(term);
if (nullMatch.Success)
{
return new(LegacyWhereExpressionKind.NullCheck, nullMatch);
}

if (grammar != LegacyWhereGrammar.Basic)
{
var inMatch = InPattern(grammar).Match(term);
if (inMatch.Success)
{
return new(LegacyWhereExpressionKind.In, inMatch);
}
}

var comparison = ComparisonPattern(grammar).Match(term);
return comparison.Success
? new(LegacyWhereExpressionKind.Comparison, comparison)
: throw new ArgumentException(unsupportedMessage);
}

internal static bool IsTrueLiteral(string term) => PostgresTrueLiteralRegex().IsMatch(term);

private static Regex NullCheckPattern(LegacyWhereGrammar grammar) => grammar switch
{
LegacyWhereGrammar.Basic or LegacyWhereGrammar.StorageMapped => BasicNullCheckRegex(),
LegacyWhereGrammar.Warehouse => WarehouseNullCheckRegex(),
_ => PostgresNullCheckRegex(),
};

private static Regex ComparisonPattern(LegacyWhereGrammar grammar) => grammar switch
{
LegacyWhereGrammar.Basic or LegacyWhereGrammar.StorageMapped => BasicComparisonRegex(),
LegacyWhereGrammar.Warehouse => WarehouseComparisonRegex(),
_ => PostgresComparisonRegex(),
};

private static Regex InPattern(LegacyWhereGrammar grammar) => grammar switch
{
LegacyWhereGrammar.Warehouse => WarehouseInExpressionRegex(),
LegacyWhereGrammar.StorageMapped => StorageMappedInRegex(),
_ => PostgresInRegex(),
};

internal static string NormalizeOperator(string value, LegacyWhereGrammar grammar, string? unsupportedMessage = null)
=> value.Trim().ToUpperInvariant() switch
{
"NOT LIKE" => "NOT LIKE",
"LIKE" => "LIKE",
">=" => ">=",
"<=" => "<=",
"!=" or "<>" => grammar == LegacyWhereGrammar.Warehouse ? "<>" : "!=",
"=" => "=",
">" => ">",
"<" => "<",
_ => throw new ArgumentException(unsupportedMessage ?? $"Unsupported operator: {value}"),
};

internal static object ParseValueToken(string value, LegacyWhereValuePolicy policy = LegacyWhereValuePolicy.NumericOrText)
{
if (value.StartsWith('\'') && value.EndsWith('\''))
{
return value[1..^1].Replace("''", "'", StringComparison.Ordinal);
}
if (policy != LegacyWhereValuePolicy.Text && decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
{
return number;
}
return policy == LegacyWhereValuePolicy.NumericRequired ? throw new ArgumentException($"Unrecognized value token: {value}") : value;
}

internal static MatchCollection MatchInValues(string values) => WarehouseInValueRegex().Matches(values);

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*)\s*(?<op>NOT\s+LIKE|LIKE|>=|<=|!=|<>|=|>|<)\s*(?<value>'(?:''|[^'])*'|-?\d+(?:\.\d+)?)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BasicComparisonRegex();

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*)\s+IS\s+(?<not>NOT\s+)?NULL$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BasicNullCheckRegex();

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*)\s*(?<op>NOT\s+LIKE|LIKE|>=|<=|!=|<>|=|>|<)\s*(?<value>'(?:''|[^'])*'|-?\d+(?:\.\d+)?)\z",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex WarehouseComparisonRegex();

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*)\s+IS\s+(?<not>NOT\s+)?NULL\z",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex WarehouseNullCheckRegex();

[GeneratedRegex(@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*)\s+IN\s*\((?<values>(?:'(?:''|[^'])*'|-?\d+(?:\.\d+)?)(?:\s*,\s*(?:'(?:''|[^'])*'|-?\d+(?:\.\d+)?))*)\)\z", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex WarehouseInExpressionRegex();

[GeneratedRegex(@"'(?:''|[^'])*'|-?\d+(?:\.\d+)?", RegexOptions.CultureInvariant)]
private static partial Regex WarehouseInValueRegex();

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*(?:->>'[^']+')?)\s*(?<op>NOT\s+LIKE|LIKE|>=|<=|!=|<>|=|>|<)\s*(?<value>'(?:''|[^'])*'|-?\d+(?:\.\d+)?)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex PostgresComparisonRegex();

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*(?:->>'[^']+')?)\s+IS\s+(?<not>NOT\s+)?NULL$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex PostgresNullCheckRegex();

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*(?:->>'[^']+')?)\s+IN\s*\((?<values>(?:'(?:''|[^'])*'|-?\d+(?:\.\d+)?)(?:\s*,\s*(?:'(?:''|[^'])*'|-?\d+(?:\.\d+)?))*)\)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex PostgresInRegex();

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*)\s+IN\s*\((?<values>(?:'(?:''|[^'])*'|-?\d+(?:\.\d+)?)(?:\s*,\s*(?:'(?:''|[^'])*'|-?\d+(?:\.\d+)?))*)\)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex StorageMappedInRegex();

[GeneratedRegex(
@"^(?:1\s*=\s*1|TRUE)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex PostgresTrueLiteralRegex();
}
Original file line number Diff line number Diff line change
Expand Up @@ -1196,34 +1196,17 @@ private static string ParseAndParameterizeWhereClause(
{
// Use direct column references for DuckDB (no JSONB path expressions)
// Delegate to shared WHERE parsing with DuckDB column mapping
var expressions = WhereClauseSplitter.SplitOnAnd(whereClause);
if (expressions.Count == 0)
var parameterizedExpressions = new List<string>();
foreach (var expression in LegacyWhereClauseParser.Parse(WhereClauseSplitter.SplitOnAnd(whereClause), LegacyWhereGrammar.Basic))
{
throw new ArgumentException("WHERE clause format not supported.");
}

var parameterizedExpressions = new List<string>(expressions.Count);

// Not rewritten as .Select(): each iteration is a multi-branch parser that can
// throw, mutate the shared `paramIndex`/`parameters` accumulators, and `continue`
// early per branch -- not a pure map of one iteration variable to another.
foreach (var trimmed in (expressions).Select(expression => expression.Trim()))
{
if (trimmed.Length == 0)
{
throw new ArgumentException("WHERE clause format not supported.");
}

if (trimmed.Equals("1=1", StringComparison.Ordinal) ||
trimmed.Equals("TRUE", StringComparison.OrdinalIgnoreCase))
if (expression.Kind == LegacyWhereExpressionKind.True)
{
parameterizedExpressions.Add("TRUE");
continue;
}

// NULL check pattern: field IS [NOT] NULL
var nullMatch = NullCheckRegex().Match(trimmed);
if (nullMatch.Success)
var nullMatch = expression.Match;
if (expression.Kind == LegacyWhereExpressionKind.NullCheck)
{
var field = nullMatch.Groups["field"].Value;
ValidateFieldName(field);
Expand All @@ -1235,15 +1218,15 @@ private static string ParseAndParameterizeWhereClause(
}

// Comparison pattern: field op value
var compMatch = ComparisonRegex().Match(trimmed);
if (compMatch.Success)
var compMatch = expression.Match;
if (expression.Kind == LegacyWhereExpressionKind.Comparison)
{
var field = compMatch.Groups["field"].Value;
ValidateFieldName(field);
EnsureFieldIsConfigured(field, mapping);
var op = NormalizeOperator(compMatch.Groups["op"].Value);
var op = LegacyWhereClauseParser.NormalizeOperator(compMatch.Groups["op"].Value, LegacyWhereGrammar.Basic);
var valueToken = compMatch.Groups["value"].Value;
var value = ParseValueToken(valueToken);
var value = LegacyWhereClauseParser.ParseValueToken(valueToken);

parameterizedExpressions.Add($"{DuckDBExternalSourceSql.QuoteIdentifier(field)} {op} ${paramIndex++}");
parameters.Add(value);
Expand All @@ -1256,45 +1239,6 @@ private static string ParseAndParameterizeWhereClause(
return string.Join(" AND ", parameterizedExpressions);
}

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*)\s*(?<op>NOT\s+LIKE|LIKE|>=|<=|!=|<>|=|>|<)\s*(?<value>'(?:''|[^'])*'|-?\d+(?:\.\d+)?)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ComparisonRegex();

[GeneratedRegex(
@"^(?<field>[a-zA-Z_][a-zA-Z0-9_]*)\s+IS\s+(?<not>NOT\s+)?NULL$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex NullCheckRegex();

private static string NormalizeOperator(string op) => op.Trim().ToUpperInvariant() switch
{
"NOT LIKE" => "NOT LIKE",
"LIKE" => "LIKE",
">=" => ">=",
"<=" => "<=",
"!=" or "<>" => "!=",
"=" => "=",
">" => ">",
"<" => "<",
_ => throw new ArgumentException($"Unsupported operator: {op}")
};

private static object ParseValueToken(string valueToken)
{
if (valueToken.StartsWith('\'') && valueToken.EndsWith('\''))
{
return valueToken[1..^1].Replace("''", "'");
}

if (decimal.TryParse(valueToken, NumberStyles.Float, CultureInfo.InvariantCulture, out var num))
{
return num;
}

return valueToken;
}


/// <summary>
/// Builds the SQL expression for a spatial filter geometry parameter, transforming
/// it from <see cref="SpatialFilter.Srid"/> into the layer SRID when they differ
Expand Down
Loading
Loading