Skip to content

Commit 76cd710

Browse files
Merge pull request #13 from utopia-php/feat/clickhouse-nested-column-quoting
fix(schema): quote column names atomically + feat(clickhouse): bulk-insert builder
2 parents 0ffa242 + 36ba9d5 commit 76cd710

17 files changed

Lines changed: 790 additions & 69 deletions

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,47 @@ $result->namedBindings;
15201520

15211521
Unregistered columns fall through to value-based inference: `int → Int64`, `float → Float64`, `bool → UInt8`, `null → Nullable(String)`, `DateTimeInterface → DateTime64(3)`, everything else → `String`. Register types via `withParamType($column, $type)` or `withParamTypes($map)` whenever the inference rule doesn't match the column's ClickHouse declaration. The positional `$bindings` array is still exposed on the resulting `Statement` for callers that prefer it.
15221522

1523+
**Bulk insert** — emit the canonical `INSERT INTO <table> FORMAT <name>` envelope together with the serialized row payload in a single typed call. The returned `FormattedInsertStatement` exposes `->query` (the envelope) and `->body` (the format-specific payload) so the caller can ship both to ClickHouse's HTTP interface without hand-assembling either side:
1524+
1525+
```php
1526+
use Utopia\Query\Builder\ClickHouse as Builder;
1527+
use Utopia\Query\Builder\ClickHouse\Format;
1528+
1529+
$statement = (new Builder())
1530+
->into('events')
1531+
->bulkInsert(Format::JSONEachRow, [
1532+
['id' => 1, 'event' => 'login', 'time' => '2024-01-01 00:00:00'],
1533+
['id' => 2, 'event' => 'logout', 'time' => '2024-01-01 00:00:05'],
1534+
]);
1535+
1536+
// $statement->query
1537+
// INSERT INTO `events` (`id`, `event`, `time`) FORMAT JSONEachRow
1538+
//
1539+
// $statement->body
1540+
// {"id":1,"event":"login","time":"2024-01-01 00:00:00"}
1541+
// {"id":2,"event":"logout","time":"2024-01-01 00:00:05"}
1542+
```
1543+
1544+
Ship the result over the HTTP interface by passing `$statement->query` as the `?query=` parameter and `$statement->body` as the POST body. Columns are derived from the first row's keys; pass an explicit third argument to pin the order or fill missing keys with `null`:
1545+
1546+
```php
1547+
$statement = (new Builder())
1548+
->into('events')
1549+
->bulkInsert(Format::JSONEachRow, $rows, ['id', 'event', 'time']);
1550+
```
1551+
1552+
The `Format` enum currently supports `Format::JSONEachRow` and `Format::TabSeparated`. JSONEachRow rows are encoded with `JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` (slashes and non-ASCII are preserved verbatim); TabSeparated escapes `\\`, `\t`, `\n`, `\r` and emits `\N` for `null`. An empty row iterable produces an empty body, which ClickHouse accepts as a zero-row ingest. The iterable is consumed eagerly — pass a generator if you want to defer row construction, but the serialized body is materialized in full before the statement is returned.
1553+
1554+
`bulkInsert()` is the recommended entry point for FORMAT-based ingest — it covers the full envelope + body contract in one typed call. The lower-level `insertFormat()` setter pairs with `insert()` for the envelope-only path (returns `body = null`) and is retained for callers that stream the payload separately. Both paths share the same envelope emitter, so the resulting `query` is identical for equivalent inputs:
1555+
1556+
```php
1557+
$statement = (new Builder())
1558+
->into('events')
1559+
->insertFormat('JSONEachRow', ['id', 'event', 'time'])
1560+
->insert();
1561+
// $statement->body is null; assemble the payload separately.
1562+
```
1563+
15231564
**UPDATE** — compiles to `ALTER TABLE ... UPDATE` with mandatory WHERE:
15241565

15251566
```php

src/Query/Builder/ClickHouse.php

Lines changed: 85 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Utopia\Query\Builder;
44

55
use Utopia\Query\Builder as BaseBuilder;
6+
use Utopia\Query\Builder\ClickHouse\Format;
67
use Utopia\Query\Builder\ClickHouse\FormattedInsertStatement;
78
use Utopia\Query\Builder\Feature\BitwiseAggregates;
89
use Utopia\Query\Builder\Feature\ClickHouse\ApproximateAggregates;
@@ -140,11 +141,58 @@ public function hint(string $hint): static
140141
}
141142

142143
/**
143-
* Declare a ClickHouse FORMAT pragma for the next INSERT.
144+
* Recommended bulk-ingest entry point. Emits the canonical `INSERT INTO
145+
* <table> [(<cols>)] FORMAT <name>` envelope alongside the serialized row
146+
* payload in a single typed call. The returned `FormattedInsertStatement`
147+
* exposes `->query` (envelope, no bindings) and `->body` (format-specific
148+
* payload) so the caller can ship both to ClickHouse's HTTP interface
149+
* without hand-assembling either side.
150+
*
151+
* The target table must be set via `into()` first. Columns are derived
152+
* from the keys of the first row when `$columns` is omitted; pass
153+
* `$columns` explicitly to pin the order when row shapes vary or when
154+
* an empty iterable is passed. An empty iterable produces an empty
155+
* body — ClickHouse accepts this as a zero-row ingest.
156+
*
157+
* @param iterable<array<string, mixed>> $rows
158+
* @param list<string> $columns Optional explicit column ordering.
159+
*/
160+
public function bulkInsert(Format $format, iterable $rows, array $columns = []): FormattedInsertStatement
161+
{
162+
$materialized = [];
163+
foreach ($rows as $row) {
164+
/** @phpstan-ignore function.alreadyNarrowedType */
165+
if (!\is_array($row)) {
166+
throw new ValidationException('bulkInsert() rows must be associative arrays.');
167+
}
168+
$materialized[] = $row;
169+
}
170+
171+
if (empty($columns) && !empty($materialized)) {
172+
$columns = \array_keys($materialized[0]);
173+
}
174+
175+
$sql = $this->compileFormatInsertEnvelope($format->value, $columns);
176+
177+
$body = $format->serialize($materialized, empty($columns) ? null : $columns);
178+
179+
return new FormattedInsertStatement(
180+
$sql,
181+
[],
182+
$columns,
183+
$format->value,
184+
$body,
185+
executor: $this->executor,
186+
);
187+
}
188+
189+
/**
190+
* Lower-level setter for the FORMAT envelope. Use `bulkInsert()` for the
191+
* typed entry point; this method is retained for callers that need to
192+
* stream the body payload separately (e.g. piping a pre-serialized stream
193+
* straight into the HTTP request) — the subsequent `insert()` call emits
194+
* the envelope only, with `body = null`.
144195
*
145-
* When a format is set, `insert()` emits
146-
* `INSERT INTO \`t\` (\`col1\`, \`col2\`) FORMAT <name>` with no VALUES.
147-
* The row payload must be streamed into the HTTP body by the caller.
148196
* Column names are derived from the most recent `set()` call (values are
149197
* ignored). Pass `$columns` to declare them explicitly when no `set()`
150198
* call has been made.
@@ -163,6 +211,38 @@ public function insertFormat(string $format, array $columns = []): static
163211
return $this;
164212
}
165213

214+
/**
215+
* Build the shared `INSERT INTO <table> [(<cols>)] FORMAT <name>`
216+
* envelope. Validates the table, validates column names, quotes the
217+
* table identifier, and wraps each column via `resolveAndWrap()`.
218+
* Resets bindings so callers don't accumulate stale values from prior
219+
* builder operations.
220+
*
221+
* @param list<string> $columns
222+
*/
223+
private function compileFormatInsertEnvelope(string $format, array $columns): string
224+
{
225+
$this->bindings = [];
226+
$this->validateTable();
227+
228+
foreach ($columns as $col) {
229+
if ($col === '') {
230+
throw new ValidationException('Column names for FORMAT INSERT must be non-empty strings.');
231+
}
232+
}
233+
234+
$wrappedColumns = empty($columns)
235+
? ''
236+
: ' (' . \implode(', ', \array_map(
237+
fn (string $col): string => $this->resolveAndWrap($col),
238+
$columns
239+
)) . ')';
240+
241+
return 'INSERT INTO ' . $this->quote($this->table)
242+
. $wrappedColumns
243+
. ' FORMAT ' . $format;
244+
}
245+
166246
/**
167247
* @param array<string, string> $settings
168248
*/
@@ -618,31 +698,11 @@ public function insert(): Statement
618698
return $this->applyNamedTypedBindings(parent::insert());
619699
}
620700

621-
$this->bindings = [];
622-
$this->validateTable();
623-
624701
$columns = !empty($this->insertFormatColumns)
625702
? $this->insertFormatColumns
626703
: (!empty($this->rows) ? \array_keys($this->rows[0]) : []);
627704

628-
if (empty($columns)) {
629-
throw new ValidationException('No columns specified for FORMAT INSERT. Pass columns to insertFormat() or call set() before insert().');
630-
}
631-
632-
foreach ($columns as $col) {
633-
if ($col === '') {
634-
throw new ValidationException('Column names for FORMAT INSERT must be non-empty strings.');
635-
}
636-
}
637-
638-
$wrappedColumns = \array_map(
639-
fn (string $col): string => $this->resolveAndWrap($col),
640-
$columns
641-
);
642-
643-
$sql = 'INSERT INTO ' . $this->quote($this->table)
644-
. ' (' . \implode(', ', $wrappedColumns) . ')'
645-
. ' FORMAT ' . $format;
705+
$sql = $this->compileFormatInsertEnvelope($format, $columns);
646706

647707
return new FormattedInsertStatement(
648708
$sql,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
namespace Utopia\Query\Builder\ClickHouse;
4+
5+
use Utopia\Query\Exception\ValidationException;
6+
7+
/**
8+
* ClickHouse bulk-ingest format identifiers.
9+
*
10+
* The values map 1:1 to the names ClickHouse accepts after the `FORMAT`
11+
* keyword in an `INSERT INTO <table> FORMAT <name>` envelope. Each case
12+
* knows how to serialize a row iterable into the request body that
13+
* ClickHouse expects for that format.
14+
*/
15+
enum Format: string
16+
{
17+
case JSONEachRow = 'JSONEachRow';
18+
case TabSeparated = 'TabSeparated';
19+
20+
/**
21+
* Serialize an iterable of associative rows into the body payload for
22+
* this format. An empty iterable yields an empty string — ClickHouse
23+
* accepts an empty body as a zero-row insert.
24+
*
25+
* When `$columns` is null the column ordering is derived from the keys
26+
* of the first row encountered. Subsequent rows are serialized against
27+
* whatever shape they themselves carry — there is no cross-row
28+
* consistency check. The implications differ per format:
29+
*
30+
* - For positional formats (e.g. {@see Format::TabSeparated}) values
31+
* are emitted in row-key order. If later rows reorder their keys the
32+
* columns silently misalign with the envelope's column list. Pass
33+
* `$columns` explicitly whenever row shapes are not guaranteed
34+
* identical, or whenever the format is positional.
35+
* - For named formats (e.g. {@see Format::JSONEachRow}) key ordering
36+
* does not affect correctness because each value is paired with its
37+
* key in the wire format. `$columns` still acts as a projection
38+
* filter: rows missing a listed column receive `null`, and row keys
39+
* outside the list are dropped.
40+
*
41+
* @param iterable<array<string, mixed>> $rows
42+
* @param list<string>|null $columns Optional explicit column ordering. When null, derived from the keys of the first row.
43+
*/
44+
public function serialize(iterable $rows, ?array $columns = null): string
45+
{
46+
return match ($this) {
47+
self::JSONEachRow => $this->serializeJsonEachRow($rows, $columns),
48+
self::TabSeparated => $this->serializeTabSeparated($rows, $columns),
49+
};
50+
}
51+
52+
/**
53+
* @param iterable<array<string, mixed>> $rows
54+
* @param list<string>|null $columns
55+
*/
56+
private function serializeJsonEachRow(iterable $rows, ?array $columns): string
57+
{
58+
$lines = [];
59+
foreach ($rows as $row) {
60+
if ($columns !== null) {
61+
$ordered = [];
62+
foreach ($columns as $col) {
63+
$ordered[$col] = $row[$col] ?? null;
64+
}
65+
$row = $ordered;
66+
}
67+
68+
$lines[] = \json_encode(
69+
(object) $row,
70+
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
71+
);
72+
}
73+
74+
return \implode("\n", $lines);
75+
}
76+
77+
/**
78+
* @param iterable<array<string, mixed>> $rows
79+
* @param list<string>|null $columns
80+
*/
81+
private function serializeTabSeparated(iterable $rows, ?array $columns): string
82+
{
83+
$lines = [];
84+
foreach ($rows as $row) {
85+
$values = [];
86+
87+
if ($columns === null) {
88+
foreach ($row as $value) {
89+
$values[] = $this->escapeTabSeparatedValue($value);
90+
}
91+
} else {
92+
foreach ($columns as $col) {
93+
$values[] = $this->escapeTabSeparatedValue($row[$col] ?? null);
94+
}
95+
}
96+
97+
$lines[] = \implode("\t", $values);
98+
}
99+
100+
return \implode("\n", $lines);
101+
}
102+
103+
private function escapeTabSeparatedValue(mixed $value): string
104+
{
105+
if ($value === null) {
106+
return '\\N';
107+
}
108+
109+
if (\is_bool($value)) {
110+
return $value ? '1' : '0';
111+
}
112+
113+
if (\is_int($value) || \is_float($value)) {
114+
return (string) $value;
115+
}
116+
117+
if (! \is_string($value)) {
118+
if (\is_object($value) && \method_exists($value, '__toString')) {
119+
$value = (string) $value;
120+
} else {
121+
throw new ValidationException('TabSeparated values must be scalar, null, or stringable. Received: ' . \get_debug_type($value));
122+
}
123+
}
124+
125+
return \strtr($value, [
126+
'\\' => '\\\\',
127+
"\t" => '\\t',
128+
"\n" => '\\n',
129+
"\r" => '\\r',
130+
]);
131+
}
132+
}

src/Query/Builder/ClickHouse/FormattedInsertStatement.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* @param list<mixed> $bindings
1313
* @param list<string> $columns
1414
* @param string $format
15+
* @param ?string $body Serialized payload to ship as the HTTP request body alongside `$query`. Null when only the envelope query was produced (the caller assembles the body separately).
1516
* @param bool $readOnly
1617
* @param (Closure(Statement): (array<mixed>|int))|null $executor
1718
*/
@@ -20,6 +21,7 @@ public function __construct(
2021
array $bindings,
2122
public array $columns,
2223
public string $format,
24+
public ?string $body = null,
2325
bool $readOnly = false,
2426
?Closure $executor = null,
2527
) {
@@ -34,6 +36,7 @@ public function withExecutor(Closure $executor): self
3436
$this->bindings,
3537
$this->columns,
3638
$this->format,
39+
$this->body,
3740
$this->readOnly,
3841
$executor,
3942
);

src/Query/QuotesIdentifiers.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,28 @@ protected function quote(string $identifier): string
4141

4242
return \implode('.', $wrapped);
4343
}
44+
45+
/**
46+
* Quote a single identifier without treating dots as qualifier separators.
47+
*
48+
* Use when the identifier is known to be atomic — e.g. a column name in a
49+
* CREATE TABLE definition where the dot is a literal part of the name
50+
* rather than a `schema.table.column` separator. The canonical case is
51+
* ClickHouse's nested-array convention (`meta.key Array(String)`) where
52+
* `meta.key` is a single top-level column whose name contains a dot.
53+
*/
54+
protected function quoteLiteral(string $identifier): string
55+
{
56+
if ($identifier === '*') {
57+
return '*';
58+
}
59+
60+
if (\preg_match('/[\x00-\x1f\x7f]/', $identifier) === 1) {
61+
throw new ValidationException('Identifier contains control character');
62+
}
63+
64+
return $this->wrapChar
65+
. \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $identifier)
66+
. $this->wrapChar;
67+
}
4468
}

0 commit comments

Comments
 (0)