/* * dbMango * * Copyright 2025 Deutsche Bank AG * SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ using System.Diagnostics; using Newtonsoft.Json; using System.Text.RegularExpressions; using MongoDB.Bson.Serialization.Attributes; namespace Rms.Risk.Mango.Pivot.Core; [BsonSerializer( typeof(ArrayBasedPivotDataSerializer))] [JsonConverter( typeof(ArrayBasedPivotDataJsonSerializer))] public class ArrayBasedPivotData : IPivotedData { private readonly List _headers; private List<(string OrigHeader, string DisplayHeader)>? _headersMap; private readonly List _realData = []; private readonly Dictionary _columnTypesCache = []; private readonly List _displayHeaders = []; private int[]? _columnMap; // displayPos -> physicalPos in _headers public static readonly ArrayBasedPivotData NoData = new(["No data"]) { Id = "no_data" }; public IReadOnlyCollection Headers { get { if ( _displayHeaders.Count > 0 && _displayHeaders.Count == _headers.Count) return _displayHeaders; if ( !(_columnMap?.Length > 0) ) return _headers; _displayHeaders.Clear(); _displayHeaders.AddRange( _columnMap .Select((physicalPos, displayPos) => (physicalPos, displayPos)) .OrderBy(x => x.displayPos) .Select( x => _headers[x.physicalPos]) ); // _displayHeaders.AddRange( // _headers // .Select((x, i) => (Name: x, Order: Array.IndexOf(_columnMap, i, 0))) // .OrderBy(x => x.Order) // .Select(x => x.Name) // .ToList() // ); return _displayHeaders; } } public IReadOnlyCollection<(string OrigHeader, string DesplayHeader)> HeadersMap => _headersMap ?? []; public void UpdateHeaders(Func changeColumnName) { // make a copy or original headers if ( _headersMap != null ) throw new ApplicationException("UpdateHeaders can only be called once"); _headersMap = []; for (var i = 0; i < _headers.Count; i++ ) { var newName = changeColumnName(_headers[i]); _headersMap.Add((OrigHeader:_headers[i], DisplayHeader: newName)); _headers[i] = newName; } _displayHeaders.Clear(); } public string Id { get; set; } = ""; public DateTime ExpireAt { get; set; } public int Count => _realData.Count; public object? Get(int displayCol, int row) => this[displayCol, row]; public object? this[int displayCol, int row] { get { if (row >= _realData.Count) throw new ArgumentException($"Row={row} is greater than Count={_realData.Count}"); var physicalCol = _columnMap != null && displayCol < _columnMap.Length ? _columnMap[displayCol] : displayCol; return physicalCol >= _realData[row].Length ? null : _realData[row][physicalCol]; } set { if (row >= _realData.Count) throw new ArgumentException($"Row={row} is greater than Count={_realData.Count}"); var physicalCol = _columnMap != null && displayCol < _columnMap.Length ? _columnMap[displayCol] : displayCol; if (physicalCol >= _realData[row].Length) throw new ArgumentException($"Col={row} is greater than Count={_realData[row].Length} or Row={row}"); _realData[row][physicalCol] = value; } } public ArrayBasedPivotData(IEnumerable headers) { _headers = headers.ToList(); } public ArrayBasedPivotData(IPivotedData other) : this( other.Headers ) { var len = other.Headers.Count; var o = new object?[len]; for ( var row = 0; row < other.Count; row++) { for (var col = 0; col < len; col++) { o[col] = other.Get(col, row); } Add(o); } } private ArrayBasedPivotData( IEnumerable headers, int[]? columnMap ) { _headers = headers.ToList(); _columnMap = columnMap == null ? null : [.. columnMap]; // make a copy } public void Add( IEnumerable data ) { var row = new object?[_headers.Count]; var i = 0; foreach ( var o in data ) { if ( i >= _headers.Count ) throw new ArgumentException($"Length of supplied data must be at least {_headers.Count}", nameof(data)); row[i++] = o; } _realData.Add( row ); } public void AddHeader(string header) { _headers.Add(header); _displayHeaders.Clear(); _columnMap = null; } public bool Contains( string header ) => _headers.Any( x => x == header ); public Type GetColumnType(int displayCol) { if ( _columnTypesCache.TryGetValue( displayCol, out var t ) ) return t; t = DetectColumnType( displayCol ); _columnTypesCache[displayCol] = t; return t; } private Type DetectColumnType(int displayCol) { var guessDouble = 0; var guessLong = 0; var guessInt = 0; var guessDec = 0; for ( var i = 0; i < Math.Min( 200, Count); i++ ) { var o = this[displayCol, i]; if (o == null) continue; if ( o is double ) guessDouble += 1; else if (o is int) guessInt += 1; else if (o is long) guessLong += 1; else if (o is decimal) guessDec += 1; } if (guessDec > guessDouble && guessDec > guessLong && guessDec > guessInt) return typeof(decimal); if (guessDouble > guessLong && guessDouble > guessInt) return typeof(double); if (guessLong > guessInt) return typeof(long); if (guessInt > 0) return typeof(int); return typeof(string); } public void ReorderColumns( IReadOnlyCollection columnsOrder ) { _displayHeaders.Clear(); if ( !(columnsOrder?.Count > 0) ) { _columnMap = []; return; } var src = new List( _headers ); var colMap = new List(); // position = display col / value = real col number foreach ( var regex in columnsOrder.Select( x => new Regex( x, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Singleline ) ).ToArray() ) { var alpha = new List(); // for alpha sorting foreach ( var name in src ) { var m = regex.Match( name ); if ( !m.Success ) continue; alpha.Add( name ); } alpha.Sort(); foreach ( var name in alpha ) { src.Remove( name ); colMap.Add( _headers.IndexOf( name ) ); } } foreach (var name in src) // something what is not fall under any regex colMap.Add(_headers.IndexOf(name)); Debug.Assert( colMap.Count == _headers.Count ); _columnMap = colMap.ToArray(); // set column mapping } /// /// Create filtered copy of data. Filter func must return true if you want row to remain in the output copy. /// /// /// public IPivotedData Filter(Func filter) { var res = new ArrayBasedPivotData(_headers, _columnMap ); for( var i = 0; i < _realData.Count; i++ ) { if (!filter(i)) continue; res.Add(_realData[i]); } return res; } /// /// Create filtered copy of data. Resulting columns present in the data in order of appearance in newColumns. If column is missing it's omitted. /// public IPivotedData FilterColumns(IReadOnlyCollection newColumns, Func? filter = null) { var newColMap = new List<(string, int)>(); foreach (var colName in newColumns) { var index = _headers.IndexOf(colName); if ( index < 0 ) continue; newColMap.Add((colName, index)); } var res = new ArrayBasedPivotData( newColMap.Select(x => x.Item1).ToList() ); for( var i = 0; i < _realData.Count; i++ ) { if (filter != null && !filter(i)) continue; var newData = new object?[newColMap.Count]; foreach (var (oldIndex, newIndex) in newColMap.Select((x,pos) => (OldIndex: x.Item2, NewIndex: pos))) { newData[newIndex] = _realData[i].Length <= oldIndex ? null : _realData[i][oldIndex] ; } res.Add(newData); } return res; } private class RowComparer(IPivotedData pivot, Func, int> comparer) : IComparer { private readonly Dictionary _columnMap = pivot.Headers.Select((x, i) => (x, i)).ToDictionary(x => x.x, x => x.i); private readonly Func, int> _comparer = comparer; public int Compare( object?[]? x, object?[]? y ) => x == null ? -1 : y == null ? 1 : _comparer( x, y, _columnMap ); } /// /// Custom sort for real data. Be careful with views. /// /// public void Sort( Func, int> comparer ) => _realData.Sort(new RowComparer( this, comparer )); }