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 @@
+