Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 108 additions & 5 deletions src/Runtime/XSharp.SQLRdd/Classes/Command.prg
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,63 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable
property Provider as ISqlDbProvider get iif(Connection == null, null, Connection:Provider)
/// <summary>The text of the Command object.</summary>
property CommandText as string get iif(DbCommand == null, "", DbCommand:CommandText) set DbCommand:CommandText := value
/// <summary>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.</summary>
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

/// <summary>TRUE for a single SELECT that writes nothing.</summary>
/// <remarks>
/// 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.
/// </remarks>
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

/// <summary>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.</summary>
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
Comment on lines +66 to +70
end method

/// <summary>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.</summary>
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
Expand Down Expand Up @@ -155,19 +209,38 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable
/// </summary>
/// <param name="cTable">The name of the DataTable (when the template cannot be used)</param>
/// <param name="oTemplate">An empty table with the columns the command returns, or NULL</param>
/// <param name="oTarget">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.</param>
/// <remarks>
/// 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
/// columns. The RDD runs every query of a work area with the same column list, so it passes
/// the structure of its buffer. When the reader's columns do not match the template, the
/// rows are loaded as by GetDataTable(cTable).
/// </remarks>
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
Expand All @@ -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<object[]>{}, 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{}
Expand Down Expand Up @@ -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
Expand All @@ -237,12 +332,19 @@ class SqlDbCommand inherit SqlDbHandleObject implements IDisposable
/// <remarks> <note type='tip'>When an error occurs then the error is registered in the LastException property of the Connection</note></remarks>
/// <seealso cref="SqlDbConnection.LastException"/>
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
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/Runtime/XSharp.SQLRdd/Classes/Connection.prg
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,46 @@ class SqlDbConnection inherit SqlDbHandleObject implements IDisposable
/// </remarks>
property UseLocks as logic auto

/// <summary>
/// How long (milliseconds) the RDD may reuse the result of an identical read statement (default 0 = never).
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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
/// <summary>The cache for ResultCacheMs, NULL when it is off.</summary>
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

/// <summary>Identifier Case as returned by the Ado.Net provider in the DataSourceInformation metadata collection.</summary>
property IdentifierCase as System.Data.Common.IdentifierCase auto get private set
/// <summary>Quoted Identifier Case as returned by the Ado.Net provider in the DataSourceInformation metadata collection.</summary>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -461,6 +505,8 @@ class SqlDbConnection inherit SqlDbHandleObject implements IDisposable
/// <seealso cref="LastException"/>
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
Expand Down
112 changes: 112 additions & 0 deletions src/Runtime/XSharp.SQLRdd/Classes/ResultCache.prg
Original file line number Diff line number Diff line change
@@ -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

/// <summary>
/// Results of identical read statements of the RDD, reused for a short time
/// (see SqlDbConnection.ResultCacheMs).
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal class SqlDbResultCache
private _entries as Dictionary<string, SqlDbResultCacheEntry>
private _lock as object
private const MaxEntries := 1000 as long
/// <summary>Larger results (pages of a sequential read) are not kept: they are hardly repeated, and
/// copying them cost more than it saved.</summary>
internal const MaxRows := 20 as long

/// <summary>Maximum age of an entry in milliseconds.</summary>
property MaxAge as long auto

constructor(nMaxAge as long)
_entries := Dictionary<string, SqlDbResultCacheEntry>{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<string>{}
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

/// <summary>Drop the entries of a table.</summary>
method Invalidate(cTable as string) as void
begin lock _lock
if _entries:Count > 0
var aKeys := List<string>{}
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

/// <summary>The result of one statement: rows (object arrays) or a scalar.</summary>
internal class SqlDbResultCacheEntry
property Table as string auto
property Created as int64 auto
property Rows as List<object[]> auto
property Columns as long auto
property Scalar as object auto
property IsScalar as logic auto
end class

end namespace
Loading