Skip to content

Commit b99fdce

Browse files
committed
bugfixes
1 parent e73dda6 commit b99fdce

27 files changed

Lines changed: 499 additions & 94 deletions

File tree

src/Shiny.DocumentDb.Aspire.Client/DocumentStoreClientExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ static DocumentProviderKind ResolveProvider(IConfiguration configuration, string
129129
if (!Enum.TryParse<DocumentProviderKind>(raw, ignoreCase: true, out var kind))
130130
throw new InvalidOperationException(
131131
$"DocumentDb provider discriminator '{raw}' for store '{name}' is not a recognized DocumentProviderKind " +
132-
"(Sqlite, Postgres, SqlServer, MySql).");
132+
"(Sqlite, Postgres, CockroachDb, SqlServer, MySql, MariaDb).");
133133

134134
return kind;
135135
}

src/Shiny.DocumentDb.Diagnostics/OperationTracker.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ public async IAsyncEnumerable<T> TrackStream<T>(string op, string collection, IA
6363
if (!await enumerator.MoveNextAsync().ConfigureAwait(false))
6464
break;
6565
}
66+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
67+
{
68+
// The consumer stopped enumerating by cancelling the token — normal termination, not an
69+
// error. Record it as "cancelled" so it isn't counted against the error rate.
70+
faulted = true;
71+
metrics.Record(system, op, collection, Stopwatch.GetElapsedTime(start), "cancelled", null, count, storeName);
72+
throw;
73+
}
6674
catch (Exception ex)
6775
{
6876
faulted = true;

src/Shiny.DocumentDb.Generators/GeneratedMetadata.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ void Register(ITypeSymbol t, Location loc)
110110
return;
111111
}
112112

113+
// Any collection other than List<T>/T[] (Dictionary, HashSet, ObservableCollection, custom
114+
// IEnumerable-backed types). These have a public parameterless ctor so IsPlainObject would accept
115+
// them, but walking them as objects yields no settable properties → they silently serialize as an
116+
// empty {} and lose every entry. STJ serializes them as arrays/objects, which the Generated
117+
// resolver doesn't model, so reject with a clear diagnostic.
118+
if (t is INamedTypeSymbol coll && !coll.IsAbstract && ImplementsIEnumerable(coll))
119+
{
120+
diagnostics.Add(new DiagInfo(Diagnostics.UnsupportedGeneratedType, loc, full,
121+
"collection types other than List<T> and T[] (e.g. Dictionary, HashSet, ObservableCollection) are not supported by DocumentSerialization.Generated — use List<T>/array, or a different DocumentSerialization mode (Reflection/JsonContext/Auto)"));
122+
return;
123+
}
124+
113125
// plain object
114126
if (t is INamedTypeSymbol obj && IsPlainObject(obj))
115127
{
@@ -134,6 +146,13 @@ void Register(ITypeSymbol t, Location loc)
134146
continue;
135147
}
136148

149+
// Flag public properties STJ would persist but the Generated resolver can't set (init-only or a
150+
// non-public setter) — they would be silently dropped on round-trip. Pure get-only (computed)
151+
// properties are intentionally not flagged.
152+
foreach (var dropped in DroppedSerializableProperties(obj))
153+
diagnostics.Add(new DiagInfo(Diagnostics.UnsupportedGeneratedType, location, full,
154+
$"property '{dropped}' has an init-only or non-public setter — DocumentSerialization.Generated cannot round-trip it (it would be silently dropped); give it a public setter, mark it [JsonIgnore], or use a non-Generated DocumentSerialization mode"));
155+
137156
var props = new List<MetaProp>();
138157
foreach (var member in EnumerateProperties(obj))
139158
{
@@ -188,6 +207,46 @@ void Register(ITypeSymbol t, Location loc)
188207
}
189208
}
190209

210+
static bool ImplementsIEnumerable(ITypeSymbol t)
211+
{
212+
if (t.SpecialType == SpecialType.System_String)
213+
return false;
214+
foreach (var i in t.AllInterfaces)
215+
if (i.SpecialType == SpecialType.System_Collections_IEnumerable
216+
|| i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T)
217+
return true;
218+
return false;
219+
}
220+
221+
static IEnumerable<string> DroppedSerializableProperties(INamedTypeSymbol type)
222+
{
223+
var seen = new HashSet<string>();
224+
for (var t = type; t != null && t.SpecialType != SpecialType.System_Object; t = t.BaseType)
225+
{
226+
foreach (var m in t.GetMembers())
227+
{
228+
if (m is not IPropertySymbol p || p.IsStatic || p.IsIndexer)
229+
continue;
230+
if (p.DeclaredAccessibility != Accessibility.Public)
231+
continue;
232+
if (p.GetMethod is not { DeclaredAccessibility: Accessibility.Public })
233+
continue;
234+
if (!seen.Add(p.Name))
235+
continue;
236+
if (p.SetMethod == null)
237+
continue; // pure get-only (computed) — not a round-trip data-loss case
238+
if (p.SetMethod.DeclaredAccessibility == Accessibility.Public && !p.SetMethod.IsInitOnly)
239+
continue; // properly settable — handled normally
240+
var isIgnored = false;
241+
foreach (var a in p.GetAttributes())
242+
if (a.AttributeClass?.ToDisplayString() == JsonIgnore) { isIgnored = true; break; }
243+
if (isIgnored)
244+
continue;
245+
yield return p.Name;
246+
}
247+
}
248+
}
249+
191250
static bool IsPlainObject(INamedTypeSymbol t)
192251
{
193252
if (t.TypeKind != TypeKind.Class && t.TypeKind != TypeKind.Struct)

src/Shiny.DocumentDb.IndexedDb/wwwroot/shiny-indexeddb.js

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -48,32 +48,33 @@ export async function get(storeName, key) {
4848
export async function put(storeName, recordJson) {
4949
const record = JSON.parse(recordJson);
5050
return new Promise((resolve, reject) => {
51+
// Resolve on tx.oncomplete (commit), not request.onsuccess — IndexedDB reports commit-time failures
52+
// (QuotaExceededError, constraint/abort) after the request succeeds, so resolving on the request would
53+
// be a false-positive success with the write silently rolled back.
5154
const tx = db.transaction(storeName, 'readwrite');
52-
const store = tx.objectStore(storeName);
53-
const request = store.put(record);
54-
55-
request.onsuccess = () => resolve();
56-
request.onerror = () => reject(new Error(`Put failed: ${request.error}`));
55+
tx.oncomplete = () => resolve();
56+
tx.onerror = () => reject(new Error(`Put failed: ${tx.error}`));
57+
tx.onabort = () => reject(new Error(`Put aborted: ${tx.error}`));
58+
tx.objectStore(storeName).put(record);
5759
});
5860
}
5961

6062
export async function remove(storeName, key) {
6163
return new Promise((resolve, reject) => {
6264
const tx = db.transaction(storeName, 'readwrite');
6365
const store = tx.objectStore(storeName);
64-
65-
// Check if exists first
66+
let existed = false;
6667
const getReq = store.get(key);
6768
getReq.onsuccess = () => {
68-
if (!getReq.result) {
69-
resolve(false);
70-
return;
69+
if (getReq.result) {
70+
existed = true;
71+
store.delete(key);
7172
}
72-
const delReq = store.delete(key);
73-
delReq.onsuccess = () => resolve(true);
74-
delReq.onerror = () => reject(new Error(`Delete failed: ${delReq.error}`));
7573
};
76-
getReq.onerror = () => reject(new Error(`Get failed: ${getReq.error}`));
74+
// Report the outcome only once the transaction commits (so an abort surfaces as a failure).
75+
tx.oncomplete = () => resolve(existed);
76+
tx.onerror = () => reject(new Error(`Delete failed: ${tx.error}`));
77+
tx.onabort = () => reject(new Error(`Delete aborted: ${tx.error}`));
7778
});
7879
}
7980

@@ -108,25 +109,18 @@ export async function clearByTypeName(storeName, typeName) {
108109
const tx = db.transaction(storeName, 'readwrite');
109110
const store = tx.objectStore(storeName);
110111
const index = store.index('typeName');
112+
let deleted = 0;
111113
const request = index.getAllKeys(typeName);
112-
113114
request.onsuccess = () => {
114-
const keys = request.result;
115-
let deleted = 0;
116-
if (keys.length === 0) {
117-
resolve(0);
118-
return;
119-
}
120-
for (const key of keys) {
121-
const delReq = store.delete(key);
122-
delReq.onsuccess = () => {
123-
deleted++;
124-
if (deleted === keys.length) resolve(deleted);
125-
};
126-
delReq.onerror = () => reject(new Error(`Delete failed: ${delReq.error}`));
115+
for (const key of request.result) {
116+
store.delete(key);
117+
deleted++;
127118
}
128119
};
129-
request.onerror = () => reject(new Error(`GetAllKeys failed: ${request.error}`));
120+
// Resolve the count only once the whole batch commits.
121+
tx.oncomplete = () => resolve(deleted);
122+
tx.onerror = () => reject(new Error(`Clear failed: ${tx.error}`));
123+
tx.onabort = () => reject(new Error(`Clear aborted: ${tx.error}`));
130124
});
131125
}
132126

src/Shiny.DocumentDb.MongoDb/MongoDbDocumentStore.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,9 @@ public async Task<IReadOnlyList<VectorResult<T>>> NearestVectors<T>(
860860
{ "path", $"{MongoFields.Data}.{mapping.JsonPath}" },
861861
{ "queryVector", qv },
862862
{ "numCandidates", numCandidates },
863-
{ "limit", k },
863+
// When a user predicate is post-applied, fetch a wider slice so the post-filter doesn't
864+
// truncate below k; results are trimmed to k after filtering.
865+
{ "limit", filter != null ? numCandidates : k },
864866
{ "filter", filterDoc }
865867
}
866868
}
@@ -906,6 +908,9 @@ public async Task<IReadOnlyList<VectorResult<T>>> NearestVectors<T>(
906908
var score = row.TryGetValue("score", out var sv) && sv.IsNumeric ? (float)sv.ToDouble() : float.NaN;
907909
results.Add(new VectorResult<T> { Document = doc, Score = score });
908910
}
911+
// Trim the widened, post-filtered candidate set back to the requested k (rows are nearest-first).
912+
if (filter != null && results.Count > k)
913+
results = results.GetRange(0, k);
909914
return results;
910915
}
911916

src/Shiny.DocumentDb.MySql/MySqlDatabaseProvider.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ public string BuildBatchSkipExistingSql(string tableName, int batchSize)
213213
return sb.ToString();
214214
}
215215

216+
// MySQL/MariaDB treat backslash as a string-literal escape, so `ESCAPE '\'` would be a malformed literal —
217+
// and LIKE already uses '\' as its default escape character, so no explicit ESCAPE clause is needed. The
218+
// inherited EscapeLikePattern (which backslash-escapes %/_/\) lines up with that default.
219+
public string LikeEscapeClause => "";
220+
216221
// virtual: MariaDB rejects CAST(... AS JSON) and overrides these to use JSON_COMPACT instead.
217222
public virtual string BuildSetPropertySql(string tableName) => $"""
218223
UPDATE `{tableName}`

src/Shiny.DocumentDb.OData/FilterExpressionBuilder.cs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,21 +91,36 @@ static Expression BuildBinary(ODataFilterNode node, ParameterExpression paramete
9191
static (Expression Left, Expression Right) BuildComparisonOperands(
9292
ODataFilterNode leftNode, ODataFilterNode rightNode, ParameterExpression parameter, JsonTypeInfo typeInfo)
9393
{
94+
var leftIsNull = IsNullConstant(leftNode);
95+
var rightIsNull = IsNullConstant(rightNode);
96+
97+
// Handle a null operand BEFORE binding the other side to its inferred type — building
98+
// Expression.Constant(null, typeof(int)) for a non-nullable value-type property throws, so the member
99+
// side must be made null-comparable (boxed to object) first.
100+
if (rightIsNull && !leftIsNull)
101+
{
102+
var member = BuildValue(leftNode, parameter, typeInfo, InferType(leftNode, parameter, typeInfo));
103+
var (m, n) = MakeNullComparable(member);
104+
return (m, n);
105+
}
106+
if (leftIsNull && !rightIsNull)
107+
{
108+
var member = BuildValue(rightNode, parameter, typeInfo, InferType(rightNode, parameter, typeInfo));
109+
var (m, n) = MakeNullComparable(member);
110+
return (n, m);
111+
}
112+
if (leftIsNull && rightIsNull)
113+
{
114+
var nul = Expression.Constant(null, typeof(object));
115+
return (nul, nul);
116+
}
117+
94118
var targetType = InferType(leftNode, parameter, typeInfo)
95119
?? InferType(rightNode, parameter, typeInfo);
96-
97120
var left = BuildValue(leftNode, parameter, typeInfo, targetType);
98121
var right = BuildValue(rightNode, parameter, typeInfo, left.Type);
99-
100122
if (left.Type != right.Type)
101-
{
102-
if (IsNullConstant(rightNode))
103-
(left, right) = MakeNullComparable(left);
104-
else if (IsNullConstant(leftNode))
105-
(right, left) = MakeNullComparable(right);
106-
else
107-
right = Expression.Convert(right, left.Type);
108-
}
123+
right = Expression.Convert(right, left.Type);
109124
return (left, right);
110125
}
111126

src/Shiny.DocumentDb.Orleans/DocumentDbGrainStorage.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,19 @@ public static void ConfigureGrainState(DocumentStoreOptions options, string tabl
8181
options.MapVersionProperty<GrainStateRecord>(x => x.Version);
8282
}
8383

84-
static string MakeId(string stateName, GrainId grainId) => $"{stateName}|{grainId}";
84+
// GrainId.ToString() renders as "{grainType}/{key}" — the '/' (and '\', '?', '#') are reserved characters
85+
// in a Cosmos DB item id, breaking the point-read on ReadStateAsync. Percent-encode them (and '%' first,
86+
// to keep the encoding unambiguous) so the composite id is valid on Cosmos and unchanged in meaning on the
87+
// relational/Mongo providers.
88+
static string MakeId(string stateName, GrainId grainId)
89+
=> $"{stateName}|{SanitizeId(grainId.ToString())}";
90+
91+
static string SanitizeId(string s) => s
92+
.Replace("%", "%25")
93+
.Replace("/", "%2F")
94+
.Replace("\\", "%5C")
95+
.Replace("?", "%3F")
96+
.Replace("#", "%23");
8597

8698
static int ParseEtag(string etag) => int.Parse(etag, CultureInfo.InvariantCulture);
8799

src/Shiny.DocumentDb.PostgreSql/PostgreSqlDatabaseProvider.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,10 @@ protected static string VecTable(string tableName, string typeName)
532532
VectorDistance.Cosine => "vector_cosine_ops",
533533
VectorDistance.Euclidean => "vector_l2_ops",
534534
VectorDistance.DotProduct => "vector_ip_ops",
535-
VectorDistance.Hamming => "bit_hamming_ops",
535+
// pgvector's Hamming distance requires a bit(n) column, but embeddings are stored as vector(n) — so
536+
// Hamming can't be expressed here. Reject it cleanly rather than emit DDL that fails on the server.
537+
VectorDistance.Hamming => throw new NotSupportedException(
538+
"pgvector Hamming distance requires a bit(n) column; float embeddings stored as vector(n) support Cosine, Euclidean and DotProduct only."),
536539
_ => throw new ArgumentOutOfRangeException(nameof(metric), metric, null)
537540
};
538541

@@ -541,7 +544,8 @@ protected static string VecTable(string tableName, string typeName)
541544
VectorDistance.Cosine => "<=>",
542545
VectorDistance.Euclidean => "<->",
543546
VectorDistance.DotProduct => "<#>",
544-
VectorDistance.Hamming => "<+>",
547+
VectorDistance.Hamming => throw new NotSupportedException(
548+
"pgvector Hamming distance requires a bit(n) column; float embeddings support Cosine, Euclidean and DotProduct only."),
545549
_ => throw new ArgumentOutOfRangeException(nameof(metric), metric, null)
546550
};
547551

src/Shiny.DocumentDb.SqlServer/SqlServerDatabaseProvider.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ ELSE IF EXISTS (SELECT * FROM sys.computed_columns WHERE name = 'cc_{indexName}'
366366
public string BuildListJsonIndexesSql(string tableName, string prefix)
367367
=> $"SELECT name FROM sys.indexes WHERE object_id = OBJECT_ID('{tableName}') AND name LIKE @prefix;";
368368

369+
// SQL Server LIKE treats '[' as a character-class metacharacter in addition to % and _, so escape it too.
370+
public string EscapeLikePattern(string literal)
371+
=> literal.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_").Replace("[", "\\[");
372+
369373
public string JsonExtract(string column, string jsonPath)
370374
=> $"JSON_VALUE({column}, '$.{jsonPath}')";
371375

0 commit comments

Comments
 (0)