-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add upsertWithCounts() with separate matched/upserted counts #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -243,10 +243,14 @@ public function createUuid(): string | |
| * | ||
| * @param array<string, mixed> $command Command to execute | ||
| * @param string|null $db Database name | ||
| * @param bool $returnRaw When true, skip the int/extracted-field short-circuits | ||
| * in the response parser and return the full stdClass | ||
| * result — use when the caller needs fields beyond `n` | ||
| * (e.g. the `upserted` array from an update command). | ||
| * @return stdClass|array|int Query result | ||
| * @throws Exception | ||
| */ | ||
| public function query(array $command, ?string $db = null): stdClass|array|int | ||
| public function query(array $command, ?string $db = null, bool $returnRaw = false): stdClass|array|int | ||
| { | ||
| // Validate connection state before each operation | ||
| $this->validateConnection(); | ||
|
|
@@ -351,7 +355,7 @@ public function query(array $command, ?string $db = null): stdClass|array|int | |
|
|
||
| $sections = Document::fromPHP($params); | ||
| $message = pack('V*', 21 + strlen($sections), $this->id, 0, 2013, 0) . "\0" . $sections; | ||
| $result = $this->send($message); | ||
| $result = $this->send($message, $returnRaw); | ||
|
|
||
| $this->updateCausalConsistency($result); | ||
|
|
||
|
|
@@ -371,7 +375,7 @@ public function query(array $command, ?string $db = null): stdClass|array|int | |
| * @return stdClass|array|int | ||
| * @throws Exception | ||
| */ | ||
| public function send(mixed $data): stdClass|array|int | ||
| public function send(mixed $data, bool $returnRaw = false): stdClass|array|int | ||
| { | ||
| // Check if connection is alive, connect if not | ||
| if (!$this->client->isConnected()) { | ||
|
|
@@ -390,14 +394,14 @@ public function send(mixed $data): stdClass|array|int | |
| } | ||
| } | ||
|
|
||
| return $this->receive(); | ||
| return $this->receive($returnRaw); | ||
| } | ||
|
|
||
| /** | ||
| * Receive a message from connection. | ||
| * @throws Exception | ||
| */ | ||
| private function receive(): stdClass|array|int | ||
| private function receive(bool $returnRaw = false): stdClass|array|int | ||
| { | ||
| $chunks = []; | ||
| $receivedLength = 0; | ||
|
|
@@ -461,7 +465,7 @@ private function receive(): stdClass|array|int | |
|
|
||
| $res = \implode('', $chunks); | ||
|
|
||
| return $this->parseResponse($res, $responseLength); | ||
| return $this->parseResponse($res, $responseLength, $returnRaw); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -899,6 +903,110 @@ public function upsert(string $collection, array $operations, array $options = [ | |
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Execute a batch of upserts and return detailed counts from the response. | ||
| * | ||
| * Unlike {@see upsert()}, which returns the raw `n` field (matched + upserted), | ||
| * this method separates matched-existing from newly-upserted documents so the | ||
| * caller can tell exactly which operations produced new docs. Useful for | ||
| * `skipDuplicates`-style callers that need to report "actually inserted" counts. | ||
| * | ||
| * Counts are only meaningful for acknowledged writes. In practice mongod | ||
| * still returns `n` for `writeConcern: { w: 0 }` over OP_MSG, so this method | ||
| * normally works regardless — but if a future protocol change ever causes | ||
| * `n` to be omitted, the method raises an Exception rather than silently | ||
| * returning zeros for writes that may have applied. | ||
| * | ||
| * @param string $collection | ||
| * @param array<int, array{filter: array<string, mixed>, update: array<string, mixed>, multi?: bool}> $operations | ||
| * @param array<string, mixed> $options | ||
| * | ||
| * @return array{ | ||
| * matched: int, | ||
| * modified: int, | ||
| * upserted: array<int, array{index: int, _id: mixed}> | ||
| * } Response counts. `matched` is the number of pre-existing docs matched | ||
| * by a filter (derived as `n - count(upserted)` since MongoDB's `n` | ||
| * includes upserts). `modified` is docs whose contents changed. `upserted` | ||
| * is the list of newly-created docs, each carrying the source operation's | ||
| * `index` and the assigned `_id`. | ||
| * | ||
| * @throws Exception When the response is missing the `n` field — typically | ||
| * the result of an unacknowledged write concern. | ||
| */ | ||
| public function upsertWithCounts(string $collection, array $operations, array $options = []): array | ||
| { | ||
| // MongoDB rejects an `update` command with an empty `updates` array, so | ||
| // short-circuit dynamically-built batches before they hit the wire. | ||
| if (empty($operations)) { | ||
| return [ | ||
| 'matched' => 0, | ||
| 'modified' => 0, | ||
| 'upserted' => [], | ||
| ]; | ||
| } | ||
|
|
||
| $updates = []; | ||
|
|
||
| foreach ($operations as $op) { | ||
| $updates[] = [ | ||
| 'q' => $op['filter'], | ||
| 'u' => $this->toObject($op['update']), | ||
| 'upsert' => true, | ||
| 'multi' => $op['multi'] ?? false, | ||
| ]; | ||
| } | ||
|
|
||
| $result = $this->query( | ||
| array_merge( | ||
| [ | ||
| self::COMMAND_UPDATE => $collection, | ||
| 'updates' => $updates, | ||
| ], | ||
| $options | ||
| ), | ||
|
Comment on lines
+960
to
+967
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, for MongoDB update commands (including db.collection.updateOne, updateMany, and the update database command), both writeConcern and readConcern must be specified as documents/objects rather than scalar values like 1 or "majority". Official MongoDB documentation explicitly states: - For writeConcern: "writeConcern: " (e.g., { w: "majority" }), as shown in the syntax for updateOne [6,7,17], update command [10], and write concern specification [2,3,18]. - For readConcern: "readConcern: { level: }" [11,15], and update operations support read concern only within transactions at the session/transaction level, not directly on the command. Scalar values like { writeConcern: "majority" } or { writeConcern: 1 } are invalid; the server expects a document with fields like w, j, wtimeout for writeConcern, leading to BSON type errors if a non-document is provided [1]. Drivers may offer convenience constructors (e.g., WriteConcern.Majority), but the wire protocol parameter is always a document. Citations:
🏁 Script executed: #!/bin/bash
# Check file size and read the entire Client.php file
wc -l src/Client.phpRepository: utopia-php/mongo Length of output: 79 🏁 Script executed: #!/bin/bash
# Search for the update() method to see how it handles options
rg -n "public function update\(" src/Client.php -A 50Repository: utopia-php/mongo Length of output: 2266 🏁 Script executed: #!/bin/bash
# Search for helper methods mentioned in the review
rg -n "createWriteConcern|createReadConcern|shouldSkipReadConcern" src/Client.phpRepository: utopia-php/mongo Length of output: 1996 🏁 Script executed: #!/bin/bash
# Search for the upsertWithCounts() method
rg -n "public function upsertWithCounts\(" src/Client.php -A 80Repository: utopia-php/mongo Length of output: 3479 Normalize
Suggested fix- $result = $this->query(
- array_merge(
- [
- self::COMMAND_UPDATE => $collection,
- 'updates' => $updates,
- ],
- $options
- ),
+ $command = [
+ self::COMMAND_UPDATE => $collection,
+ 'updates' => $updates,
+ ];
+
+ if (isset($options['session'])) {
+ $command['session'] = $options['session'];
+ }
+
+ if (isset($options['writeConcern'])) {
+ $command['writeConcern'] = $this->createWriteConcern($options['writeConcern']);
+ }
+
+ if (isset($options['readConcern']) && !$this->shouldSkipReadConcern($options)) {
+ $command['readConcern'] = $this->createReadConcern($options['readConcern']);
+ }
+
+ $otherOptions = array_diff_key($options, array_flip(['session', 'writeConcern', 'readConcern']));
+
+ $result = $this->query(
+ array_merge($command, $otherOptions),🤖 Prompt for AI Agents |
||
| null, | ||
| true // $returnRaw — we need the full response, not just $result->n | ||
| ); | ||
|
Comment on lines
+937
to
+970
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Short-circuit empty upsert batches.
Suggested fix public function upsertWithCounts(string $collection, array $operations, array $options = []): array
{
+ if ($operations === []) {
+ return [
+ 'matched' => 0,
+ 'modified' => 0,
+ 'upserted' => [],
+ ];
+ }
+
$updates = [];🤖 Prompt for AI Agents |
||
|
|
||
| if (!$result instanceof stdClass) { | ||
| throw new Exception('Unexpected upsertWithCounts response: expected stdClass, got ' . \gettype($result)); | ||
| } | ||
|
|
||
| // Defense-in-depth: the counts in this method are only meaningful for | ||
| // acknowledged writes. In practice mongod always replies with `n` over | ||
| // OP_MSG (even for writeConcern: { w: 0 }) because send() does not set | ||
| // the moreToCome flag, so this check is rarely tripped today — but if | ||
| // a future protocol change ever causes `n` to go missing, refuse loudly | ||
| // instead of silently returning zeros for writes that may have applied. | ||
| if (!\property_exists($result, 'n')) { | ||
| throw new Exception( | ||
| 'upsertWithCounts() requires acknowledged writes — the response did not include `n`. ' | ||
| . 'Do not pass writeConcern: { w: 0 } when calling this method.' | ||
| ); | ||
| } | ||
|
|
||
| $upserted = []; | ||
| if (\property_exists($result, 'upserted') && \is_iterable($result->upserted)) { | ||
| foreach ($result->upserted as $entry) { | ||
| $upserted[] = [ | ||
| 'index' => (int) $entry->index, | ||
| '_id' => $entry->_id, | ||
| ]; | ||
| } | ||
| } | ||
|
|
||
| // MongoDB's `n` in the update response = matched-existing + upserted-new. | ||
| // Subtract the upserts so `matched` reflects only pre-existing docs. | ||
| $matched = \max(0, (int) $result->n - \count($upserted)); | ||
|
|
||
| return [ | ||
| 'matched' => $matched, | ||
| 'modified' => (int) ($result->nModified ?? 0), | ||
| 'upserted' => $upserted, | ||
| ]; | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * Find document(s) with full transaction and session support. | ||
|
|
@@ -1584,7 +1692,7 @@ public function close(): void | |
| * @return stdClass|array|int Parsed response | ||
| * @throws Exception | ||
| */ | ||
| private function parseResponse(string $response, int $responseLength): stdClass|array|int | ||
| private function parseResponse(string $response, int $responseLength, bool $returnRaw = false): stdClass|array|int | ||
| { | ||
| /* | ||
| * The first 21 bytes of the MongoDB wire protocol response consist of: | ||
|
|
@@ -1648,6 +1756,12 @@ private function parseResponse(string $response, int $responseLength): stdClass| | |
| ); | ||
| } | ||
|
|
||
| // Callers that need the full response object (e.g. to read the | ||
| // `upserted` array from an update command) opt in with $returnRaw. | ||
| if ($returnRaw && $result->ok === 1.0) { | ||
| return $result; | ||
| } | ||
|
|
||
| // Check for operation success | ||
| if (\property_exists($result, 'n') && $result->ok === 1.0) { | ||
| return $result->n; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
In PHP 8.x, is a child method signature compatible with a parent if the parent adds a new optional parameter but the child override keeps the previous shorter signature?💡 Result:
No, a child method signature is not compatible with a parent if the parent adds a new optional parameter but the child override keeps the previous shorter signature. This violates method signature compatibility rules in PHP 8.x, as the child method has fewer parameters than the parent, making it incompatible under Liskov Substitution Principle (LSP) checks. In PHP 8.0+, such incompatibilities always trigger a fatal error during inheritance validation, rather than just a warning as in earlier versions.
Citations:
🏁 Script executed:
Repository: utopia-php/mongo
Length of output: 518
🏁 Script executed:
Repository: utopia-php/mongo
Length of output: 480
🏁 Script executed:
Repository: utopia-php/mongo
Length of output: 108
🏁 Script executed:
Repository: utopia-php/mongo
Length of output: 482
🏁 Script executed:
Repository: utopia-php/mongo
Length of output: 110
🏁 Script executed:
Repository: utopia-php/mongo
Length of output: 865
Preserve override compatibility for
query()andsend().Widening public, non-final method signatures can break downstream subclasses. If a child class overrides the old signature without the new optional parameters, PHP 8.0+ enforces strict compatibility and will raise a fatal LSP error. Since
Clientis non-final and part of your public API surface, consider keeping these signatures stable or marking the class as final if extension is not intended. Alternatively, move the raw-response toggle behind a new helper method instead.Also applies to: 378-378
🤖 Prompt for AI Agents