Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7399895
fix: parenthesise ternary/coalesce in ExprToString to preserve AST on…
McNultyyy May 18, 2026
c0c9c62
Remove EmulatorFlaky trait — use adaptive concurrency instead
McNultyyy May 19, 2026
8b795db
Treat 404/0 as transient during emulator warmup
McNultyyy May 19, 2026
2ea4370
docs: add Publishing & Releases section to AGENTS.md
McNultyyy May 19, 2026
520abfb
fix: evaluate literal expressions in ProjectAggregateFields (#67)
McNultyyy May 19, 2026
4c875b4
ci: add Windows integration test job to catch platform-specific diver…
McNultyyy May 19, 2026
2825cc3
ci: rename integration job to integration-linux for clarity
McNultyyy May 19, 2026
f30b4ed
Merge remote-tracking branch 'origin/mcnultyyy/fix-emulator-flaky-tra…
McNultyyy May 19, 2026
094bd4b
Merge remote-tracking branch 'origin/mcnultyyy/fix-string-literal-ali…
McNultyyy May 19, 2026
038e994
fix: treat missing properties as null in FilterPredicate evaluation (…
McNultyyy May 19, 2026
28a22c3
fix: return documents in insertion order for queries without ORDER BY
McNultyyy May 20, 2026
fbd7997
Add hardening integration tests for insertion order behavior
McNultyyy May 20, 2026
270ac51
Merge PR #73: fix query insertion order (resolve conflicts)
McNultyyy May 20, 2026
a3727ef
docs: update CHANGELOG to cover all merged fixes for v4.0.20
McNultyyy May 20, 2026
2cf3a57
test: add repro tests for Issue #75 - decimal scale not preserved on …
McNultyyy May 26, 2026
033ffaa
fix: normalise whole-number JSON floats to integers on round-trip (Is…
McNultyyy May 26, 2026
ef6e5ea
Fix query plan test regressions from isSingleAggregateProjectionBypass
McNultyyy May 27, 2026
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
19 changes: 18 additions & 1 deletion .github/workflows/_build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:
fail-fast: false
matrix:
framework: [net8.0, net10.0]
name: integration (${{ matrix.framework }})
name: integration-linux (${{ matrix.framework }})
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-dotnet-build
Expand All @@ -85,3 +85,20 @@ jobs:

- name: Integration Tests
run: dotnet test tests/CosmosDB.InMemoryEmulator.Tests.Integration --configuration Release --no-build --verbosity normal --framework ${{ matrix.framework }}

test-integration-windows:
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
framework: [net8.0, net10.0]
name: integration-windows (${{ matrix.framework }})
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-dotnet-build
with:
build-args: --framework ${{ matrix.framework }}

- name: Integration Tests
shell: bash
run: dotnet test tests/CosmosDB.InMemoryEmulator.Tests.Integration --configuration Release --no-build --verbosity normal --framework ${{ matrix.framework }}
37 changes: 37 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ Tests are split into two projects. When creating or moving tests, follow these r
### Key constraint
The Integration project does **not** have `InternalsVisibleTo` access. If a test needs internal APIs, it belongs in Unit.

## Publishing & Releases

Packages are published to NuGet via the `release.yml` GitHub Actions workflow, triggered by pushing a `v*` tag. The workflow runs the full test suite before publishing — if tests fail, nothing is published.

### Beta / Prerelease

To publish a prerelease package for testing before merging:

1. Ensure your fix is committed and pushed to a branch.
2. Create and push a tag with a prerelease suffix:
```bash
git tag v4.0.18-beta.1
git push origin v4.0.18-beta.1
```
3. The workflow extracts the version from the tag (strips the `v` prefix), passes it to `dotnet pack -p:Version=...`, and publishes to NuGet as a prerelease package.
4. Consumers install with: `dotnet add package CosmosDB.InMemoryEmulator --version 4.0.18-beta.1`

The tag can be on any branch — the workflow checks out the tagged commit.

### Stable Release

After merging to `main`:

1. Ensure `src/Directory.Build.props` has the correct `<Version>` (e.g. `4.0.18`).
2. Commit, create a tag, and push:
```bash
git tag v4.0.18
git push origin v4.0.18
```
3. The workflow publishes stable packages and creates a GitHub Release with auto-generated release notes.

### Version Conventions

- `Directory.Build.props` `<Version>` is the target stable version — increment the patch after each release.
- The CI workflow **overrides** the version from the tag, so the `.props` value doesn't need to match beta suffixes.
- Prerelease format: `X.Y.Z-beta.N` (increment N for successive betas of the same version).

## Documentation

After any changes are made that might effect the public API or functionality, documentation must be updated to reflect those changes. The documentation should be clear and comprehensive, covering all new features, changes to existing features, and any deprecations or removals. This includes updating README file (if relevant), but mainly the wiki which can be found in a sister folder to the main repository - ../CosmosDB.InMemoryEmulator.wiki.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.0.20] - 2026-05-20

### Fixed
- `COUNT(expr)` with nested ternary expressions no longer miscounts documents (Issue #64). `ExprToString` in `CosmosSqlParser` did not parenthesise ternary/coalesce sub-expressions when they appeared as operands of higher-precedence binary operators. When the SDK's transformed query was round-tripped through `SimplifySdkQuery`, the missing parentheses caused re-parsing to produce a different AST — e.g. `(innerTernary > 0) ? 1 : undefined` became `innerTernary ? val : (otherVal > 0 ? 1 : undefined)` — making `COUNT` evaluate the wrong condition. The fix wraps ternary and coalesce expressions in parentheses whenever they appear inside binary, unary, BETWEEN, IN, or LIKE operators.
- String literal aliases in aggregate queries (e.g. `SELECT 'Settlement' AS Label, COUNT(1) AS ItemCount FROM c`) no longer return `null` on Linux (Issue #67). `ProjectAggregateFields` now evaluates literal/expression fields via `EvaluateSqlExpression` instead of treating them as property paths. `DefaultQueryPlanStrategy` also bypasses the SDK aggregate pipeline when literals appear alongside aggregates.
- Patch operations with filter predicates referencing non-existent properties no longer throw (Issue #70). Missing properties are now treated as `null` in `FilterPredicate` evaluation, matching real Cosmos DB behavior.
- Queries without `ORDER BY` now return documents in insertion order, matching real Cosmos DB behavior (Issue #72). Previously documents were returned in hash-map order due to `ConcurrentDictionary` enumeration. Added insertion-order tracking to `InMemoryContainer` that maintains document position across create, replace, upsert, and delete operations.

### Changed
- Removed `EmulatorFlaky` trait. The single flaky test (`ConcurrentReadsOfNonExistent`) now uses adaptive concurrency — 50 parallel reads for in-memory, 10 for emulator targets — eliminating the need for blanket test exclusions.

### CI
- Integration tests now run on **both** `ubuntu-latest` and `windows-latest` to catch platform-specific divergences (e.g. ServiceInterop vs non-ServiceInterop code paths).

## [4.0.17] - 2026-05-14

### Fixed
Expand Down
7 changes: 2 additions & 5 deletions scripts/run-tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,10 @@ if ($Target -ne 'inmemory') {
Remove-Item Env:COSMOS_EMULATOR_ENDPOINT -ErrorAction SilentlyContinue
}

# Build filter: exclude InMemoryOnly and EmulatorFlaky tests when targeting an emulator.
# EmulatorFlaky tags tests that pass on in-memory but fail reproducibly on the Linux
# Docker / Windows Cosmos DB emulators due to emulator-side instability — the behaviour
# is still validated on the inmemory target, so excluding here just keeps CI signal clean.
# Build filter: exclude InMemoryOnly tests when targeting an emulator.
$filterExpr = ''
if ($Target -ne 'inmemory') {
$filterExpr = 'Target!=InMemoryOnly&Target!=EmulatorFlaky'
$filterExpr = 'Target!=InMemoryOnly'
}
if ($Filter) {
if ($filterExpr -and $Filter -match '\|') {
Expand Down
258 changes: 129 additions & 129 deletions src/CosmosDB.InMemoryEmulator.JsTriggers/JintSprocEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,48 +15,48 @@ namespace CosmosDB.InMemoryEmulator.JsTriggers;
/// </summary>
public class JintSprocEngine : ISprocEngine
{
private List<string> _capturedLogs = new();

public IReadOnlyList<string> CapturedLogs => _capturedLogs;

public string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args)
=> Execute(jsBody, partitionKey, args, null!);

public string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args, ICollectionContext context)
{
string? result = null;
_capturedLogs = new List<string>();

var engine = new Engine(options =>
{
options.TimeoutInterval(TimeSpan.FromSeconds(5));
options.MaxStatements(10_000);
});

// Wire up the Cosmos DB server-side stored procedure API
engine.SetValue("__setBody", new Action<JsValue>(val =>
{
engine.SetValue("__toSerialize", val);
result = engine.Evaluate("JSON.stringify(__toSerialize)").AsString();
// Unwrap JSON string wrapping for simple string values
if (result is not null && result.StartsWith('"') && result.EndsWith('"'))
{
result = result[1..^1]
.Replace("\\\"", "\"")
.Replace("\\\\", "\\")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t");
}
}));
engine.SetValue("__log", new Action<JsValue>(msg =>
{
_capturedLogs.Add(msg.ToString());
}));

WireCollectionContext(engine, context);

engine.Execute("""
private List<string> _capturedLogs = new();

public IReadOnlyList<string> CapturedLogs => _capturedLogs;

public string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args)
=> Execute(jsBody, partitionKey, args, null!);

public string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args, ICollectionContext context)
{
string? result = null;
_capturedLogs = new List<string>();

var engine = new Engine(options =>
{
options.TimeoutInterval(TimeSpan.FromSeconds(5));
options.MaxStatements(10_000);
});

// Wire up the Cosmos DB server-side stored procedure API
engine.SetValue("__setBody", new Action<JsValue>(val =>
{
engine.SetValue("__toSerialize", val);
result = engine.Evaluate("JSON.stringify(__toSerialize)").AsString();
// Unwrap JSON string wrapping for simple string values
if (result is not null && result.StartsWith('"') && result.EndsWith('"'))
{
result = result[1..^1]
.Replace("\\\"", "\"")
.Replace("\\\\", "\\")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t");
}
}));
engine.SetValue("__log", new Action<JsValue>(msg =>
{
_capturedLogs.Add(msg.ToString());
}));

WireCollectionContext(engine, context);

engine.Execute("""
var console = { log: function(msg) { __log(msg); } };
function getContext() {
return {
Expand All @@ -70,94 +70,94 @@ function getContext() {
}
""");

// Convert C# args to JS values
var jsArgs = new JsValue[args?.Length ?? 0];
for (var i = 0; i < jsArgs.Length; i++)
{
var arg = args![i];
if (arg is null)
jsArgs[i] = JsValue.Null;
else if (arg is string s)
jsArgs[i] = new JsString(s);
else if (arg is int intVal)
jsArgs[i] = new JsNumber(intVal);
else if (arg is long longVal)
jsArgs[i] = new JsNumber(longVal);
else if (arg is double dblVal)
jsArgs[i] = new JsNumber(dblVal);
else if (arg is bool bVal)
jsArgs[i] = bVal ? JsBoolean.True : JsBoolean.False;
else
jsArgs[i] = engine.Evaluate($"({Newtonsoft.Json.JsonConvert.SerializeObject(arg)})");
}

try
{
// Check if the body starts with a named function declaration
var match = System.Text.RegularExpressions.Regex.Match(jsBody.TrimStart(), @"^function\s+(\w+)\s*\(");
if (match.Success)
{
engine.Execute(jsBody);
engine.Invoke(match.Groups[1].Value, jsArgs);
}
else
{
// Anonymous function: wrap as expression and invoke
var func = engine.Evaluate($"({jsBody})");
engine.Invoke(func, jsArgs);
}
}
catch (JavaScriptException ex)
{
throw InMemoryCosmosException.Create(
$"Stored procedure failed: {ex.Message}",
HttpStatusCode.BadRequest, 0, string.Empty, 0);
}

return result;
}

internal static void WireCollectionContext(Engine engine, ICollectionContext context)
{
if (context == null)
{
engine.Execute("""
// Convert C# args to JS values
var jsArgs = new JsValue[args?.Length ?? 0];
for (var i = 0; i < jsArgs.Length; i++)
{
var arg = args![i];
if (arg is null)
jsArgs[i] = JsValue.Null;
else if (arg is string s)
jsArgs[i] = new JsString(s);
else if (arg is int intVal)
jsArgs[i] = new JsNumber(intVal);
else if (arg is long longVal)
jsArgs[i] = new JsNumber(longVal);
else if (arg is double dblVal)
jsArgs[i] = new JsNumber(dblVal);
else if (arg is bool bVal)
jsArgs[i] = bVal ? JsBoolean.True : JsBoolean.False;
else
jsArgs[i] = engine.Evaluate($"({Newtonsoft.Json.JsonConvert.SerializeObject(arg)})");
}

try
{
// Check if the body starts with a named function declaration
var match = System.Text.RegularExpressions.Regex.Match(jsBody.TrimStart(), @"^function\s+(\w+)\s*\(");
if (match.Success)
{
engine.Execute(jsBody);
engine.Invoke(match.Groups[1].Value, jsArgs);
}
else
{
// Anonymous function: wrap as expression and invoke
var func = engine.Evaluate($"({jsBody})");
engine.Invoke(func, jsArgs);
}
}
catch (JavaScriptException ex)
{
throw InMemoryCosmosException.Create(
$"Stored procedure failed: {ex.Message}",
HttpStatusCode.BadRequest, 0, string.Empty, 0);
}

return result;
}

internal static void WireCollectionContext(Engine engine, ICollectionContext context)
{
if (context == null)
{
engine.Execute("""
var __collection = { getSelfLink: function() { return ""; } };
""");
return;
}

var selfLink = context.SelfLink ?? "";

engine.SetValue("__selfLink", selfLink);
engine.SetValue("__createDocument", new Func<string, string>(jsonDoc =>
{
var doc = JObject.Parse(jsonDoc);
var created = context.CreateDocument(doc);
return created.ToString(Formatting.None);
}));
engine.SetValue("__readDocument", new Func<string, string>(docId =>
{
var doc = context.ReadDocument(docId);
return doc.ToString(Formatting.None);
}));
engine.SetValue("__queryDocuments", new Func<string, string>(sql =>
{
var docs = context.QueryDocuments(sql);
return JsonConvert.SerializeObject(docs, Formatting.None);
}));
engine.SetValue("__replaceDocument", new Func<string, string, string>((docId, jsonDoc) =>
{
var doc = JObject.Parse(jsonDoc);
var replaced = context.ReplaceDocument(docId, doc);
return replaced.ToString(Formatting.None);
}));
engine.SetValue("__deleteDocument", new Action<string>(docId =>
{
context.DeleteDocument(docId);
}));

engine.Execute("""
return;
}

var selfLink = context.SelfLink ?? "";

engine.SetValue("__selfLink", selfLink);
engine.SetValue("__createDocument", new Func<string, string>(jsonDoc =>
{
var doc = JObject.Parse(jsonDoc);
var created = context.CreateDocument(doc);
return created.ToString(Formatting.None);
}));
engine.SetValue("__readDocument", new Func<string, string>(docId =>
{
var doc = context.ReadDocument(docId);
return doc.ToString(Formatting.None);
}));
engine.SetValue("__queryDocuments", new Func<string, string>(sql =>
{
var docs = context.QueryDocuments(sql);
return JsonConvert.SerializeObject(docs, Formatting.None);
}));
engine.SetValue("__replaceDocument", new Func<string, string, string>((docId, jsonDoc) =>
{
var doc = JObject.Parse(jsonDoc);
var replaced = context.ReplaceDocument(docId, doc);
return replaced.ToString(Formatting.None);
}));
engine.SetValue("__deleteDocument", new Action<string>(docId =>
{
context.DeleteDocument(docId);
}));

engine.Execute("""
var __collection = {
getSelfLink: function() { return __selfLink; },
createDocument: function(link, doc, opts, cb) {
Expand Down Expand Up @@ -196,5 +196,5 @@ internal static void WireCollectionContext(Engine engine, ICollectionContext con
}
};
""");
}
}
}
Loading
Loading