Skip to content

Commit 4857c68

Browse files
committed
upsert WIP
1 parent 0d3bad4 commit 4857c68

8 files changed

Lines changed: 284 additions & 107 deletions

File tree

src/Eloquent/Builder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class Builder extends BaseEloquentBuilder
7575
'openpit',
7676
'bulkinsert',
7777
'createonly',
78+
'upsert',
7879
];
7980

8081
/**

src/Eloquent/Docs/ModelDocs.php

Lines changed: 114 additions & 104 deletions
Large diffs are not rendered by default.

src/Eloquent/Model.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
use PDPhilip\Elasticsearch\Data\ModelMeta;
99

1010
/**
11-
* @method bool|int push(string $column = null, mixed $values = null, bool $unique = false)
12-
*
1311
* @property object $searchHighlights
1412
* @property array $searchHighlightsAsArray
1513
* @property object $withHighlights

src/Query/Builder.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,99 @@ public function bulkInsert(array $values): array
748748
return $this->processor->processBulkInsert($this, $this->connection->insert($this->grammar->compileInsert($this, $values), [], true));
749749
}
750750

751+
/**
752+
* Insert or update records matching the unique key.
753+
*
754+
* ES has no unique column constraints, so this queries for existing
755+
* documents first, then issues a single bulk request mixing index
756+
* (for new docs) and update (for existing docs) actions.
757+
*/
758+
public function upsert(array $values, $uniqueBy, $update = null): int
759+
{
760+
// Normalize to batch format
761+
if (! array_is_list($values)) {
762+
$values = [$values];
763+
}
764+
765+
$uniqueBy = (array) $uniqueBy;
766+
767+
// Empty update means plain insert
768+
if ($update === []) {
769+
$this->insert($values);
770+
771+
return count($values);
772+
}
773+
774+
// Collect the unique field values from the input
775+
$lookupValues = [];
776+
foreach ($values as &$doc) {
777+
$key = $this->buildUpsertKey($doc, $uniqueBy);
778+
$doc['_upsert_key'] = $key;
779+
$lookupValues[] = $key;
780+
}
781+
unset($doc);
782+
783+
// Query ES for existing documents matching the unique fields
784+
$existingIds = $this->findExistingIds($uniqueBy, $lookupValues);
785+
786+
// Compile and execute the bulk request
787+
$dsl = $this->grammar->compileUpsert($this, $values, $existingIds, $update);
788+
$result = $this->connection->insert($dsl);
789+
790+
return $this->processor->processUpsert($this, $result);
791+
}
792+
793+
/**
794+
* Build a lookup key from the document's unique field values.
795+
*/
796+
private function buildUpsertKey(array $doc, array $uniqueBy): string
797+
{
798+
$parts = [];
799+
foreach ($uniqueBy as $field) {
800+
$parts[] = (string) ($doc[$field] ?? '');
801+
}
802+
803+
return implode('|', $parts);
804+
}
805+
806+
/**
807+
* Find existing document IDs by unique field values.
808+
*
809+
* Returns a map of lookup_key => _id.
810+
*/
811+
private function findExistingIds(array $uniqueBy, array $lookupValues): array
812+
{
813+
// Build a fresh query against the same index
814+
$query = $this->newQuery();
815+
816+
if (count($uniqueBy) === 1) {
817+
// Single field: simple whereIn
818+
$field = $uniqueBy[0];
819+
$fieldValues = array_unique(array_map(fn ($key) => explode('|', $key)[0], $lookupValues));
820+
$results = $query->whereIn($field, $fieldValues)->get();
821+
} else {
822+
// Multi-field: use bool should with exact match per combination
823+
foreach (array_unique($lookupValues) as $key) {
824+
$parts = explode('|', $key);
825+
$query->orWhere(function ($q) use ($uniqueBy, $parts) {
826+
foreach ($uniqueBy as $i => $field) {
827+
$q->where($field, $parts[$i] ?? '');
828+
}
829+
});
830+
}
831+
$results = $query->get();
832+
}
833+
834+
// Map lookup_key => _id
835+
$map = [];
836+
foreach ($results as $result) {
837+
$key = $this->buildUpsertKey((array) $result, $uniqueBy);
838+
$map[$key] = $result['_id'];
839+
}
840+
841+
return $map;
842+
}
843+
751844
/**
752845
* {@inheritdoc}
753846
*/

src/Query/DSL/DslFactory.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ public static function indexOperation(string $index, mixed $id = null, array $op
2121
return ['index' => $operation];
2222
}
2323

24+
public static function updateOperation(string $index, string $id, array $options = []): array
25+
{
26+
return ['update' => array_merge(['_index' => $index, '_id' => $id], $options)];
27+
}
28+
2429
// ----------------------------------------------------------------------
2530
// Query
2631
// ----------------------------------------------------------------------

src/Query/Grammar/Grammar.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,56 @@ public function compileInsert($query, array $values): array
112112
return $dsl->getDsl();
113113
}
114114

115+
/**
116+
* Compile an upsert bulk request.
117+
*
118+
* Existing docs (with _id) use the update action with doc_as_upsert.
119+
* New docs use the index action.
120+
*/
121+
public function compileUpsert($query, array $values, array $existingIds, ?array $updateColumns): array
122+
{
123+
$dsl = new DslBuilder;
124+
$index = $query->getFrom();
125+
126+
foreach ($values as $doc) {
127+
// Convert DateTime values
128+
foreach ($doc as &$property) {
129+
$property = $this->getStringValue($property);
130+
}
131+
unset($property);
132+
133+
// Clean internal fields from the doc body
134+
$cleanDoc = $doc;
135+
unset($cleanDoc['id'], $cleanDoc['_id']);
136+
137+
$lookupKey = $doc['_upsert_key'] ?? null;
138+
unset($cleanDoc['_upsert_key']);
139+
140+
$existingId = $lookupKey !== null ? ($existingIds[$lookupKey] ?? null) : null;
141+
142+
if ($existingId) {
143+
// Existing doc: update action with partial doc
144+
$updateDoc = $updateColumns
145+
? array_intersect_key($cleanDoc, array_flip($updateColumns))
146+
: $cleanDoc;
147+
148+
$dsl->appendBody(DslFactory::updateOperation($index, $existingId));
149+
$dsl->appendBody([
150+
'doc' => $updateDoc,
151+
'upsert' => $cleanDoc,
152+
]);
153+
} else {
154+
// New doc: index action
155+
$dsl->appendBody(DslFactory::indexOperation($index));
156+
$dsl->appendBody($cleanDoc);
157+
}
158+
}
159+
160+
$dsl->setRefresh($query->getOption('refresh', true));
161+
162+
return $dsl->getDsl();
163+
}
164+
115165
/**
116166
* @param Builder $query
117167
*

src/Query/Processor/Processor.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,26 @@ public function processBulkInsert(Builder $query, Elasticsearch $result): array
289289
return $outcome;
290290
}
291291

292+
public function processUpsert(Builder $query, Elasticsearch $result): int
293+
{
294+
$this->rawResponse = $result;
295+
$this->query = $query;
296+
297+
$process = $result->asArray();
298+
$count = 0;
299+
300+
foreach ($process['items'] ?? [] as $item) {
301+
// Bulk response items are keyed by action type (index or update)
302+
$action = $item['index'] ?? $item['update'] ?? null;
303+
304+
if ($action && empty($action['error'])) {
305+
$count++;
306+
}
307+
}
308+
309+
return $count;
310+
}
311+
292312
public function processRaw($query, $response)
293313
{
294314
$this->rawResponse = $response;

tests/ModelTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@
107107
expect($result)->toBe(1)
108108
->and(User::count())->toBe(2)
109109
->and(User::where('email', 'foo')->first()->name)->toBe('bar3');
110-
})->todo();
110+
});
111111

112112
it('tests manual string id', function () {
113113
$user = new User;

0 commit comments

Comments
 (0)