feat: establish legacy parity migration baseline

This commit is contained in:
2026-07-15 07:10:01 +09:00
parent e468f43886
commit f14800656b
36 changed files with 3166 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
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 searchTerm = 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);
}
return new LegacyStockSearchData(query, names, 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);
}