206 lines
6.5 KiB
PHP
206 lines
6.5 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\ClientInterface;
|
|
use GuzzleHttp\Exception\GuzzleException;
|
|
use Psr\Http\Message\StreamInterface;
|
|
use RuntimeException;
|
|
|
|
class ClickHouseClient
|
|
{
|
|
private readonly ClientInterface $http;
|
|
|
|
/** @param array<string,mixed>|null $configuration */
|
|
public function __construct(?ClientInterface $http = null, private readonly ?array $configuration = null)
|
|
{
|
|
$config = $this->config();
|
|
$this->http = $http ?? new Client([
|
|
'base_uri' => rtrim((string) $config['url'], '/') . '/',
|
|
'connect_timeout' => (float) $config['connect_timeout_seconds'],
|
|
'timeout' => (float) $config['query_timeout_seconds'],
|
|
]);
|
|
}
|
|
|
|
public function enabled(): bool
|
|
{
|
|
return (bool) $this->config()['enabled'];
|
|
}
|
|
|
|
public function ping(): bool
|
|
{
|
|
if (! $this->enabled()) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$response = $this->http->request('GET', 'ping', $this->authOptions());
|
|
|
|
return $response->getStatusCode() === 200
|
|
&& trim((string) $response->getBody()) === 'Ok.';
|
|
} catch (GuzzleException) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** @param array<string,scalar|null> $parameters */
|
|
public function command(string $sql, array $parameters = []): void
|
|
{
|
|
$this->request($sql, $parameters);
|
|
}
|
|
|
|
/** @param array<string,scalar|null> $parameters @return list<array<string,mixed>> */
|
|
public function rows(string $sql, array $parameters = []): array
|
|
{
|
|
$body = (string) $this->request(rtrim($sql, "; \t\n\r") . ' FORMAT JSONEachRow', $parameters);
|
|
if (trim($body) === '') {
|
|
return [];
|
|
}
|
|
|
|
$rows = [];
|
|
foreach (preg_split('/\r?\n/', trim($body)) ?: [] as $line) {
|
|
$decoded = json_decode($line, true, 64, JSON_THROW_ON_ERROR);
|
|
if (! is_array($decoded)) {
|
|
throw new RuntimeException('clickhouse_invalid_json_row');
|
|
}
|
|
$rows[] = $decoded;
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/** @param array<string,scalar|null> $parameters */
|
|
public function scalar(string $sql, array $parameters = []): mixed
|
|
{
|
|
$body = (string) $this->request(rtrim($sql, "; \t\n\r") . ' FORMAT TabSeparatedRaw', $parameters);
|
|
|
|
return trim($body);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $columns
|
|
* @param list<array<string,mixed>> $rows
|
|
*/
|
|
public function insertJsonEachRow(string $table, array $columns, array $rows): void
|
|
{
|
|
if ($rows === []) {
|
|
return;
|
|
}
|
|
|
|
$table = $this->qualifiedTable($table);
|
|
$columns = array_map([$this, 'identifier'], $columns);
|
|
$payload = '';
|
|
foreach ($rows as $row) {
|
|
$payload .= json_encode($row, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR) . "\n";
|
|
}
|
|
|
|
$sql = sprintf('INSERT INTO %s (%s) FORMAT JSONEachRow', $table, implode(', ', $columns));
|
|
$this->request($sql, [], $payload, 'application/x-ndjson');
|
|
}
|
|
|
|
/** @param array<string,scalar|null> $parameters */
|
|
public function stream(string $sql, string $format, array $parameters = []): StreamInterface
|
|
{
|
|
if (preg_match('/^[A-Za-z][A-Za-z0-9]*$/', $format) !== 1) {
|
|
throw new RuntimeException('clickhouse_invalid_format');
|
|
}
|
|
|
|
return $this->request(
|
|
rtrim($sql, "; \t\n\r") . ' FORMAT ' . $format,
|
|
$parameters,
|
|
null,
|
|
null,
|
|
true,
|
|
);
|
|
}
|
|
|
|
public function qualifiedTable(string $table): string
|
|
{
|
|
return $this->identifier((string) $this->config()['database']) . '.' . $this->identifier($table);
|
|
}
|
|
|
|
public function identifier(string $value): string
|
|
{
|
|
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $value) !== 1) {
|
|
throw new RuntimeException('clickhouse_invalid_identifier');
|
|
}
|
|
|
|
return '`' . $value . '`';
|
|
}
|
|
|
|
/**
|
|
* @param array<string,scalar|null> $parameters
|
|
* @return StreamInterface|string
|
|
*/
|
|
private function request(
|
|
string $sql,
|
|
array $parameters = [],
|
|
?string $body = null,
|
|
?string $contentType = null,
|
|
bool $stream = false,
|
|
): StreamInterface|string {
|
|
if (! $this->enabled()) {
|
|
throw new RuntimeException('clickhouse_disabled');
|
|
}
|
|
|
|
$query = [
|
|
'database' => (string) $this->config()['database'],
|
|
'wait_end_of_query' => 1,
|
|
'query' => $sql,
|
|
];
|
|
foreach ($parameters as $name => $value) {
|
|
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name) !== 1 || (! is_scalar($value) && $value !== null)) {
|
|
throw new RuntimeException('clickhouse_invalid_parameter');
|
|
}
|
|
$query['param_' . $name] = $value;
|
|
}
|
|
|
|
$options = $this->authOptions() + ['query' => $query, 'stream' => $stream];
|
|
if ($stream) {
|
|
$options['timeout'] = (float) $this->config()['stream_timeout_seconds'];
|
|
}
|
|
if ($body !== null) {
|
|
$options['body'] = $body;
|
|
}
|
|
if ($contentType !== null) {
|
|
$options['headers'] = ['Content-Type' => $contentType];
|
|
}
|
|
|
|
try {
|
|
$response = $this->http->request('POST', '', $options);
|
|
} catch (GuzzleException $exception) {
|
|
throw new RuntimeException('clickhouse_request_failed', 0, $exception);
|
|
}
|
|
|
|
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
|
|
throw new RuntimeException('clickhouse_unexpected_status');
|
|
}
|
|
|
|
return $stream ? $response->getBody() : (string) $response->getBody();
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function authOptions(): array
|
|
{
|
|
$username = (string) $this->config()['username'];
|
|
|
|
return $username === '' ? [] : ['auth' => [$username, (string) $this->config()['password']]];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function config(): array
|
|
{
|
|
return ($this->configuration ?? (array) config('business_data.clickhouse', [])) + [
|
|
'enabled' => false,
|
|
'url' => 'http://clickhouse:8123',
|
|
'database' => 'caiwu_business_analytics',
|
|
'username' => 'caiwu_app',
|
|
'password' => '',
|
|
'connect_timeout_seconds' => 3,
|
|
'query_timeout_seconds' => 30,
|
|
'stream_timeout_seconds' => 1800,
|
|
];
|
|
}
|
|
}
|