using System.Data; using System.Globalization; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; /// /// 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. /// 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? _masterCatalog; public LegacyParityStockSearchService(IDataQueryExecutor executor) { _executor = executor ?? throw new ArgumentNullException(nameof(executor)); } public async Task 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(); 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> 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(); 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 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 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); }