Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyParityStockSearchService.cs

175 lines
7.3 KiB
C#

using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
/// <summary>
/// Compatibility search for the operator screen. Unlike the modern lookup it intentionally
/// keeps blank-enter, LIKE wildcard, provider ordering and unlimited-result semantics.
/// Values remain bound parameters so parity does not reintroduce SQL concatenation.
/// </summary>
public sealed class LegacyParityStockSearchService : ILegacyStockLookup
{
public const int LegacyTextBoxMaximumLength = 32_767;
private static readonly SearchProfile[] Profiles =
[
new(
LegacyDomesticStockMarket.Kospi,
DataSourceKind.Oracle,
"LEGACY_PARITY_KOSPI",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'"),
new(
LegacyDomesticStockMarket.Kosdaq,
DataSourceKind.Oracle,
"LEGACY_PARITY_KOSDAQ",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N'"),
new(
LegacyDomesticStockMarket.NxtKospi,
DataSourceKind.MariaDb,
"LEGACY_PARITY_NXT_KOSPI",
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_STOCK a, N_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'"),
new(
LegacyDomesticStockMarket.NxtKosdaq,
DataSourceKind.MariaDb,
"LEGACY_PARITY_NXT_KOSDAQ",
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK a, N_KOSDAQ_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK WHERE F_STOP_GUBUN = 'N'")
];
private readonly IDataQueryExecutor _executor;
private readonly SemaphoreSlim _masterLoadGate = new(1, 1);
private IReadOnlyList<LegacyStockIdentity>? _masterCatalog;
public LegacyParityStockSearchService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (query.Length > LegacyTextBoxMaximumLength)
{
throw new ArgumentOutOfRangeException(
nameof(query),
$"The legacy search text cannot exceed {LegacyTextBoxMaximumLength} characters.");
}
var masterCatalog = await GetMasterCatalogAsync(cancellationToken).ConfigureAwait(false);
var isInitialSearch = HangulSearchMatcher.IsInitialConsonantQuery(query);
// Keep the candidate set identical to the legacy provider searches.
// In particular, NXT search joins the online tables while the complete
// identity master intentionally does not. The master must therefore
// never be used as the visible result set for an initial-only query.
var searchTerm = isInitialSearch
? string.Empty
: query.ToUpper(CultureInfo.CurrentCulture);
var names = new List<string>();
foreach (var profile in Profiles)
{
cancellationToken.ThrowIfCancellationRequested();
var parameter = new DataQueryParameter("search_term", searchTerm, DbType.String);
var spec = new DataQuerySpec(profile.SearchSql, [parameter]);
var table = await _executor
.ExecuteAsync(profile.Source, profile.SearchTableName, spec, cancellationToken)
.ConfigureAwait(false);
AppendNames(table, profile.SearchTableName, names);
}
var displayNames = isInitialSearch
? names.Where(name => HangulSearchMatcher.IsMatch(name, query))
: names;
return new LegacyStockSearchData(query, displayNames, masterCatalog);
}
private async Task<IReadOnlyList<LegacyStockIdentity>> GetMasterCatalogAsync(
CancellationToken cancellationToken)
{
var cached = Volatile.Read(ref _masterCatalog);
if (cached is not null)
{
return cached;
}
await _masterLoadGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cached = Volatile.Read(ref _masterCatalog);
if (cached is not null)
{
return cached;
}
var items = new List<LegacyStockIdentity>();
foreach (var profile in Profiles)
{
cancellationToken.ThrowIfCancellationRequested();
var table = await _executor
.ExecuteAsync(
profile.Source,
$"{profile.SearchTableName}_MASTER",
new DataQuerySpec(profile.MasterSql),
cancellationToken)
.ConfigureAwait(false);
AppendMaster(table, profile, items);
}
cached = Array.AsReadOnly(items.ToArray());
Volatile.Write(ref _masterCatalog, cached);
return cached;
}
finally
{
_masterLoadGate.Release();
}
}
private static void AppendNames(DataTable? table, string sourceName, ICollection<string> target)
{
ValidateTable(table, sourceName);
foreach (DataRow row in table!.Rows)
{
target.Add(Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty);
}
}
private static void AppendMaster(
DataTable? table,
SearchProfile profile,
ICollection<LegacyStockIdentity> target)
{
ValidateTable(table, $"{profile.SearchTableName}_MASTER");
foreach (DataRow row in table!.Rows)
{
target.Add(new LegacyStockIdentity(
profile.Market,
Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty,
Convert.ToString(row[1], CultureInfo.CurrentCulture) ?? string.Empty));
}
}
private static void ValidateTable(DataTable? table, string sourceName)
{
if (table is null || table.Columns.Count < 2)
{
throw new InvalidOperationException(
$"Legacy stock data from {sourceName} does not contain two columns.");
}
}
private sealed record SearchProfile(
LegacyDomesticStockMarket Market,
DataSourceKind Source,
string SearchTableName,
string SearchSql,
string MasterSql);
}