diff --git a/src/Runtime/XSharp.SQLRdd/Classes/Command.prg b/src/Runtime/XSharp.SQLRdd/Classes/Command.prg index 712e83ab44..5aad8a2cce 100644 --- a/src/Runtime/XSharp.SQLRdd/Classes/Command.prg +++ b/src/Runtime/XSharp.SQLRdd/Classes/Command.prg @@ -33,9 +33,63 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable property Provider as ISqlDbProvider get iif(Connection == null, null, Connection:Provider) /// The text of the Command object. property CommandText as string get iif(DbCommand == null, "", DbCommand:CommandText) set DbCommand:CommandText := value + /// The table the RDD reads with this command: its read statements may be answered from the + /// connection's result cache (see SqlDbConnection.ResultCacheMs). NULL = never. + internal property CacheTable as string auto #endregion + private static _writeWords := System.Text.RegularExpressions.Regex{ ; + "\b(insert|update|delete|merge|into|exec|execute|create|alter|drop|truncate)\b", ; + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled} as System.Text.RegularExpressions.Regex + + /// TRUE for a single SELECT that writes nothing. + /// + /// A batch does not need a semicolon between its statements, so for a statement that does not come + /// from the RDD (no CacheTable) a word that writes counts as a write (also when the word is part of a + /// literal: then the cache is just dropped once too often). The RDD's own reads are SELECTs it builds, + /// and searching them costs more than the statement itself: a page of a table with 150 columns is a + /// few KB of text. + /// + private method _IsReadOnlySelect(cText as string) as logic + return cText:TrimStart():StartsWith("select", StringComparison.OrdinalIgnoreCase) .and. cText:IndexOf(';') < 0 ; + .and. (!String.IsNullOrEmpty(SELF:CacheTable) .or. !_writeWords:IsMatch(cText)) + end method + + /// The statement as cache key, or NULL when its result may not be reused: no cache, not an RDD + /// read (CacheTable), parameters, or not a single SELECT. Call it after CallBack(), the key is the + /// statement that is sent. + private method _CacheKey() as string + if String.IsNullOrEmpty(SELF:CacheTable) .or. SELF:Connection:ResultCache == null .or. SELF:Parameters?:Count > 0 + return null + endif + var cText := SELF:CommandText + if cText == null .or. !SELF:_IsReadOnlySelect(cText) + return null + endif + return cText + end method + + /// Before a statement that is not a SELECT runs: drop the cached results it may change, those of + /// CacheTable for a write of the RDD, all of them for a statement of somebody else. + private method _InvalidateCache() as void + var oCache := SELF:Connection:ResultCache + if oCache == null + return + endif + var cText := SELF:CommandText + if cText == null .or. SELF:_IsReadOnlySelect(cText) + return + endif + if !String.IsNullOrEmpty(SELF:CacheTable) + oCache:Invalidate(SELF:CacheTable) + elseif cText:IndexOf(SqlDbConnection.LockTableName, StringComparison.OrdinalIgnoreCase) < 0 + // the lock table refresh runs on a timer and changes no data + oCache:Clear() + endif + return + end method + private withEvents as LOGIC #region static properties and methods @@ -155,6 +209,8 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable /// /// The name of the DataTable (when the template cannot be used) /// An empty table with the columns the command returns, or NULL + /// An empty table with the structure of oTemplate to load the rows into instead + /// of a copy of oTemplate (copying the structure of a wide table is not free), or NULL. /// /// Building the structure from the reader's schema (DataTable.Load()) is the expensive part of /// a query that returns one or a few rows of a wide table: 1.5 ms against 0.5 ms for 178 @@ -162,12 +218,29 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable /// the structure of its buffer. When the reader's columns do not match the template, the /// rows are loaded as by GetDataTable(cTable). /// - method GetDataTable(cTable as string, oTemplate as DataTable) as DataTable + method GetDataTable(cTable as string, oTemplate as DataTable, oTarget := null as DataTable) as DataTable if oTemplate == null return SELF:GetDataTable(cTable) endif try - using var reader := SELF:ExecuteReader(cTable) + if String.IsNullOrEmpty(cTable) + cTable := SELF:Name + endif + // the cache key is the statement as the callback leaves it, and a cached result passes it as well + SELF:CallBack(cTable) + local oCached as SqlDbResultCacheEntry + var cKey := SELF:_CacheKey() + if cKey != null .and. SELF:Connection:ResultCache:TryGet(cKey, out oCached) .and. !oCached:IsScalar ; + .and. oCached:Columns == oTemplate:Columns:Count + var oResult := iif(oTarget != null, oTarget, oTemplate:Clone()) + oResult:BeginLoadData() + foreach var aRow in oCached:Rows + oResult:LoadDataRow(aRow, true) + next + oResult:EndLoadData() + return oResult + endif + using var reader := SELF:_ExecuteReader(cTable, FALSE) if reader == null return null endif @@ -180,14 +253,26 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable next local oDataTable as DataTable if lMatch - oDataTable := oTemplate:Clone() + oDataTable := iif(oTarget != null, oTarget, oTemplate:Clone()) var aValues := object[]{reader:FieldCount} + var aRows := iif(cKey != null, List{}, null) oDataTable:BeginLoadData() do while reader:Read() reader:GetValues(aValues) oDataTable:LoadDataRow(aValues, true) + if aRows != null + if aRows:Count < SqlDbResultCache.MaxRows + aRows:Add((object[]) aValues:Clone()) + else + aRows := null + cKey := null + endif + endif enddo oDataTable:EndLoadData() + if aRows != null + SELF:Connection:ResultCache:Put(cKey, SqlDbResultCacheEntry{}{Table := SELF:CacheTable, Rows := aRows, Columns := reader:FieldCount}) + endif else oDataTable := DataTable{iif(String.IsNullOrEmpty(cTable), SELF:Name, cTable)} var oDataSet := DataSet{} @@ -216,13 +301,23 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable cTable := SELF:Name endif SELF:CallBack(cTable) + local oCached as SqlDbResultCacheEntry + var cKey := SELF:_CacheKey() + if cKey != null .and. SELF:Connection:ResultCache:TryGet(cKey, out oCached) .and. oCached:IsScalar + return oCached:Scalar + endif + SELF:_InvalidateCache() if self:Connection:DbTransaction != null self:DbTransaction := self:Connection:DbTransaction endif if ! SELF:_TryBindParameters() return null endif - return self:DbCommand:ExecuteScalar() + var oResult := self:DbCommand:ExecuteScalar() + if cKey != null + SELF:Connection:ResultCache:Put(cKey, SqlDbResultCacheEntry{}{Table := SELF:CacheTable, Scalar := oResult, IsScalar := TRUE}) + endif + return oResult catch e as Exception Connection:LastException := e return null @@ -237,12 +332,19 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable /// When an error occurs then the error is registered in the LastException property of the Connection /// method ExecuteReader(cTable := "" as string) as DbDataReader + return SELF:_ExecuteReader(cTable, TRUE) + end method + + private method _ExecuteReader(cTable as string, lCallBack as logic) as DbDataReader try self:Connection:ForceOpen() if String.IsNullOrEmpty(cTable) cTable := SELF:Name endif - SELF:CallBack(cTable) + if lCallBack + SELF:CallBack(cTable) + endif + SELF:_InvalidateCache() if self:Connection:DbTransaction != null self:DbTransaction := self:Connection:DbTransaction endif @@ -266,6 +368,7 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable method ExecuteNonQuery(cTable := "" as string) as LOGIC try self:Connection:ForceOpen() + SELF:_InvalidateCache() if String.IsNullOrEmpty(cTable) cTable := SELF:Name endif diff --git a/src/Runtime/XSharp.SQLRdd/Classes/Connection.prg b/src/Runtime/XSharp.SQLRdd/Classes/Connection.prg index 1ce955f251..9dc87680fd 100644 --- a/src/Runtime/XSharp.SQLRdd/Classes/Connection.prg +++ b/src/Runtime/XSharp.SQLRdd/Classes/Connection.prg @@ -111,6 +111,46 @@ class SqlDbConnection inherit SqlDbHandleObject implements IDisposable /// property UseLocks as logic auto + /// + /// How long (milliseconds) the RDD may reuse the result of an identical read statement (default 0 = never). + /// + /// + /// Code written for DBF/ADS repeats the same lookups again and again, a round trip each against a + /// server. With a value > 0 the RDD answers a read statement it sent within that time from its + /// result. A table's results are dropped as soon as this connection writes to it or locks one of its + /// records, and all of them when a transaction is rolled back or a statement not sent by the RDD + /// writes. Changes of other users are seen at the latest after that time. + /// + property ResultCacheMs as long + get + return _resultCacheMs + end get + set + _resultCacheMs := value + // While it is off nothing is invalidated: results from before must not come back + // when it is switched on again. + if value <= 0 + _resultCache := null + endif + end set + end property + + private _resultCacheMs as long + private _resultCache as SqlDbResultCache + /// The cache for ResultCacheMs, NULL when it is off. + internal property ResultCache as SqlDbResultCache + get + if SELF:ResultCacheMs <= 0 + return null + endif + if _resultCache == null + _resultCache := SqlDbResultCache{SELF:ResultCacheMs} + endif + _resultCache:MaxAge := SELF:ResultCacheMs + return _resultCache + end get + end property + /// Identifier Case as returned by the Ado.Net provider in the DataSourceInformation metadata collection. property IdentifierCase as System.Data.Common.IdentifierCase auto get private set /// Quoted Identifier Case as returned by the Ado.Net provider in the DataSourceInformation metadata collection. @@ -222,6 +262,10 @@ class SqlDbConnection inherit SqlDbHandleObject implements IDisposable self:UseNulls := isTrue case "uselocks" self:UseLocks := isTrue + case "resultcachems" + if Int32.TryParse(strValue, out var cacheMs) + self:ResultCacheMs := cacheMs + endif otherwise builder:Add(key, strValue) end switch @@ -461,6 +505,8 @@ class SqlDbConnection inherit SqlDbHandleObject implements IDisposable /// method RollBackTrans as logic if self:DbTransaction != null + // results read inside the transaction may show changes that are now undone + self:_resultCache?:Clear() try self:DbTransaction:Rollback() self:DbTransaction := null diff --git a/src/Runtime/XSharp.SQLRdd/Classes/ResultCache.prg b/src/Runtime/XSharp.SQLRdd/Classes/ResultCache.prg new file mode 100644 index 0000000000..ffd048da66 --- /dev/null +++ b/src/Runtime/XSharp.SQLRdd/Classes/ResultCache.prg @@ -0,0 +1,112 @@ +// +// Copyright (c) XSharp B.V. All Rights Reserved. +// Licensed under the Apache License, Version 2.0. +// See License.txt in the project root for license information. +// + +using System +using System.Collections.Generic +using System.Diagnostics + +begin namespace XSharp.RDD.SqlRDD + +/// +/// Results of identical read statements of the RDD, reused for a short time +/// (see SqlDbConnection.ResultCacheMs). +/// +/// +/// Code written for DBF/ADS repeats the same lookups over and over (the same seek into a tax or +/// parameter table for every line of a document): free with a local index, a round trip each with +/// a server. The entries of a table are dropped as soon as this connection writes to the table or +/// locks a record in it; changes of other users are seen at the latest when an entry expires. +/// +internal class SqlDbResultCache + private _entries as Dictionary + private _lock as object + private const MaxEntries := 1000 as long + /// Larger results (pages of a sequential read) are not kept: they are hardly repeated, and + /// copying them cost more than it saved. + internal const MaxRows := 20 as long + + /// Maximum age of an entry in milliseconds. + property MaxAge as long auto + + constructor(nMaxAge as long) + _entries := Dictionary{StringComparer.Ordinal} + _lock := object{} + MaxAge := nMaxAge + return + + method TryGet(cStatement as string, oEntry out SqlDbResultCacheEntry) as logic + begin lock _lock + if _entries:TryGetValue(cStatement, out oEntry) + if (Stopwatch.GetTimestamp() - oEntry:Created) * 1000 / Stopwatch.Frequency <= MaxAge + return true + endif + _entries:Remove(cStatement) + endif + end lock + oEntry := null + return false + + method Put(cStatement as string, oEntry as SqlDbResultCacheEntry) as void + oEntry:Created := Stopwatch.GetTimestamp() + begin lock _lock + if _entries:Count >= MaxEntries + SELF:_RemoveExpired() + if _entries:Count >= MaxEntries + _entries:Clear() + endif + endif + _entries[cStatement] := oEntry + end lock + return + + private method _RemoveExpired() as void + var nNow := Stopwatch.GetTimestamp() + var aKeys := List{} + foreach var oPair in _entries + if (nNow - oPair:Value:Created) * 1000 / Stopwatch.Frequency > MaxAge + aKeys:Add(oPair:Key) + endif + next + foreach var cKey in aKeys + _entries:Remove(cKey) + next + return + + /// Drop the entries of a table. + method Invalidate(cTable as string) as void + begin lock _lock + if _entries:Count > 0 + var aKeys := List{} + foreach var oPair in _entries + if String.Equals(oPair:Value:Table, cTable, StringComparison.OrdinalIgnoreCase) + aKeys:Add(oPair:Key) + endif + next + foreach var cKey in aKeys + _entries:Remove(cKey) + next + endif + end lock + return + + method Clear() as void + begin lock _lock + _entries:Clear() + end lock + return +end class + +/// The result of one statement: rows (object arrays) or a scalar. +internal class SqlDbResultCacheEntry + property Table as string auto + property Created as int64 auto + property Rows as List auto + property Columns as long auto + property Scalar as object auto + property IsScalar as logic auto +end class + +end namespace diff --git a/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Main.prg b/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Main.prg index 61ef6ab3c5..fc2d5a24af 100644 --- a/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Main.prg +++ b/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Main.prg @@ -150,6 +150,8 @@ partial class SQLRDD inherit Workarea if !self:_oTd:HasRecnoColumn throw Exception{"RecnoColumn is required for table " + info:FileName} endif + // its read statements may be answered from the result cache (SqlDbConnection.ResultCacheMs) + _command:CacheTable := self:_oTd:RealName endif local aUpdatableColumns := null as HashSet if _oTd:HasUpdatableColumns @@ -279,12 +281,11 @@ partial class SQLRDD inherit Workarea endif var lResult := SELF:GoCold() if lResult - self:_eofPending := false - var key := (dword) self:_builder:GetNextKey() + // With a recno column the insert below returns the new record number; asking the server + // for MAX(recno) first only cost a round trip per Append(). + var key := iif(self:_recnoColumNo != -1, 0U, (dword) self:_builder:GetNextKey()) var row := self:DataTable:NewRow() self:DataTable:Rows:Add(row) - // The new row does not belong at the end of the buffered slice of the order. - self:_bufferContiguous := false if _emptyValues == null self:_GetEmptyValues() endif @@ -296,9 +297,19 @@ partial class SQLRDD inherit Workarea row[c] := SELF:_HandleNullDate(values[c:Ordinal],c) endif next - self:_ExecuteInsertStatement(row) + if !self:_ExecuteInsertStatement(row) + // the error is reported, stay where we were (buffer and EOF state unchanged) + self:DataTable:Rows:Remove(row) + return false + endif + self:_eofPending := false + // The new row does not belong at the end of the buffered slice of the order. + self:_bufferContiguous := false row:AcceptChanges() - _updatedRecNos:Add((int)row[self:_recnoColumNo]) + if self:_recnoColumNo != -1 + key := Convert.ToUInt32(row[self:_recnoColumNo]) + endif + _updatedRecNos:Add((int) key) SELF:RowNumber := SELF:DataTable:Rows:Count self:_serverReccount += 1 if self:_tableReccountValid @@ -655,6 +666,8 @@ partial class SQLRDD inherit Workarea if !self:_ForceOpen() return false endif + // VO resets FOUND to FALSE after a record movement + SELF:_Found := FALSE // Reloading the buffer below discards pending FieldPut()s, so write them first (DBF does too). SELF:GoCold() SELF:_outsideOrder := FALSE @@ -683,6 +696,7 @@ partial class SQLRDD inherit Workarea if !self:_ForceOpen() return false endif + SELF:_Found := FALSE // Reloading the buffer below discards pending FieldPut()s, so write them first (DBF does too). SELF:GoCold() SELF:_outsideOrder := FALSE @@ -732,11 +746,12 @@ partial class SQLRDD inherit Workarea SELF:GoCold() IF nToSkip != 0 .AND. SELF:_eofPending // On the phantom record after GoTo(0), with a buffer that does not end at the last - // record: moving forward stays at EOF, moving backward starts from the last record. - SELF:_eofPending := FALSE + // record: moving forward stays at EOF (still pending, so a second Skip(1) does not read on + // behind the buffer), moving backward starts from the last record. IF nToSkip > 0 RETURN TRUE ENDIF + SELF:_eofPending := FALSE isOK := SELF:GoBottom() IF isOK .AND. nToSkip < -1 isOK := SELF:_SkipInBuffer(nToSkip + 1) @@ -759,7 +774,8 @@ partial class SQLRDD inherit Workarea // phantom row is only built once a SELECT actually loaded a schema - see the // DataTable setter) - same case GetValue() already guards against above. if self:CurrentRow == null .or. oRec != self:CurrentRow[self:_recnoColumNo] - result := SELF:GoTo( (DWORD) nRec ) + // GoTo() sets EOF/BOF itself (at the phantom record BOF as ADS does) + return SELF:GoTo( (DWORD) nRec ) endif CATCH ex AS Exception SELF:_dbfError(ex, Subcodes.EDB_GOTO,Gencode.EG_DATATYPE, "SQLRDD.GoToId",FALSE) @@ -781,7 +797,9 @@ partial class SQLRDD inherit Workarea IF nRec == 0 // DBF semantics: GoTo(0) positions on the phantom record (EOF) without any I/O. // Browsers do this for every empty row they paint, so it must not cost a query. - SELF:_GoToPhantom(TRUE) + // ADS sets BOF as well, as for any record number that does not exist. + SELF:_GoToPhantom() + SELF:_SetBOF(TRUE) RETURN TRUE ENDIF SELF:_eofPending := FALSE @@ -802,7 +820,6 @@ partial class SQLRDD inherit Workarea SELF:RowNumber := rowIndex SELF:_SetEOF(FALSE) SELF:_SetBOF(FALSE) - SELF:_Found := TRUE return true endif rowIndex++ @@ -819,17 +836,39 @@ partial class SQLRDD inherit Workarea // rows instead of one query per row. (A single record left by an order change does not // count - SetOrder() followed by GoTo(saved record) is the usual restore pattern.) local found as logic + var oLastError := SELF:Connection:LastException if SELF:_tableMode == TableMode.Table .and. SELF:_recnoColumNo >= 0 .and. SELF:DataTable != null ; .and. !(SELF:_lazyGoto .and. !SELF:_bufferContiguous .and. SELF:RowCount <= 1) - found := SELF:_LoadSingleRecord(nRec) + var nFetched := SELF:_FetchSingleRecord(nRec) + found := nFetched == 1 if found SELF:_lazyGoto := TRUE + elseif nFetched == 0 + // The record does not exist (e.g. LastRec()+1, saved at EOF and restored): EOF without + // looking it up again in the order. ADS sets BOF as well (DBFCDX only for an empty file). + // TRUE as in DBF: positioning on the phantom record succeeded. FALSE made the VO + // DbServer:GoTo() build and throw an error object every time. + SELF:_GoToPhantom() + SELF:_SetBOF(TRUE) + RETURN TRUE else found := SELF:_GotoRecord(nRec) endif else found := SELF:_GotoRecord(nRec) endif + if !found .and. SELF:_tableMode == TableMode.Table + // A failed query does not mean that the record does not exist (timeout, no permission ...) + var oError := SELF:Connection:LastException + if oError != null .and. oError != oLastError + SELF:_dbfError(ERDD.READ, XSharp.Gencode.EG_READ, "SqlRDD:GoTo", oError:Message, FALSE) + RETURN FALSE + endif + // does not exist, as above + SELF:_GoToPhantom() + SELF:_SetBOF(TRUE) + RETURN TRUE + endif SELF:_CheckEofBof() RETURN found end method @@ -872,14 +911,25 @@ partial class SQLRDD inherit Workarea override method Skip(nToSkip as long) as logic LOCAL result := FALSE AS LOGIC SELF:_ForceOpen() + IF nToSkip != 0 + SELF:_Found := FALSE + ENDIF SELF:_Top := FALSE SELF:_Bottom := FALSE IF nToSkip != 0 // e.g. right after SetOrder(): the record's position in the order is still unknown - SELF:_EnsurePositioned() + IF !SELF:_EnsurePositioned() + RETURN FALSE + ENDIF ENDIF IF nToSkip == 0 result := SELF:GoCold() + ELSEIF nToSkip < 0 .AND. SELF:_EoF .AND. SELF:_BoF + // EOF and BOF at once (e.g. after GoTo() to a record that does not exist): ADS lands on + // the first record and keeps BOF, as for any backward skip at BOF. + SELF:GoCold() + result := SELF:GoTop() + SELF:BoF := TRUE ELSEIF SELF:_outsideOrder // Positioned (via GoTo()) on a record outside the current order (OrderKeyNo 0). // RowNumber/_bufferStart do not correspond to any real position in the order's @@ -997,8 +1047,13 @@ partial class SQLRDD inherit Workarea lockInfo:Result := false return false endif + // Read fresh after a lock: the record may be written next. + if self:_oTd != null + self:Connection:ResultCache?:Invalidate(self:_oTd:RealName) + endif if !self:Connection:UseLocks - // Locking switched off for this connection (see SqlDbConnection.UseLocks). + // Locking switched off for this connection (see SqlDbConnection.UseLocks). Nothing is + // locked, so the record is not read again either (see _RefreshLockedRow()). lockInfo:Result := true return true endif @@ -1024,6 +1079,7 @@ partial class SQLRDD inherit Workarea if myLock SELF:_ownLocks:Add(nLockRecNo) + SELF:_RefreshLockedRow(nLockRecNo) lockInfo:Result := true return true endif @@ -1061,6 +1117,7 @@ partial class SQLRDD inherit Workarea end try SELF:_ownLocks:Add(nLockRecNo) + SELF:_RefreshLockedRow(nLockRecNo) lockInfo:Result := true return true end method diff --git a/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Orders.prg b/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Orders.prg index 61f80f81d8..af0b8a4875 100644 --- a/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Orders.prg +++ b/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Orders.prg @@ -202,6 +202,8 @@ partial class SQLRDD aCurrentRow := SELF:CurrentRow:ItemArray endif var lTableCountValid := SELF:_tableReccountValid + // ADS keeps BOF as it was when the order changes at EOF + var lWasBof := SELF:_BoF if (orderInfo:Order is long var nOrder0 .and. nOrder0 == 0) .or. ; (orderInfo:Order is string var cOrder0 .and. String.IsNullOrEmpty(cOrder0)) // Order 0 (or an empty order name) means: switch back to the natural/physical @@ -221,7 +223,8 @@ partial class SQLRDD self:_tableReccountValid := lTableCountValid if lReset self:_RepositionAfterOrderChange(currentRecord, lWasEof, aCurrentRow) - else + self:_RestoreBofAtEof(lWasEof, lWasBof) + elseif !lWasEof self:GoTo(currentRecord) endif result := true @@ -242,7 +245,9 @@ partial class SQLRDD // be nothing to evaluate. if lReset self:_RepositionAfterOrderChange(currentRecord, lWasEof, aCurrentRow) - else + self:_RestoreBofAtEof(lWasEof, lWasBof) + elseif !lWasEof + // same order: at EOF stay there (GoTo(LastRec()+1) looked the record up again) self:GoTo(currentRecord) endif SELF:CurrentOrder:CalculateKeyLength() @@ -582,7 +587,7 @@ partial class SQLRDD IF SELF:DataTable == null SELF:GoTo(0) ELSE - SELF:_GoToPhantom(FALSE) + SELF:_GoToPhantom() ENDIF SELF:_Found := false SELF:_SetEOF(true) diff --git a/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Private.prg b/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Private.prg index 336b6fabc5..a08a06534d 100644 --- a/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Private.prg +++ b/src/Runtime/XSharp.SQLRdd/RDD/SQLRDD-Private.prg @@ -48,6 +48,9 @@ partial class SQLRDD private _bufferContiguous as logic /// Empty table with the buffer's columns, to load further query results into. private _tableTemplate as DataTable + /// An empty table with the buffer's structure (the last replaced buffer or a page already copied + /// into it) to load the next query result into, instead of copying _tableTemplate again. + private _spareTable as DataTable /// /// Rows to fetch with the next forward page (0 = PageSize): doubles with every full page read /// on in a row (up to BufferSize pages). After a seek it starts small, because code that @@ -155,7 +158,9 @@ partial class SQLRDD _table:Rows:Clear() return endif + var oOld := _table _table := value + SELF:_ReturnSpareTable(oOld) if _table is NULL SELF:_hasData := FALSE return @@ -406,7 +411,7 @@ partial class SQLRDD hasGetIdentity := true endif try - _command:Connection:BeginTrans() + var lOwnTrans := _command:Connection:BeginTrans() _command:BindParameters() local lInsertWithGetIdentity := false as LOGIC if hasGetIdentity .and. ! String.IsNullOrEmpty(Provider:GetIdentity) @@ -415,18 +420,35 @@ partial class SQLRDD lInsertWithGetIdentity := TRUE endif _command.CommandText := Connection:RaiseStringEvent(_command, SqlRDDEventReason.CommandText, _cTable, sb:ToString()) + // The command methods report a failure through their result and LastException, they + // do not throw. + local lOk as logic + _command:Connection:LastException := null if lInsertWithGetIdentity var result := _command:ExecuteScalar() - row[_recnoColumNo] := result - elseif hasGetIdentity - _command:ExecuteScalar() - row[_recnoColumNo] := _builder:GetMaxRecno() + lOk := result != null .and. result != DBNull.Value + if lOk + row[_recnoColumNo] := result + endif else - _command:ExecuteNonQuery() + lOk := _command:ExecuteNonQuery() + if lOk .and. hasGetIdentity + row[_recnoColumNo] := _builder:GetMaxRecno() + endif + endif + if lOk + lOk := _command:Connection:CommitTrans() .or. !lOwnTrans endif - _command:Connection:CommitTrans() - catch + if !lOk + var oError := _command:Connection:LastException + throw iif(oError != null, oError, Exception{"The insert returned no record number"}) + endif + catch e as Exception + // Reporting success here made a failed insert look like a new record whose changes + // then went nowhere. _command:Connection:RollBackTrans() + self:_dbfError(ERDD.WRITE, XSharp.Gencode.EG_WRITE, "SqlRDD:Append", e:Message, FALSE) + return false end try return true end method @@ -629,7 +651,7 @@ partial class SQLRDD try SELF:_command:CommandText := _builder:BuildLastPageStatement(SELF:_SmallPageRows) SELF:_command:ClearParameters() - var newTable := SELF:_command:GetDataTable(SELF:Alias, SELF:_tableTemplate) + var newTable := SELF:_command:GetDataTable(SELF:Alias, SELF:_tableTemplate, SELF:_TakeSpareTable()) if newTable == null return false endif @@ -643,6 +665,7 @@ partial class SQLRDD SELF:DataTable:Rows:InsertAt(newRow, 0) newRow:AcceptChanges() next + SELF:_ReturnSpareTable(newTable) SELF:_anchorWhere := "" SELF:_bufferContiguous := true SELF:_bufferStart := Math.Max(0, nTotalRows - SELF:RowCount) @@ -676,7 +699,7 @@ partial class SQLRDD try _command:CommandText := self:_BuildSqlStatement(sWhereClause, nOffset, nCount, lReverse) _command:ClearParameters() - var oDataTable := _command:GetDataTable(self:Alias, SELF:_tableTemplate) + var oDataTable := _command:GetDataTable(self:Alias, SELF:_tableTemplate, SELF:_TakeSpareTable()) return oDataTable catch as Exception return null @@ -795,18 +818,16 @@ partial class SQLRDD return fields:ToArray() end method - /// Position on the phantom record (EOF) without reading anything. - /// FALSE when the caller sets BOF itself, which saves counting the - /// order for an empty buffer (e.g. a seek that found nothing). - private method _GoToPhantom(lSetBof as logic) as void + /// + /// Position on the phantom record (EOF) without reading anything. BOF is left to the caller: + /// GoTo() to a record that does not exist sets it (as ADS does), an order change at EOF and a + /// failed seek keep or clear it. Computing it here meant counting the order for an empty buffer. + /// + private method _GoToPhantom() as void SELF:_Found := FALSE SELF:_outsideOrder := FALSE SELF:RowNumber := SELF:RowCount + 1 SELF:_SetEOF(TRUE) - // BOF too only for an empty table (an empty buffer alone does not mean that) - if lSetBof - SELF:_SetBOF(SELF:RowCount == 0 .and. SELF:_OrderRowCount() == 0) - endif // When the buffer already ends at the last record, a backward Skip() from the phantom // row naturally lands on it; otherwise remember to go to the bottom first. SELF:_eofPending := !(SELF:_hasEOF .and. SELF:_bufferContiguous) @@ -986,7 +1007,7 @@ partial class SQLRDD switch oRow[nCol] case cValue as string foreach var c in cValue - if c > (char) 127 + if c > (char) 127 .or. c == (char) 92 return null endif next @@ -1055,6 +1076,7 @@ partial class SQLRDD SELF:DataTable:Rows:Add(newRow) newRow:AcceptChanges() next + SELF:_ReturnSpareTable(newTable) // A short page always means EOF. But when the total record count is an exact // multiple of PageSize, the last page comes back FULL - "short page" never fires, // so also check whether this page's absolute record range already reaches the @@ -1106,6 +1128,7 @@ partial class SQLRDD SELF:DataTable:Rows:InsertAt(newRow, 0) newRow:AcceptChanges() next + SELF:_ReturnSpareTable(newTable) SELF:_bufferStart := nStart SELF:RowNumber += nRows SELF:_TrimBufferEnd() @@ -1147,6 +1170,7 @@ partial class SQLRDD SELF:DataTable:Rows:InsertAt(newRow, 0) newRow:AcceptChanges() next + SELF:_ReturnSpareTable(newTable) SELF:RowNumber += nRows SELF:_readAheadRows := 0 SELF:_readBackRows := iif(nRows < nCount, 0, Math.Min(nCount * 2, SELF:_MaxBufferRows)) @@ -1450,13 +1474,18 @@ partial class SQLRDD else SELF:_command:CommandText := _builder:BuildDirectRecnoStatement(nRec) SELF:_command:ClearParameters() - var oRecTable := SELF:_command:GetDataTable(SELF:Alias, SELF:_tableTemplate) + var oRecTable := SELF:_command:GetDataTable(SELF:Alias, SELF:_tableTemplate, SELF:_TakeSpareTable()) if oRecTable == null .or. oRecTable:Rows:Count == 0 + SELF:_ReturnSpareTable(oRecTable) return false endif oRow := oRecTable:Rows[0] endif var cFrom := SELF:_KeysetCondition(oRow, FALSE, TRUE, TRUE) + if oRow:Table != SELF:DataTable + // the record was read only for its key + SELF:_ReturnSpareTable(oRow:Table) + endif if cFrom == null return false endif @@ -1502,13 +1531,20 @@ partial class SQLRDD /// (Skip(), OrderKeyNo, ...). /// PRIVATE METHOD _LoadSingleRecord(nRec as DWORD) AS LOGIC + return SELF:_FetchSingleRecord(nRec) == 1 + + /// Like _LoadSingleRecord(): 1 = loaded, 0 = the record does not exist, -1 = error. + PRIVATE METHOD _FetchSingleRecord(nRec as DWORD) AS LONG try SELF:_lazyGoto := FALSE SELF:_command:CommandText := _builder:BuildDirectRecnoStatement(nRec) SELF:_command:ClearParameters() - var oTable := SELF:_command:GetDataTable(SELF:Alias, SELF:_tableTemplate) - if oTable == null .or. oTable:Rows:Count == 0 - return false + var oTable := SELF:_command:GetDataTable(SELF:Alias, SELF:_tableTemplate, SELF:_TakeSpareTable()) + if oTable == null + return -1 + elseif oTable:Rows:Count == 0 + SELF:_ReturnSpareTable(oTable) + return 0 endif SELF:_ClearTable() SELF:DataTable := oTable @@ -1520,9 +1556,9 @@ partial class SQLRDD SELF:_bufferContiguous := false SELF:_SetEOF(FALSE) SELF:_SetBOF(FALSE) - return true + return 1 catch as Exception - return false + return -1 end try /// @@ -1544,7 +1580,8 @@ partial class SQLRDD // record. _GoToPhantom() marks the EOF as pending (the empty buffer does not end at // the last record), so Skip(1) stays at EOF and Skip(-1) goes to the bottom. if SELF:_SetBufferRows(null) .or. SELF:_OpenTable("1=0", "") - SELF:_GoToPhantom(TRUE) + // BOF stays as it was, see OrderListFocus() + SELF:_GoToPhantom() else SELF:GoTo(0) endif @@ -1558,6 +1595,71 @@ partial class SQLRDD endif return + /// After an order change at EOF keep BOF as it was before, as ADS does. + PRIVATE METHOD _RestoreBofAtEof(lWasEof as logic, lWasBof as logic) AS VOID + if lWasEof .and. SELF:_EoF + SELF:_SetBOF(lWasBof) + endif + return + + /// An empty table with the buffer's structure to load a query result into, or NULL. + PRIVATE METHOD _TakeSpareTable() AS DataTable + var oTable := SELF:_spareTable + SELF:_spareTable := null + return oTable + + /// + /// After a new record lock: read the locked record again when the work area stands on it and has + /// no pending changes, as ADS does. + /// + /// + /// Code written for DBF/ADS locks a record, reads it and writes it back. A value read before the + /// lock (from the buffer or the result cache) could overwrite a change another user committed in + /// the meantime. Only the values of the row are replaced, the buffer and the position stay. + /// + PRIVATE METHOD _RefreshLockedRow(nRecNo as int) AS VOID + var row := SELF:CurrentRow + if nRecNo <= 0 .or. SELF:_tableMode != TableMode.Table .or. SELF:_recnoColumNo < 0 .or. SELF:_EoF ; + .or. row == null .or. row == SELF:_phantomRow .or. row:RowState != DataRowState.Unchanged .or. SELF:_updatedRecNos:Count > 0 + return + endif + if Convert.ToInt32(row[SELF:_recnoColumNo]) != nRecNo + return + endif + try + SELF:_command:CommandText := _builder:BuildDirectRecnoStatement((DWORD) nRecNo) + SELF:_command:ClearParameters() + var oTable := SELF:_command:GetDataTable(SELF:Alias, SELF:_tableTemplate, SELF:_TakeSpareTable()) + if oTable != null + if oTable:Rows:Count == 1 + var oNew := oTable:Rows[0] + // column by column: the recno column is read only (ItemArray would stop there + // and leave the row in edit mode) + foreach oColumn as DataColumn in row:Table:Columns + if !oColumn:ReadOnly + row[oColumn] := oNew[oColumn:Ordinal] + endif + next + row:AcceptChanges() + endif + SELF:_ReturnSpareTable(oTable) + endif + catch as Exception + // keep the values read before + row:RejectChanges() + end try + return + + /// Keep a table that is no longer used (a replaced buffer, a page whose rows were copied into + /// the buffer) for the next query, see _TakeSpareTable(). + PRIVATE METHOD _ReturnSpareTable(oTable as DataTable) AS VOID + if oTable != null .and. oTable != SELF:_table .and. SELF:_tableTemplate != null ; + .and. oTable:Columns:Count == SELF:_tableTemplate:Columns:Count + oTable:Rows:Clear() + SELF:_spareTable := oTable + endif + return + /// /// Replace the buffer with an empty table (aRow NULL) or one row, built from the structure of /// the previous buffer without a query. FALSE when that structure is not known yet. @@ -1566,7 +1668,10 @@ partial class SQLRDD if SELF:_tableTemplate == null return false endif - var oTable := SELF:_tableTemplate:Clone() + var oTable := SELF:_TakeSpareTable() + if oTable == null + oTable := SELF:_tableTemplate:Clone() + endif if aRow != null oTable:LoadDataRow(aRow, true) endif @@ -1590,13 +1695,19 @@ partial class SQLRDD /// When the buffer only holds records whose position in the current order is unknown /// (see _LoadSingleRecord()), look that position up for the current record now. /// Sets _outsideOrder when the record is not part of the order at all. + /// Returns FALSE when pending changes could not be written. /// - PRIVATE METHOD _EnsurePositioned() AS VOID + PRIVATE METHOD _EnsurePositioned() AS LOGIC if SELF:_tableMode == TableMode.Table .and. !SELF:_bufferContiguous .and. !SELF:_outsideOrder ; .and. !SELF:_EoF .and. SELF:RowCount > 0 .and. SELF:RowNumber >= 1 .and. SELF:RowNumber <= SELF:RowCount + // Loading the window replaces the buffer: write the changes of the record first, or + // GoTo(n), FieldPut(), Skip() would lose them. + if !SELF:GoCold() + return false + endif SELF:_GotoRecord(Convert.ToUInt32(SELF:CurrentRow[self:_recnoColumNo])) endif - return + return true PRIVATE METHOD _GotoRow(nRow as LONG) AS LOGIC SELF:_Found := FALSE diff --git a/src/Runtime/XSharp.SQLRdd/Support/SqlDbTableCommandBuilder.prg b/src/Runtime/XSharp.SQLRdd/Support/SqlDbTableCommandBuilder.prg index 538b5fc227..7790a61749 100644 --- a/src/Runtime/XSharp.SQLRdd/Support/SqlDbTableCommandBuilder.prg +++ b/src/Runtime/XSharp.SQLRdd/Support/SqlDbTableCommandBuilder.prg @@ -376,7 +376,20 @@ internal class SqlDbTableCommandBuilder return sb:ToString() + /// The quoted column list of the table, recno and deleted column last. + /// Built once: every statement of the work area uses it, and for a table with 178 columns + /// building it took a noticeable part of a seek on the client. method ColumnList() as string + if _columnList == null .or. _columnListCount != _oTable:Columns:Count + _columnList := SELF:_BuildColumnList() + _columnListCount := _oTable:Columns:Count + endif + return _columnList + + private _columnList as string + private _columnListCount as long + + private method _BuildColumnList() as string var sb := StringBuilder{} var List := Dictionary{StringComparer.OrdinalIgnoreCase} var newColumns := List{} diff --git a/src/Runtime/XSharp.SQLRdd/XSharp.SQLRDD.xsproj b/src/Runtime/XSharp.SQLRdd/XSharp.SQLRDD.xsproj index 06f6838ed1..63a9fa9473 100644 --- a/src/Runtime/XSharp.SQLRdd/XSharp.SQLRDD.xsproj +++ b/src/Runtime/XSharp.SQLRdd/XSharp.SQLRDD.xsproj @@ -57,6 +57,7 @@ +