ch_connect 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8625111f390ba7adf290bf889a5684c34003d1f318f421c62939ae13a310f569
4
- data.tar.gz: 512cc470c691018ac249647c3dc46918490705392667bee72ebb5f49bb604b44
3
+ metadata.gz: 5856233f30844a931e61ac5aca11af740c7a876a95ae19e497a3157bca9097b2
4
+ data.tar.gz: 6588fea1311f26c52e6b13885316741e47a98a284751456d9a5c3e022d036c04
5
5
  SHA512:
6
- metadata.gz: 7157352591d8de2ff4f07228e2e57c9df16f0eb8ff4d75e68499bd07d402a082e938bbcaec2975ffba6dfdd697229f5c929925db13cffb643cd3001a275e7010
7
- data.tar.gz: a5c4988858f866533fb27784af8c2a0f109e2432078d4ec819e644b55a45896d47c5fb07a0dd2225b9f2660e0f6cfe9ce880cad3b95dae59feb003b25d1c0bca
6
+ metadata.gz: 8a2650b889220c5b2e66803f0da23022357fd6c6997e7d09841b983768d099bb487b4e6ad51c8eadb18e255e966afa318d7652a176dd01a44819a43989bb1aef
7
+ data.tar.gz: 0310c5b3de97b1388d85eab045c1c21ff8ab5ab7354cc092683e1c383532c93c77ca6cdffd3bc86b0bdcd6d8ae943b26c3eb00616e996f732dd9f658ce892485
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## Unreleased
2
+
3
+ ## [0.4.0] - 2026-10-06
4
+
5
+ - Raised the minimum supported ClickHouse version to 23.3; connections to older servers are rejected during the handshake
6
+ - Updated vendored clickhouse-c headers to `916cbf5`, adding sparse column decoding
7
+ - Improved decimal decoding performance while preserving exact values across all decimal widths
8
+ - Fixed decoding of geometry types, including Point, Ring, Polygon, MultiPolygon, LineString, and MultiLineString
9
+ - Preserved complete UTF-8 server error messages during connection establishment
10
+
1
11
  ## [0.3.1] - 2026-08-19
2
12
 
3
13
  - Fixed LZ4 compression negotiation for ClickHouse users configured with `readonly = 1`
data/README.md CHANGED
@@ -52,7 +52,7 @@ Add the gem to your Gemfile:
52
52
  gem "ch_connect"
53
53
  ```
54
54
 
55
- The gem contains a native extension and currently requires MRI Ruby. LZ4 and ZSTD support are detected when the extension is built.
55
+ The gem contains a native extension and requires MRI Ruby and ClickHouse 23.3 or newer. LZ4 and ZSTD support are detected when the extension is built.
56
56
 
57
57
  ## Configuration
58
58
 
@@ -16,6 +16,7 @@
16
16
  #include <ruby/encoding.h>
17
17
  #include <ruby/intern.h>
18
18
 
19
+ #include <inttypes.h>
19
20
  #include <limits.h>
20
21
  #include <string.h>
21
22
  #include <time.h>
@@ -40,8 +41,6 @@ static VALUE vAF_INET6;
40
41
 
41
42
  static ID id_jd;
42
43
  static ID id_new;
43
- static ID id_pow;
44
- static ID id_div;
45
44
  static ID id_BigDecimal;
46
45
 
47
46
  static VALUE sym_read_rows;
@@ -136,6 +135,39 @@ load_i64le(const uint8_t *p)
136
135
  return v;
137
136
  }
138
137
 
138
+ /* Little-endian two's complement value of 4, 8, 16 or 32 bytes as int64, when it fits. */
139
+ static int
140
+ load_decimal_i64(const uint8_t *p, size_t nbytes, int64_t *out)
141
+ {
142
+ if (nbytes == 4) {
143
+ *out = load_i32le(p);
144
+ return 1;
145
+ }
146
+ int64_t v = load_i64le(p);
147
+ uint8_t sign = v < 0 ? 0xFF : 0x00;
148
+ for (size_t b = 8; b < nbytes; b++) {
149
+ if (p[b] != sign) return 0;
150
+ }
151
+ *out = v;
152
+ return 1;
153
+ }
154
+
155
+ /* "<unscaled>e-<scale>" for a decimal value. BigDecimal parses it exactly,
156
+ * without the intermediate BigDecimals of dividing by 10**scale. Only values
157
+ * outside the int64 range go through a Ruby Integer. */
158
+ static VALUE
159
+ decimal_literal(const uint8_t *p, size_t nbytes, int scale)
160
+ {
161
+ int64_t v;
162
+ if (load_decimal_i64(p, nbytes, &v)) {
163
+ char buf[48];
164
+ int len = snprintf(buf, sizeof(buf), "%" PRId64 "e-%d", v, scale);
165
+ return rb_usascii_str_new(buf, len);
166
+ }
167
+ VALUE unscaled = rb_integer_unpack(p, nbytes, 1, 0, INTEGER_PACK_LITTLE_ENDIAN | INTEGER_PACK_2COMP);
168
+ return rb_str_catf(rb_big2str(unscaled, 10), "e-%d", scale);
169
+ }
170
+
139
171
  static inline float
140
172
  load_f32le(const uint8_t *p)
141
173
  {
@@ -334,23 +366,9 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
334
366
  case CHC_DECIMAL128:
335
367
  case CHC_DECIMAL256: {
336
368
  int scale = chc_type_decimal_scale(t);
337
- VALUE divisor = rb_funcall(INT2FIX(10), id_pow, 1, INT2FIX(scale));
369
+ size_t nbytes = kind == CHC_DECIMAL32 ? 4 : kind == CHC_DECIMAL64 ? 8 : kind == CHC_DECIMAL128 ? 16 : 32;
338
370
  for (long i = 0; i < n_rows; i++) {
339
- VALUE unscaled;
340
- if (kind == CHC_DECIMAL32) {
341
- int32_t v = load_i32le(data + i * 4);
342
- unscaled = INT2NUM(v);
343
- } else if (kind == CHC_DECIMAL64) {
344
- int64_t v = load_i64le(data + i * 8);
345
- unscaled = LL2NUM(v);
346
- } else {
347
- size_t nbytes = (kind == CHC_DECIMAL128) ? 16 : 32;
348
- unscaled = rb_integer_unpack(data + i * nbytes, nbytes, 1, 0,
349
- INTEGER_PACK_LITTLE_ENDIAN |
350
- INTEGER_PACK_2COMP);
351
- }
352
- VALUE bd = rb_funcall(rb_mKernel, id_BigDecimal, 1, unscaled);
353
- rb_ary_push(ary, rb_funcall(bd, id_div, 1, divisor));
371
+ rb_ary_push(ary, rb_funcall(rb_mKernel, id_BigDecimal, 1, decimal_literal(data + i * nbytes, nbytes, scale)));
354
372
  }
355
373
  break;
356
374
  }
@@ -377,9 +395,85 @@ decode_string_column(const chc_column *col, long n_rows)
377
395
  return ary;
378
396
  }
379
397
 
398
+ static void
399
+ raise_invalid_geometry(native_state *state, const char *detail)
400
+ {
401
+ *state = NATIVE_BROKEN;
402
+ rb_raise(eConnectionError, "Invalid native geometry column: %s", detail);
403
+ }
404
+
405
+ /* clickhouse-c exposes geometry aliases as their wire-level Array/Tuple
406
+ * column tree, while the logical geometry type has no synthetic children. */
407
+ static VALUE
408
+ decode_geometry(const chc_column *col, long n_rows, int depth, native_state *state)
409
+ {
410
+ if (n_rows == 0) return rb_ary_new();
411
+
412
+ if (depth == 0) {
413
+ if (chc_column_layout(col) != CHC_COL_TUPLE ||
414
+ chc_column_tuple_arity(col) != 2) {
415
+ raise_invalid_geometry(state, "Point must be a two-coordinate tuple");
416
+ }
417
+
418
+ const chc_column *x_col = chc_column_tuple_child(col, 0);
419
+ const chc_column *y_col = chc_column_tuple_child(col, 1);
420
+ size_t x_size = 0;
421
+ size_t y_size = 0;
422
+ const uint8_t *x = (const uint8_t *)chc_column_fixed_data(x_col, &x_size);
423
+ const uint8_t *y = (const uint8_t *)chc_column_fixed_data(y_col, &y_size);
424
+ if (!x || !y || x_size != 8 || y_size != 8) {
425
+ raise_invalid_geometry(state, "Point coordinates must be Float64 columns");
426
+ }
427
+
428
+ VALUE points = rb_ary_new_capa(n_rows);
429
+ for (long i = 0; i < n_rows; i++) {
430
+ VALUE point = rb_ary_new_capa(2);
431
+ rb_ary_push(point, DBL2NUM(load_f64le(x + i * 8)));
432
+ rb_ary_push(point, DBL2NUM(load_f64le(y + i * 8)));
433
+ rb_ary_push(points, point);
434
+ }
435
+ return points;
436
+ }
437
+
438
+ if (chc_column_layout(col) != CHC_COL_ARRAY) {
439
+ raise_invalid_geometry(state, "expected an Array layer");
440
+ }
441
+
442
+ const uint64_t *offsets = chc_column_array_offsets(col);
443
+ const chc_column *values_col = chc_column_array_values(col);
444
+ long total = (long)chc_column_n_rows(values_col);
445
+ VALUE elements = decode_geometry(values_col, total, depth - 1, state);
446
+ VALUE ary = rb_ary_new_capa(n_rows);
447
+ uint64_t start = 0;
448
+ for (long i = 0; i < n_rows; i++) {
449
+ uint64_t end = offsets[i];
450
+ if (end < start || end > (uint64_t)RARRAY_LEN(elements)) {
451
+ raise_invalid_geometry(state, "Array offset is out of bounds");
452
+ }
453
+ rb_ary_push(ary, rb_ary_subseq(elements, (long)start, (long)(end - start)));
454
+ start = end;
455
+ }
456
+ return ary;
457
+ }
458
+
380
459
  static VALUE
381
460
  decode_column(const chc_column *col, const chc_type *t, long n_rows, native_state *state)
382
461
  {
462
+ switch (chc_type_kind(t)) {
463
+ case CHC_POINT:
464
+ return decode_geometry(col, n_rows, 0, state);
465
+ case CHC_RING:
466
+ case CHC_LINE_STRING:
467
+ return decode_geometry(col, n_rows, 1, state);
468
+ case CHC_POLYGON:
469
+ case CHC_MULTI_LINE_STRING:
470
+ return decode_geometry(col, n_rows, 2, state);
471
+ case CHC_MULTI_POLYGON:
472
+ return decode_geometry(col, n_rows, 3, state);
473
+ default:
474
+ break;
475
+ }
476
+
383
477
  if (n_rows == 0) {
384
478
  chc_kind kind = chc_type_kind(t);
385
479
  if (kind == CHC_STRING) return rb_ary_new();
@@ -638,7 +732,8 @@ native_client_handshake_step(VALUE self)
638
732
 
639
733
  nc->state = NATIVE_ACTIVE;
640
734
  chc_err err = {0};
641
- int rc = chc_async_handshake(nc->ac, &err);
735
+ chc_exception *exception = NULL;
736
+ int rc = chc_async_handshake(nc->ac, &exception, &err);
642
737
  if (rc == CHC_OK) {
643
738
  nc->state = NATIVE_READY;
644
739
  return sym_done;
@@ -646,6 +741,16 @@ native_client_handshake_step(VALUE self)
646
741
  if (rc == CHC_WOULD_BLOCK) return sym_want_read;
647
742
 
648
743
  nc->state = NATIVE_BROKEN;
744
+ if (exception) {
745
+ /* Anchor the server exception before allocating Ruby objects so close
746
+ * or GC can still release it if message construction raises. */
747
+ nc->pending_pkt.kind = CHC_PKT_EXCEPTION;
748
+ nc->pending_pkt.exception = exception;
749
+ VALUE msg = rb_utf8_str_new(exception->display_text,
750
+ (long)exception->display_text_len);
751
+ clear_pending(nc);
752
+ rb_exc_raise(rb_exc_new_str(eConnectionError, msg));
753
+ }
649
754
  rb_raise(eConnectionError, "%s", err.msg);
650
755
  }
651
756
 
@@ -880,7 +985,7 @@ native_client_recv_step(VALUE self)
880
985
  nc->state = NATIVE_READY;
881
986
  return sym_done;
882
987
  }
883
- /* PONG / TOTALS / EXTREMES / LOG / PROFILE_EVENTS — skip. */
988
+ /* PONG / TOTALS / EXTREMES / LOG / PROFILE_EVENTS / TIMEZONE_UPDATE — skip. */
884
989
  }
885
990
  }
886
991
 
@@ -969,8 +1074,6 @@ Init_ch_connect_native(void)
969
1074
 
970
1075
  id_jd = rb_intern("jd");
971
1076
  id_new = rb_intern("new");
972
- id_pow = rb_intern("**");
973
- id_div = rb_intern("/");
974
1077
  id_BigDecimal = rb_intern("BigDecimal");
975
1078
 
976
1079
  sym_read_rows = ID2SYM(rb_intern("read_rows"));
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChConnect
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,9 +1,12 @@
1
1
  # Vendored: clickhouse-c
2
2
 
3
3
  - Source: https://github.com/ClickHouse/clickhouse-c
4
- - Commit: dd47d2882646aeba5a1946c62a229d57abea6c28 (cloned 2026-07-14)
4
+ - Commit: 916cbf5ff41a218978bc073bca10f5b609a00081 (updated 2026-10-05)
5
5
  - License: Apache-2.0 (see LICENSE)
6
6
 
7
+ The native client requires ClickHouse 23.3 or newer and advertises protocol
8
+ revision 54465, including sparse column serialization support.
9
+
7
10
  Only the headers the extension includes are vendored: `clickhouse.h`,
8
11
  `clickhouse-compression.h`, `clickhouse-client.h`, `clickhouse-async.h`
9
12
  (plus LICENSE). Upstream's tests, docs, tools, and the `clickhouse-posix-io.h`
@@ -12,7 +15,7 @@ ioless (Ruby owns the socket and TLS).
12
15
 
13
16
  ## Local patches
14
17
 
15
- - `clickhouse.h` — `chc__col_read`, `case CHC_TUPLE`: ClickHouse serializes the
18
+ - `clickhouse.h` — `chc__col_read_tuple`: ClickHouse serializes the
16
19
  empty `Tuple()` as one UInt8 (zero) per row (ClickHouse/ClickHouse#55061);
17
20
  upstream reads nothing and desyncs the stream. Marked with
18
21
  `LOCAL PATCH (ch_connect)`. Should be upstreamed.
@@ -47,8 +47,15 @@ int chc_async_submit(chc_async_client *c, const void *buf, size_t len,
47
47
  void chc_async_pending_out(chc_async_client *c, const uint8_t **buf, size_t *len);
48
48
  void chc_async_consume_out(chc_async_client *c, size_t n);
49
49
 
50
- /* drive -- never block; CHC_WOULD_BLOCK = submit more / drain out */
51
- int chc_async_handshake(chc_async_client *c, chc_err *err);
50
+ /* Drive calls never block. CHC_WOULD_BLOCK requests more input or output
51
+ * draining
52
+ *
53
+ * Handshake rejection returns CHC_ERR_SERVER and leaves err->msg empty. When
54
+ * exc is not NULL, caller owns *exc and frees it with chc_exception_free.
55
+ * Other results leave *exc unchanged. Query exceptions still arrive as
56
+ * packets, but handshake rejection ends connection setup */
57
+ int chc_async_handshake(chc_async_client *c, chc_exception **exc,
58
+ chc_err *err);
52
59
  int chc_async_send_query(chc_async_client *c, const char *sql, size_t sql_len,
53
60
  const char *query_id, size_t query_id_len, chc_err *err);
54
61
  int chc_async_send_data(chc_async_client *c, const chc_block_builder *bb,
@@ -122,16 +129,7 @@ chc_async_client_init(chc_async_client **out, const chc_client_opts *opts,
122
129
  if (!c) return CHC_ERR_OOM;
123
130
 
124
131
  c->cli.al = al;
125
- c->cli.client_version_major = opts->client_version_major;
126
- c->cli.client_version_minor = opts->client_version_minor;
127
- c->cli.client_version_patch = opts->client_version_patch;
128
- c->cli.client_revision = opts->client_revision ? opts->client_revision
129
- : CHC_CLIENT_DEFAULT_REVISION;
130
- c->cli.compression = opts->codec ? opts->compression : CHC_COMP_NONE;
131
- c->cli.codec = opts->codec;
132
- /* Seed server.revision so block/packet framing is well-defined before the
133
- * handshake completes; recv after handshake uses min(client, server). */
134
- c->cli.server.revision = c->cli.client_revision;
132
+ chc__client_setup(&c->cli, opts);
135
133
 
136
134
  chc__mem_sink_init(&c->out, &c->out_io, al);
137
135
  c->cli.io = &c->out_io;
@@ -196,7 +194,7 @@ chc_async_consume_out(chc_async_client *c, size_t n)
196
194
  }
197
195
 
198
196
  int
199
- chc_async_handshake(chc_async_client *c, chc_err *err)
197
+ chc_async_handshake(chc_async_client *c, chc_exception **exc, chc_err *err)
200
198
  {
201
199
  int rc;
202
200
  chc_client *cli = &c->cli;
@@ -209,7 +207,6 @@ chc_async_handshake(chc_async_client *c, chc_err *err)
209
207
  .client_version_major = cli->client_version_major,
210
208
  .client_version_minor = cli->client_version_minor,
211
209
  .client_version_patch = cli->client_version_patch,
212
- .client_revision = cli->client_revision,
213
210
  .database = c->database,
214
211
  .user = c->user,
215
212
  .password = c->password,
@@ -222,27 +219,23 @@ chc_async_handshake(chc_async_client *c, chc_err *err)
222
219
 
223
220
  case CHC__HS_RECV_HELLO:
224
221
  chc__in_checkpoint(&cli->in);
225
- rc = chc__client_recv_hello(cli, err);
222
+ rc = chc__client_recv_hello(cli, exc, err);
226
223
  if (rc == CHC_WOULD_BLOCK) { chc__in_rewind(&cli->in); return rc; }
227
224
  if (rc != CHC_OK) return rc; /* server exception / protocol */
228
225
  chc_in_reset(&cli->in);
229
- if (cli->server.revision > cli->client_revision)
230
- cli->server.revision = cli->client_revision;
231
226
  c->hs_phase = CHC__HS_POST_HELLO;
232
227
  continue;
233
228
 
234
229
  case CHC__HS_POST_HELLO:
235
- if (cli->server.revision >= CHC__REV_ADDENDUM) {
236
- rc = chc__write_string(cli->io, "", 0, err); /* quota_key */
237
- if (rc != CHC_OK) return rc;
238
- }
230
+ rc = chc__write_string(cli->io, "", 0, err); /* quota_key */
231
+ if (rc != CHC_OK) return rc;
239
232
  rc = chc_client_send_ping(cli, err);
240
233
  if (rc != CHC_OK) return rc;
241
234
  c->hs_phase = CHC__HS_RECV_PONG;
242
235
  continue;
243
236
 
244
237
  case CHC__HS_RECV_PONG:
245
- rc = chc__recv_pong(cli, err); /* owns checkpoint/rewind */
238
+ rc = chc__recv_pong(cli, exc, err); /* owns checkpoint/rewind */
246
239
  if (rc != CHC_OK) return rc;
247
240
  chc_in_reset(&cli->in);
248
241
  c->hs_phase = CHC__HS_DONE;