diff --git a/src/Response/Elasticsearch.php b/src/Response/Elasticsearch.php index 164d999fa..267f72b12 100644 --- a/src/Response/Elasticsearch.php +++ b/src/Response/Elasticsearch.php @@ -15,17 +15,20 @@ namespace Elastic\Elasticsearch\Response; use ArrayAccess; +use DateTime; use Elastic\Elasticsearch\Exception\ArrayAccessException; use Elastic\Elasticsearch\Exception\ClientResponseException; use Elastic\Elasticsearch\Exception\ServerResponseException; use Elastic\Elasticsearch\Traits\MessageResponseTrait; use Elastic\Elasticsearch\Traits\ProductCheckTrait; +use Elastic\Elasticsearch\Utility; use Elastic\Transport\Exception\UnknownContentTypeException; use Elastic\Transport\Serializer\CsvSerializer; use Elastic\Transport\Serializer\JsonSerializer; use Elastic\Transport\Serializer\NDJsonSerializer; use Elastic\Transport\Serializer\XmlSerializer; use Psr\Http\Message\ResponseInterface; +use stdClass; /** * Wraps a PSR-7 ResponseInterface offering helpers to deserialize the body response @@ -224,4 +227,63 @@ public function offsetUnset($offset): void { throw new ArrayAccessException('The array is reading only'); } + + /** + * Map the response body to an object of a specific class + * by default the class is the PHP standard one (stdClass) + * + * This mapping works only for ES|QL results (with columns and values) + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html + * + * @return object[] + */ + public function mapTo(string $class = stdClass::class): array + { + $response = $this->asArray(); + if (!isset($response['columns']) || !isset($response['values'])) { + throw new UnknownContentTypeException(sprintf( + "The response is not a valid ES|QL result. I cannot mapTo(\"%s\")", + $class + )); + } + $iterator = []; + $ncol = count($response['columns']); + foreach ($response['values'] as $value) { + $obj = new $class; + for ($i=0; $i < $ncol; $i++) { + $field = Utility::formatVariableName($response['columns'][$i]['name']); + if ($class !== stdClass::class && !property_exists($obj, $field)) { + continue; + } + switch($response['columns'][$i]['type']) { + case 'boolean': + $obj->{$field} = (bool) $value[$i]; + break; + case 'date': + $obj->{$field} = new DateTime($value[$i]); + break; + case 'alias': + case 'text': + case 'keyword': + case 'ip': + $obj->{$field} = (string) $value[$i]; + break; + case 'integer': + $obj->{$field} = (int) $value[$i]; + break; + case 'long': + case 'double': + $obj->{$field} = (float) $value[$i]; + break; + case 'null': + $obj->{$field} = null; + break; + default: + $obj->{$field} = $value[$i]; + } + } + $iterator[] = $obj; + } + return $iterator; + } } \ No newline at end of file diff --git a/src/Utility.php b/src/Utility.php index 0bc8593e1..d54185ce5 100644 --- a/src/Utility.php +++ b/src/Utility.php @@ -45,4 +45,18 @@ public static function urlencode(string $url): string ? urlencode($url) : rawurlencode($url); } + + /** + * Remove all the characters not valid for a PHP variable name + * The valid characters are: a-z, A-Z, 0-9 and _ (underscore) + * The variable name CANNOT start with a number + */ + public static function formatVariableName(string $var): string + { + // If the first character is a digit, we append the underscore + if (is_int($var[0])) { + $var = '_' . $var; + } + return preg_replace('/[^a-zA-Z0-9_]/', '', $var); + } } \ No newline at end of file diff --git a/tests/Response/ElasticsearchTest.php b/tests/Response/ElasticsearchTest.php index 8e5f0fbee..1a3e30827 100644 --- a/tests/Response/ElasticsearchTest.php +++ b/tests/Response/ElasticsearchTest.php @@ -14,15 +14,17 @@ namespace Elastic\Elasticsearch\Tests\Response; +use DateTime; use Elastic\Elasticsearch\Exception\ArrayAccessException; use Elastic\Elasticsearch\Exception\ClientResponseException; use Elastic\Elasticsearch\Exception\ProductCheckException; use Elastic\Elasticsearch\Exception\ServerResponseException; use Elastic\Elasticsearch\Response\Elasticsearch; +use Elastic\Transport\Exception\UnknownContentTypeException; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; - +use stdClass; class ElasticsearchTest extends TestCase { protected Psr17Factory $psr17Factory; @@ -215,4 +217,84 @@ public function testWithStatusForPsr7Version1And2Compatibility() $this->elasticsearch = $this->elasticsearch->withStatus(400); $this->assertEquals(400, $this->elasticsearch->getStatusCode()); } + + public function testMapToStdClassAsDefault() + { + $array = [ + 'columns' => [ + ['name' => 'a', 'type' => 'integer'], + ['name' => 'b', 'type' => 'date'] + ], + 'values' => [ + [1, '2023-10-23T12:15:03.360Z'], + [3, '2023-10-23T13:55:01.543Z'] + ] + ]; + $body = $this->psr17Factory->createStream(json_encode($array)); + $this->elasticsearch->setResponse($this->response200->withBody($body)); + + $iterator = $this->elasticsearch->mapTo(); + $this->assertIsArray($iterator); + $this->assertEquals(stdClass::class, get_class($iterator[0])); + $this->assertEquals(stdClass::class, get_class($iterator[1])); + $this->assertEquals('integer', gettype($iterator[0]->a)); + $this->assertEquals(DateTime::class, get_class($iterator[0]->b)); + $this->assertEquals('integer', gettype($iterator[1]->a)); + $this->assertEquals(DateTime::class, get_class($iterator[1]->b)); + } + + public function testMapToStdClass() + { + $array = [ + 'columns' => [ + ['name' => 'a', 'type' => 'integer'], + ['name' => 'b', 'type' => 'date'] + ], + 'values' => [ + [1, '2023-10-23T12:15:03.360Z'], + [3, '2023-10-23T13:55:01.543Z'] + ] + ]; + $body = $this->psr17Factory->createStream(json_encode($array)); + $this->elasticsearch->setResponse($this->response200->withBody($body)); + + $iterator = $this->elasticsearch->mapTo(stdClass::class); + $this->assertIsArray($iterator); + $this->assertEquals(stdClass::class, get_class($iterator[0])); + $this->assertEquals(stdClass::class, get_class($iterator[1])); + } + + public function testMapToWithoutEsqlResponseWillThrowException() + { + $array = ['foo' => 'bar']; + $body = $this->psr17Factory->createStream(json_encode($array)); + $this->elasticsearch->setResponse($this->response200->withBody($body)); + + $this->expectException(UnknownContentTypeException::class); + $iterator = $this->elasticsearch->mapTo(); + } + + public function testMapToCustomClass() + { + $array = [ + 'columns' => [ + ['name' => 'a', 'type' => 'integer'], + ['name' => 'b', 'type' => 'date'] + ], + 'values' => [ + [1, '2023-10-23T12:15:03.360Z'], + [3, '2023-10-23T13:55:01.543Z'] + ] + ]; + $body = $this->psr17Factory->createStream(json_encode($array)); + $this->elasticsearch->setResponse($this->response200->withBody($body)); + + $iterator = $this->elasticsearch->mapTo(TestMapClass::class); + + $this->assertIsArray($iterator); + $this->assertEquals(TestMapClass::class, get_class($iterator[0])); + $this->assertEquals('integer', gettype($iterator[0]->a)); + $this->assertEquals(DateTime::class, get_class($iterator[0]->b)); + $this->assertEquals('', $iterator[0]->c); + } } \ No newline at end of file diff --git a/tests/Response/TestMapClass.php b/tests/Response/TestMapClass.php new file mode 100644 index 000000000..e319ac71c --- /dev/null +++ b/tests/Response/TestMapClass.php @@ -0,0 +1,24 @@ +