Skip to content

Added Support for Fetch Modes#4

Open
iliaal wants to merge 116 commits intoSeasX:masterfrom
iliaal:master
Open

Added Support for Fetch Modes#4
iliaal wants to merge 116 commits intoSeasX:masterfrom
iliaal:master

Conversation

@iliaal
Copy link
Copy Markdown

@iliaal iliaal commented Dec 15, 2019

Couple of feature enhancements to simplify data retrieval

  1. Added ability to fetch dates as strings (date as Y-m-d and datetime as Y-m-d H:i:s similar to how it is returned by HTTP client) via DATE_AS_STRINGS mode
  2. Added ability to fetch single value via FETCH_ONE mode
  3. Added ability to retrieve all volumes from single column as an basic array of values via FETCH_COLUMN mode
  4. Added ability to fetch results as an associated array (key-value-pair) via FETCH_KEY_PAIR mode using col1 as index and col2 as value.

Also added SeasClickException class so that exceptions thrown are specific to extension as opposed to using generic Exception class.

@wujunze wujunze requested review from Neeke and aiwhj December 16, 2019 04:32
Copy link
Copy Markdown
Member

@769344359 769344359 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove shutdown callback ?

Comment thread SeasClick.cpp
SEASCLICK_RES_NAME,
SeasClick_functions,
PHP_MINIT(SeasClick),
PHP_MSHUTDOWN(SeasClick),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove shutdown callback?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't do anything, so recommended approach is to not invlude PHP_MSHUT / PHP_MINIT unless they do something

Comment thread SeasClick.cpp Outdated
};

#define REGISTER_SC_CLASS_CONST_LONG(const_name, value) \
zend_declare_class_constant_long(SeasClick_ce, const_name, sizeof(const_name)-1, (zend_long)value);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do some compatibility tests, such as zend_long type does not support PHP 5 version

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment thread SeasClick.cpp Outdated
convertToZval(col2, block[1], row, "", 0, fetch_mode|SC_FETCH_ONE);

if (Z_TYPE_P(col1) == IS_LONG) {
zend_hash_index_update(Z_ARRVAL_P(return_value), Z_LVAL_P(col1), col2);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compatibility

#define zend_hash_index_update(ht, h, pData, nDataSize, pDest) _zend_hash_index_update_or_next_insert(ht, h, pData, nDataSize, pDest, HASH_UPDATE ZEND_FILE_LINE_CC)

This is the definition of PHP 5 version

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@iliaal iliaal requested a review from 769344359 April 6, 2020 12:02
@iliaal iliaal requested a review from aiwhj April 6, 2020 12:47
@guba-odudkin
Copy link
Copy Markdown

@769344359 @Neeke Any chance to get it merged?

@Rock-520
Copy link
Copy Markdown
Member

@769344359 @Neeke Any chance to get it merged?

it will have been merged when i finsh unit tests!

@Rock-520
Copy link
Copy Markdown
Member

Rock-520 commented Mar 7, 2023

image

@guba-odudkin could you please help me to fix the conflicts ? it change a lot of file, i can not merge it .

Resyncs this PR with SeasX/SeasClick master after five years of drift.
Brings in upstream's PHP 8 support, the writeStart/write/writeEnd
block-insert methods, the GCC 10 compile fix, and the package.xml
bumps. Keeps the PR's fetch-mode features (FETCH_ONE, FETCH_KEY_PAIR,
FETCH_COLUMN, DATE_AS_STRINGS), the SeasClickException class, and the
timeout/retry properties.

Conflict resolutions:

- SeasClick.cpp: merged the fetch-mode select() with upstream's write*
  block-insert methods. Standardized arg-info names on SeasClick_*
  (upstream's master shipped a SeasCilck_ typo), and routed every
  catch through sc_zend_throw_exception_tsrmls_cc against
  SeasClickException_ce so PHP 7 and PHP 8 builds both link.
- client.cpp: kept upstream's split InsertData / InsertDataEnd API.
  The PR's PHP-side writeStart/write/writeEnd already calls them in
  that order, so the combined InsertData from the lib upgrade was
  wrong.
- php7_wrapper.h: changed the ZEND_ACC_DTOR fallback from 0x4000 to 0.
  PHP 8 reused bit 14 for ZEND_ACC_VARIADIC, and the old fallback was
  crashing zend_register_functions on extension load.
- nullable.cpp: dropped the duplicate <stdexcept> include the
  auto-merge introduced.

Verified against PHP 7.1, 7.2, 7.4, 8.3 (debug+ASAN), 8.4 (debug+ASAN
and release), and 8.5 (debug+ASAN). Extension loads, all nine methods
and four fetch-mode constants register, and SeasClickException extends
Exception.
@iliaal
Copy link
Copy Markdown
Author

iliaal commented Apr 25, 2026

@Rock-520 Not sure if this repo is still alive, but PR updated and merges cleanly

iliaal added 2 commits April 25, 2026 11:56
The PR's package.xml has declared <min>7.0.0</min> for years and the
upstream code path stopped compiling on PHP 5 several releases ago.
Carrying the dual-PHP scaffolding only obscures the real PHP 7 vs
PHP 8 distinctions, so strip it.

- php7_wrapper.h: deleted the entire PHP_MAJOR_VERSION < 7 branch
  (PHP 5 alternates for hash/zval/exception calls). Removed TSRMLS_CC
  from PHP 7 wrapper bodies (the macro is empty on PHP 7+ and absent
  on PHP 8). Removed the ZEND_ACC_DTOR fallback now that the only
  callsite is gone.
- SeasClick.cpp: collapsed the PHP_VERSION_ID >= 70000 / <= 70000 /
  < 70000 conditionals around class registration, property
  declarations, and write() argument fetching. Stripped TSRMLS_CC
  from every zend_parse_parameters call. Dropped ZEND_ACC_DTOR from
  the __destruct method entry; PHP 7+ recognizes destructors by name.

Net: -155 lines.

Verified against PHP 7.4 (Docker), PHP 8.3 (debug+ASAN), and PHP 8.4
(release). Extension loads, all nine methods and four constants
register, SeasClickException extends Exception.
Replaces the artpaul-fork v1.x vendored library with the official
ClickHouse/clickhouse-cpp v2.6.1. Brings in DateTime64, LowCardinality,
Map, Geo, Time and Time64 column types from upstream, modern
ClientOptions, optional ZSTD compression on the wire, and a current
contrib (cityhash, lz4, abseil-int128, zstd).

PHP-side surface changes:

- DateTime64(N) now accepts epoch-seconds (int) or "Y-m-d H:i:s"
  strings on insert, and reads back either as int or as "Y-m-d
  H:i:s.ffffff" when DATE_AS_STRINGS is set.
- LowCardinality(String) and LowCardinality(FixedString(N)) round-trip
  through the dictionary column. Read returns plain strings.
- Map(K, V) columns can be created via factory; per-row read/write
  through the new ColumnMapT API is left as TODO.

Library / build mechanics:

- Bumped to C++17 (clickhouse-cpp v2 requires it).
- Vendored contrib/zstd in full (compress, decompress, common). zstd
  is a hard dep of the new compressed.cpp -- there is no compile-time
  gate. Built with -DZSTD_DISABLE_ASM since we don't compile the .S
  optimization file.
- Reworked config.m4 source list (~80 sources now) and added include
  paths for the new contrib trees.
- writeStart/write/writeEnd were rewired from the v1.x
  InsertQuery/InsertData/InsertDataEnd API to v2.x
  BeginInsert/SendInsertBlock/EndInsert.
- ClientOptions field renames: SetSocketReceiveTimeout ->
  SetConnectionRecvTimeout, SetSocketConnectTimeout ->
  SetConnectionConnectTimeout.
- Fixed two ZPP / arg-info mismatches PHP 8.3 caught: select() and
  execute() now declare their first argument as the only required
  one. The previous values were inherited from the original PR and
  worked on PHP 8.4 by accident.

Build artifacts now ignored via .gitignore (Makefile, configure,
config.*, autom4te.cache/, build/, modules/, *.dep, *.loT, etc.).

Verified end-to-end against ClickHouse 24.8.14 (docker
clickhouse/clickhouse-server) on PHP 7.4 (docker), PHP 8.3
(debug+ASAN), and PHP 8.4 (release): connect, ping, execute, insert,
select with FETCH_ONE / FETCH_COLUMN / FETCH_KEY_PAIR / DATE_AS_STRINGS,
writeStart/write/writeEnd block insert, DateTime64(3)/(6) round-trip,
LowCardinality(String) round-trip with correct cardinality.

PHP 8.3 ASAN reports indirect leaks (~17KB) inside Client::Impl
construction (NonSecureSocketFactory and friends) that are vendored-
library-internal and only surface at process exit. Functional path is
clean -- no SEGV, no use-after-free.

README updated: dependency list now reflects v2.x types, install
section mentions the C++17 / vendored-contrib model, and added a
"Testing against a local ClickHouse server" section pointing at the
clickhouse/clickhouse-server image with a one-liner docker run.
iliaal added 30 commits April 30, 2026 13:14
Second pass of code-review follow-ups, surfaced by parallel Claude
specialists plus a Codex review on the post-be355ca state. Critical-
tier items here are reachable bugs (NULL-deref / leak / state wedge),
not just hardening.

- insert() iterated $columns by zend_hash_index_find(0..N-1) and
  dereferenced the result without a NULL check. A sparse or assoc
  $columns literal segfaulted the worker. Now we materialize a
  vector<zend_string*> in HashTable order, validating every entry
  is a string (CR-201).
- Map decoder lambdas (decodeKey / value switch / map-type metadata)
  carried ~24 unchecked As<ColumnFoo>()->At(i) chains. A server-side
  schema mismatch on the inner ColumnTuple read off null. Routed
  through a new as_or_throw<TCol>(ref, what) helper that throws a
  contextual error on the cast (CR-202, CR-203).
- do_select_into OnData lambda allocated col1 / col2 / row_tmp via
  SC_MAKE_STD_ZVAL + array_init and had no try/catch around the
  per-row body. A throw mid-row leaked the partially-built zvals;
  now the body is wrapped and the partial state dtor'd on rethrow
  (CR-204). While here, hoist GetColumnName out of the row loop
  into a per-block vector (CR-029, perf).
- Tuple insert allocated return_should + per-field return_tmp, and
  recursive insertColumn could throw before the cleanup at the end
  of the case ran. Wrapped in try/catch with explicit dtor on
  rethrow; ownership is transferred via ZVAL_UNDEF after each
  add_next_index_zval so the catch handler doesn't double-free
  (CR-205).
- obj->has_insert_block was set true on writeStart success but
  never cleared when SendInsertBlock / EndInsert / zvalToBlock
  threw. The underlying clickhouse-cpp inserting_ flag stayed
  true and every subsequent call wedged. write() / writeEnd()
  catches now reset has_insert_block; insert() single-shot calls
  best-effort EndInsert in its catch so the wire closes; and
  resetConnection() also drops local insert state (CR-206).
- Seconds-based timeouts (retry_timeout, retry_count, connect_/
  receive_/send_timeout, tcp_keepalive_idle/intvl) accepted
  negative values and silently wrapped through unsigned setters
  inside ClientOptions. Each now rejects n < 0 up front (CR-207).
- Integer insert helpers narrowed silently from zend_long to int8/
  int16/UInt8/UInt16/UInt32. appendIntColumn / appendUIntColumnWithHex
  now take Min/Max bounds and reject out-of-range values, and the
  hex fast path validates strtoull/strtoul end-pointer + ERANGE
  rather than dropping garbage suffixes (CR-208).
- Map numeric-key parsers (strtoll / strtoull / strtod) ran without
  end-pointer or ERANGE checks, so "abc" silently became 0 and
  "12x" silently became 12. Each now rejects unconsumed input or
  range overflow (CR-209).
- IPv4 / IPv6 reads silently emitted the empty string when
  As<ColumnIPv4/6>() returned null. Routed through as_or_throw to
  match every other type in the switch (CR-210).
- Eight zend_throw_exception sites still bypassed the project
  wrapper sc_zend_throw_exception_tsrmls_cc. Mechanical sweep
  (CR-211).
- typesToPhp.cpp insert helpers still mutated caller zvals in
  place via convert_to_long / convert_to_string / convert_to_double.
  Replaced ~25 sites with non-mutating zval_get_long / zval_get_double
  and a new ZStrGuard RAII helper (typesToPhp.hpp) that owns the
  zend_string from zval_get_string and releases it on scope exit
  (CR-212, CR-109).
- getInsertSql was a void-returning out-param helper that built the
  column list via std::stringstream and took table_name as raw
  char*. Rewritten as `std::string getInsertSql(string_view, zval*)`
  using `s.reserve()` + `+=`, no stream allocation. The new column
  loop uses ZStrGuard so a column-name validation throw can't leak
  the coerced zend_string (CR-213).
- TypedParam carried a separate is_null flag that attachTypedParams
  branched on. Folded into std::optional<std::string> value so the
  attach loop is q.SetParam(name, value) with no branch (CR-214).
- Various medium-tier hardening: to_time_t throws on parse failure
  instead of silently returning -1; new to_time_t_with_frac
  preserves sub-second precision in DateTime64 string inserts;
  emitEpoch no longer NULLs out 1970-01-01 in DATE_AS_STRINGS mode;
  appendMapColumn reuses its entries vector across rows; query_log
  capped at 1024 entries (drop oldest); sanitizeError truncation
  reserves space for the suffix; FixedString(N) parsing uses a new
  parseFixedStringWidth helper that validates the prefix instead
  of erase(npos, 12) UB; setProgressCallback / setProfileCallback
  share setCallbackField; emitVerbose clears EG(exception) after
  php_json_encode so a non-UTF8 payload doesn't bleed onto the
  next API call.

Tests: tests/059 (sparse / assoc / non-string $columns), tests/060
(integer narrow-type range checks), tests/061 (negative timeout
rejection), tests/062 (Map numeric-key strict parse). All 61 of 62
pass (1 expected TLS skip), three deterministic re-runs clean.
CR-014 plus the medium-tier consolidations from the latest review.

FAST_ZPP migration (CR-014). All 24 zend_parse_parameters call sites
converted to ZEND_PARSE_PARAMETERS_START / Z_PARAM_* / END blocks.
PHP 7.4 stays supported via three compatibility shims in
php7_wrapper.h: Z_PARAM_STR_OR_NULL / Z_PARAM_ARRAY_OR_NULL /
Z_PARAM_ZVAL_OR_NULL fall through to the older Z_PARAM_*_EX(dest, 1, 0)
form on pre-8.0. The format-string→param-macro mapping is mechanical;
optional ranges (e.g. "S |zlSa" → START(1, 5) + OPTIONAL marker) match
the pre-existing semantics.

Helpers introduced. makeQid(zend_string*) replaces the 8-site
`std::string qid = (q && ZSTR_LEN(q) > 0) ? ... : std::string();`
boilerplate. buildQueryLog(obj, sql, qid) factors the shared field-
assignment body of recordQuerySuccess / recordQueryError.
statement_emit_rows(return_value, this) replaces the duplicated body
of ClickHouseStatement::jsonSerialize and ::toArray. setCallbackField
already centralized progress / profile setters; left untouched here.

Insert-path templates. appendEnumColumn<TCol, TInt> collapses the
two near-identical Enum8 / Enum16 ZEND_HASH_FOREACH blocks.
appendDateColumn<TCol> templates Date / Date32 / DateTime over the
shared "string-with-dash → to_time_t else zval_get_long" pattern.
appendLowCardinalityColumn<TCol, nullable> consolidates the four
LowCardinality(String) / Nullable(String) / FixedString / Nullable
(FixedString) blocks via a compile-time `nullable` flag.

128-bit parser dedup. parse_uint128_dec(s, len, label) shared by the
Int128 (with sign-handling at call site) and UInt128 string-insert
paths; eliminates ~30 lines of duplicated overflow-checked digit loop.

Decimal scale formatting. Replaced the per-cell two-std::string
allocate-and-mutate path with in-place stack-buffer manipulation
(memmove for the leading-zero pad and the dot insertion). Worst-case
buffer is 64 bytes; previous worst case was sign + 39 digits = 41,
plus the heap allocs.

Constructor option helpers. The three `*_timeout_ms` blocks
(connect / receive / send) now share an apply_timeout_ms lambda
parameterized on the ClientOptions setter pointer-to-member; the
five SSL TLS-version branches collapse into a small const lookup
table. Same input validation, less repetition.

Cosmetic / hardening. Stale `using namespace`-era cast pattern
`(char*)X.data()` removed at 19 sites — the Zend API has accepted
const char* since 7.0 so the const-cast was noise. `(long)X` casts
on int64_t Date/Time read paths replaced with `(zend_long)X` so
narrow-long Windows LLP64 builds don't truncate. typeNeedsQuoting
replaced strlen-on-each-literal with a constexpr name+len table.
attachVerbose comment updated to match the actual implementation
(it attaches OnException only; data_block events are emitted from
the do_select_into / selectStreamCallback OnData closures).
applyPlaceholders doc-comment refreshed to reflect the post-CR-016
whitelist (no more *, (, ), +). Unused #include <map> dropped.

Tests: 61/62 pass, 1 expected TLS skip, 0 failures across three
deterministic re-runs.
insertAssoc, runHelperSelect, runHelperExec, and (transitively)
runHelperSelectFirstRow used to forward to insert / select / execute
through call_user_function on every call. That added a full PHP
method-dispatch frame (symbol-table lookup, ZPP re-parse, args
zval marshalling, retval handling) per helper invocation and exposed
the helpers to user-defined subclass overrides of insert/select/execute.

Extract do_insert_into and do_execute_into next to the existing
do_select_into. Each takes the parsed args directly, runs the same
work the PHP_METHOD did, and on error throws a PHP exception via
throwClickHouseError. The PHP_METHOD bodies become a thin ZPP +
helper-call + RETURN_TRUE shell.

runHelperSelect / runHelperExec now route straight to the do_*_into
helpers with empty params/qid/settings; insertAssoc passes its
just-built columns_zv and values_zv straight in instead of
re-marshalling them as zval-args for call_user_function.

Net: every SQL-helper one-liner (databaseSize, tablesSize, partitions,
showTables, showCreateTable, isExists, getServerVersion, getServerUptime,
truncateTable, dropPartition, ...) and insertAssoc save one PHP
method-dispatch frame per call, and stop honouring user subclass
overrides for those internal queries (which they shouldn't have
been anyway).

Tests: 61/62 pass, 1 expected TLS skip, 0 failures across 3
deterministic re-runs.
Two carry-over inconsistencies from the residual list.

setSettings used to silently skip numeric keys and silently store
empty-string keys, while setSetting (singular) explicitly rejected
the empty-string case. So `$c->setSetting("", "x")` threw but
`$c->setSettings(["" => "x"])` quietly stored an unusable empty-key
setting that the server later complained about, and `$c->setSettings(
[0 => "x"])` silently dropped the entry. setSettings now applies the
same validation as setSetting (numeric keys rejected, empty strings
rejected) and validates into a temporary so a malformed entry doesn't
leave the caller's settings half-replaced. Added tests/063 to pin
both directions of the contract.

setVerbose used to throw "expects bool or callable" on null, while
setProgressCallback / setProfileCallback (the other ?callable setters)
accepted null as the obvious "remove the sink" idiom. setVerbose(null)
now accepts null as a synonym for false, the error string is updated
to "expects bool, null, or callable", and tests/056 grows a
regression case for the null-disable path.

setSetting also routed through throwClickHouseError(std::runtime_error)
on the empty-key path, where every other validation throw goes through
sc_zend_throw_exception_tsrmls_cc directly. Aligned for consistency.

Tests: 62/63 pass, 1 expected TLS skip, 0 failures across 3
deterministic re-runs.
… fix

Five issues from the third-pass review.

CR-301: stack-overflow on adversarial server schema during INSERT.
The read path has had ConvertDepthGuard (cap 32) capping recursion
through nested types since pass 1, so a server schema like
Tuple(Tuple(Tuple(...))) couldn't blow the worker stack on SELECT.
The write path was missing the same guard — createColumn /
insertColumn recurse through Array / Nullable / Tuple / Map / Low
Cardinality, and the type tree comes from client->BeginInsert's
server response. A compromised or MITM'd ClickHouse server could
push back an arbitrarily-deep type and segfault the worker during
INSERT preparation. Moved the guard struct above all three entry
points and added the RAII guard to createColumn and insertColumn.
The thread-local depth counter is shared with convertToZval, which
is correct: the cap is per-call-stack depth, not per-direction.

CR-302: leak in selectStreamCallback's OnData on convertToZval throw.
do_select_into got a try/catch around its per-row body in pass 2 to
release the partially-built row_zv when convertToZval throws (Map
type mismatch, depth-cap, schema downcast). The streaming-callback
sibling never got the same treatment. Mirrored the pattern: try
/catch the column loop, zval_ptr_dtor on rethrow.

CR-303: locale-dependent double serialization in formatScalarParam.
The IS_DOUBLE branch used snprintf("%.17g") which honors LC_NUMERIC.
A PHP user calling setlocale(LC_NUMERIC, 'de_DE') would emit "1,5"
on the wire; the ClickHouse server then rejected the typed param
or setting value as malformed. php_gcvt is locale-independent and
takes the decimal-point char explicitly — exactly the contract we
need at the SQL boundary. Available on PHP 7.4+ so it doesn't
constrain the build matrix.

CR-304: column-name allocation per row in selectStreamCallback OnData
and ClickHouseRowIterator::current. do_select_into hoisted these in
pass 2 (CR-029) but the streaming sibling and the foreach iterator
both still called block.GetColumnName(col) per cell. clickhouse-cpp
returns a fresh std::string per call. selectStreamCallback now
hoists into a per-block vector before the row loop. The row
iterator caches names on the iter object once, populated by the
first non-empty OnData block (schemas are stable across all blocks
in a result), and reused for every current() call.

CR-305: emitVerbose user-callable branch swallowed EG(exception).
The progress and profile callback handlers re-raise on EG(exception)
to abort the packet loop (pass 2). The emitVerbose stderr branch
clears EG(exception) (pass 2). The user-callable branch did
neither — a throwing verbose closure left the exception buffered,
the surrounding query reported success, and the user's exception
surfaced from the next unrelated query. Re-raise to match the
progress / profile pattern; payload / ctx are dtor'd before the
throw so cleanup ordering matches the success path.

CR-306: Map key strict-end check accepted embedded NUL bytes. Pass 2
added trailing-junk rejection via `endp == s || (endp && *endp != '\0')`.
zend_string is length-prefixed; "123\x00garbage" parses as 123 because
endp lands on the NUL and *endp == '\0' passes. Replaced with
`(size_t)(endp - s) != ZSTR_LEN(zk)` so the consumed-byte count must
match the stored length exactly. Same fix applied to i64 / u64 / f64
key parsers. Error messages now also build their reflected-key with
ZSTR_LEN-bounded std::string instead of `+ s` (which would truncate
at the NUL too).

Tests: tests/064 covers locale-safe Float64 round-trip (skipif
de_DE locale not installed). tests/065 covers the write-side
depth guard. 63 pass / 65 total (1 expected TLS skip, 1 conditional
locale skip), 0 failures, 3 deterministic re-runs.
Six items from the third-pass review. Performance, cleanliness,
one refcount-leak fix.

CR-307: do_insert_into and PHP_METHOD(ClickHouse, write) carried
two near-identical row-major-to-column-major transpose loops with
manual zval lifecycle (~30 lines each, plus mirrored catch-block
cleanup). Both now route through buildColumnMajorRows, which owns
the partial-build dtor on throw. The insert variant takes the
column-name list (associative-row fallback), write passes NULL
(positional only). Net: ~50 lines deleted, identical behavior.

CR-308: query_log used std::vector with erase(begin()) when the
ring hit its cap, which is O(n) on every overflow. Switched to
std::deque + pop_front. Same iteration semantics for the public
getLogQueries() copy-out, constant-time eviction.

CR-309: Map read decoder ran a dynamic_pointer_cast (As<T>())
inside the per-entry loop for both key and value columns. For a
Map(Int64, String) cell with 100 entries that was 200 casts per
row, 200M casts on a million-row scan. Hoisted twelve typed
shared_ptrs (one per supported scalar code) above the loop and
populated exactly the one matching key_code / value_code. The
entry-loop switches now read straight off the cached pointers.

CR-311: __construct still pulled host / database / user / passwd /
ssl_min_protocol_version / ssl_ca_directory / inner ssl_ca_files /
endpoints walker host_s through the manual zval_get_string +
release pattern. Replaced with the ZStrGuard RAII wrapper introduced
in Round 2 so an early throw can't leak the coerced zend_string.

CR-312: fetchKeyPair (FETCH_KEY_PAIR helper) coerced the key zval
via ZVAL_COPY + convert_to_string. ZVAL_COPY bumps the refcount on
strings/objects; convert_to_string does its own coerce-and-replace.
The bumped ref was never released, so every key-pair row leaked
one zend_string. Switched to zval_get_string + EG(exception) check
+ zend_string_release.

CR-313: insertColumn UUID case open-coded the same parsing the
read-side phpToUUID helper already does (~30 lines of strncmp /
hex-decode / brace-stripping). Replaced with a one-line
value->Append(phpToUUID(array_value)) call. The helper throws on
malformed input; the existing surrounding try/catch already maps
that to the PHP boundary.

CR-310 (Type::As<> defensive null checks across read paths) skipped
on cost/benefit: as_or_throw is already pinned at every read-side
downcast and the diagnostic value of localizing the throw doesn't
justify the line churn.

Tests: 63/65 pass, 2 skips (TLS env + de_DE locale), 0 failures
across deterministic re-runs.
The MINIT path links php_json_serializable_ce when registering
ClickHouseStatement (clickhouse.cpp:395), and emitVerbose's stderr
branch calls php_json_encode (clickhouse.cpp:1160). Without ext/json
loaded first, dlopen fails with "undefined symbol:
php_json_serializable_ce" before the user even reaches __construct.

In practice json is always present on supported PHP versions:
PHP 8.0+ makes ext/json mandatory and unremovable, and PHP 7.4 bundles
it on by default. The dependency is implicit but undeclared. A custom
7.4 build with --disable-json (some bench / minimal images do this)
hits the cryptic dlopen error.

PHP_ADD_EXTENSION_DEP / ADD_EXTENSION_DEP wires json before clickhouse
in MINIT order so the symbol resolves and any future load-order
diagnostic gets a clearer message. composer.json gains "ext-json": "*"
so PIE / Composer can fail at install time on a build without it,
rather than at first dlopen.

Tests: 63/65 pass, 2 expected skips, 0 failures on PHP 8.4.
Seven items from the fourth-pass review. One Critical (worker crash
on bad input), three Important (silent data corruption / dropped
exception), one Medium (locale safety), two Minor (unused includes).

CR-501 (Critical): every PHP_METHOD whose stub declared an `array`
parameter used Z_PARAM_ZVAL internally, which accepts any zval type.
PHP's stub-type declarations are only enforced when the C body uses
the matching ZPP macro; with Z_PARAM_ZVAL the engine lets the bad
value through and Z_ARRVAL_P on a non-array dereferences a misaligned
HashTable pointer. Reproduced via ASan: `new ClickHouse("string")` →
SEGV reading 0x32 inside _zend_is_inconsistent. Switched to
Z_PARAM_ARRAY at every site (constructor connectParams, insert
columns/values, insertAssoc rows, write values, writeStart columns,
select/execute/selectStatement params, all settings args). Bad input
now surfaces as a clean TypeError before the C body runs. Tests/066
covers the full surface.

CR-502 (Important): selectStreamCallback's OnData was the only
call_user_function site that didn't re-raise on EG(exception). The
other three (progress, profile, verbose) all check and throw a
sentinel std::runtime_error so the surrounding catch can convert and
recordQueryError fires. Without the check, a row callback that threw
on row 1 of 1M kept the stream consuming all remaining rows AND
called recordQuerySuccess, with the user's PHP exception buffered the
whole time. Mirror the existing pattern. Tests/067 pins the abort.

CR-503 (Important): Map insert dispatch (appendMapByValueType + the
key-side switch in case Type::Code::Map) used a single i64Val/u64Val
extractor for every narrow column width. PHP value 1000 inserted into
Map(K, Int8) silently truncated to int8_t -24 inside ColumnInt8::
Append. The non-Map insert path has had per-width bounds since pass 1
via appendIntColumn; the Map path was missed. Added narrow extractors
(Int8/16/32, UInt8/16/32) on both key and value sides; Int64/UInt64
unchanged. Tests/068 covers both directions.

CR-504 (Important): Int128 string insert called parse_uint128_dec
(which accepts up to 2^128-1) and then static_cast<Int128>(mag),
silently wrapping magnitudes in (2^127, 2^128-1] to negative.
UInt128's reciprocal path is correct because uint128 is its native
range. Bound the magnitude per-sign before casting; INT128_MIN gets
a special branch because -INT128_MIN is undefined behavior on the
negation. Tests/069 walks the four boundary values plus one
out-of-range case per direction.

CR-507 (Medium): Map(Float, *) read decoder formatted Float keys via
snprintf("%.17g", k), which honors LC_NUMERIC. Under
setlocale(LC_NUMERIC, 'de_DE') the same Float64 cell materializes
under PHP key "1,5" instead of "1.5", so the array shape changes
between locales for identical server data. Same fix CR-303 applied
at the SQL boundary: php_gcvt with explicit '.'. Factored a single
fmtFloatKey lambda so the three call sites share one comment. Tests/
070 pins the locale-independence (skips when de_DE is not installed).

CR-510 (Minor): clickhouse.cpp dropped <sstream> (the only reference
was a historical comment about the prior version of getInsertSql).

CR-511 (Minor): typesToPhp.cpp dropped <map> (no std::map<> uses
remained after Round 5).

Tests: 67/70 pass, 3 skips (TLS env + de_DE locale × 2), 0 failures
across three deterministic re-runs.
…etry

CR-508: appendUIntColumnWithHex's full-consumption check used
`*endp != '\0'`. PHP zend_string is length-prefixed and may carry
embedded NUL bytes, so "0xABCD\0garbage" slipped through: strtoul
stops at the NUL, endp points to the NUL, *endp == '\0' passes, and
the trailing garbage is silently dropped. Same NUL-byte trap CR-306
fixed for Map keys. Replaced with a (size_t)(endp - s) != ZSTR_LEN
length-exact comparison. Tests/071 pins the embedded-NUL and
trailing-junk rejection plus a round-trip on well-formed hex.

CR-512: do_select_into's FETCH_KEY_PAIR path used convert_to_string
on the key zval while the post-CR-312 fetchKeyPair path used the
modern non-mutating zval_get_string + zend_string_release pair.
Both worked; aligning the two key-pair sites keeps the codebase
consistent and removes one of the few remaining convert_to_string
callsites.

Tests: 68/71 pass, 3 skips (TLS env + de_DE locale × 2), 0 failures
across three deterministic re-runs.
The ASAN job started failing on every test that exercises LZ4
compression. The trigger is in lz4.c's LZ4_compress_fast_extState:
`dictionary + dictSize` evaluates as `NULL + 0` when no compression
dictionary is set, which UBSan flags as "applying zero offset to
null pointer". Technically UB per C; harmless in practice; a known
LZ4 + UBSan false positive every integration silences.

Add `pointer-overflow` to the existing `function,vptr` skip list on
the extension's CFLAGS/CXXFLAGS. The other UBSan checks remain on.
PHP's own ASAN build doesn't disable pointer-overflow because LZ4
isn't in PHP core; this change is scoped to the extension build only.
…e parsers, zstd dispatch

Five findings from the fifth-pass review (scan.md, 2026-04-30). One
worker-crash Critical, four data-integrity / security Importants.

CR-001 (Critical): same-client reentry inside a row / progress / profile
/ verbose callback crashes the worker. clickhouse-cpp's Client owns a
single TCP socket and a single per-call packet loop; a userland callback
that fires another query / insert on the same client mid-stream pushes
packets onto a wire still owned by the outer call, and the next
ReceiveData walks invalidated state and SEGVs (repro'd via ASan). Added
a `query_active` flag on clickhouse_object and a QueryActiveGuard RAII
wrapper applied at every client-touching entry point: do_select_into,
do_execute_into, do_insert_into, ping, writeStart, write, writeEnd,
selectStreamCallback, selectStream, resetConnection, setDatabase. A
separate ClickHouse instance from inside a callback continues to work.

CR-002 (Important): client-side `{name}` placeholder allowed `-`, so a
value like "tbl --" turned into a SQL line comment that masked the
trailing predicate (`SELECT count() FROM {tbl} WHERE tenant = 1` →
`SELECT count() FROM tbl -- WHERE tenant = 1`, predicate dropped,
baseline=1 became placeholder=2). Drop `-` from the whitelist; the
remaining alphabet (letters / digits / `_` / `.` / `,` / whitespace)
keeps every legitimate identifier-list and numeric-substitution use
working without permitting comment markers. tests/058 updated to assert
both single-dash and `--` are now REJECTED; tests/073 pins the
SQL-comment injection case end-to-end.

CR-003 (Important): integer / unsigned / float insert columns went
through zval_get_long / zval_get_double, which silently coerce "abc"
→ 0, [] → 1, NaN / Inf doubles into Int*, and fractional doubles into
integers. Hex literals went through strtoul on 64-bit, which returns
64-bit values regardless of the destination column, so "0x100000000"
silently truncated to UInt32 0. New strict_zval_long / strict_zval_double
helpers reject non-numeric strings (full string consumption required),
empty strings, NaN / Inf for any column type, and fractional doubles for
integer columns. Hex path width-checks against MaxV (UINT32_MAX /
UINT64_MAX) before append. Mirrors the strict-parser pattern already
applied to Map keys (CR-306) and hex literals (CR-508).

CR-004 (Important): std::get_time stops at the first non-matching
character without raising failbit, so "2024-01-01abc" parsed as
2024-01-01. timegm normalizes invalid dates silently, so "2024-02-30"
became 2024-03-01. The DateTime64 fractional path stopped reading after
the precision digits without rejecting trailing junk. Now: peek() must
hit EOF after the format, parsed components are round-tripped through
gmtime and compared (catches Feb 30 → Mar 1 normalization), and the
fractional path errors on any non-digit character after the digits.

CR-005 (Important): `protected bool $compression` in the stub coerced
the long write of 2 (zstd) to true, then the read-back `Z_LVAL_P` got
1, dispatching to LZ4 in __construct's `cv == 2` branch. ZSTD never
engaged despite the public surface accepting "zstd". Stub changed to
`int $compression = 0` (0=none, 1=lz4, 2=zstd); arginfo regenerated.
The integer round-trips verbatim and the ZSTD branch is reachable.

Tests: 73/76 pass, 3 expected skips (TLS env + de_DE locale × 2),
0 failures across two deterministic re-runs. New tests 072 (CR-001
reentry), 073 (CR-002 SQL comment), 074 (CR-003 numeric coercion), 075
(CR-004 date parsers), 076 (CR-005 zstd dispatch). tests/058 updated to
assert the new dash rejection.

Mediums from the same review (CR-006 UInt64 truncation, CR-007
insertAssoc extra keys, CR-008 transpose memory pressure) deferred.

scan.md retained at repo root as the source-of-truth review document
for this round.
… / Int128 / geo / date

Five findings from the post-Round-8 follow-up review (scan.md
2026-04-30, FR-001..FR-006). Three Importants tighten the same
classes of bug Round 8 closed at the top-level scalar surface but
left in adjacent paths; two Mediums close a silent-data-drop in
insertAssoc and a small ordering issue in write().

FR-001 (Important): the {name} placeholder validator dropped `-` in
Round 8, but whitespace + arbitrary letters were still permitted, so a
value like "test.a ANY INNER JOIN test.secret USING tenant" landed
verbatim and reshaped the FROM clause (`baseline=2 joined=1` repro
from scan.md). The flat character whitelist is now a structural
parser: each comma-separated token is either a numeric literal
(optional sign, digits, optional fractional and exponent) or an
identifier (`[A-Za-z_][A-Za-z0-9_]*`, optionally db-qualified with
one dot). Whitespace is permitted only around commas; internal
whitespace inside a token is rejected. Pre-existing tests/058 already
covers the `-` and `--` rejection; tests/077 pins the join-smuggle
case plus the surrounding internal-whitespace, leading/trailing
separator, and malformed-numeric cases.

FR-002 (Important): CR-003's strict_zval_long / strict_zval_double
helpers were applied to top-level scalar Int*/UInt*/Float* columns but
not to Map values, non-string Int128 / UInt128 cells, or geo Point
coordinates. Same data-corruption class: "abc" silently coerced to 0
through Map(K, Int64), `[1,2,3]` landed as Int128 1, and `["abc","def"]`
landed as Point(0, 0). All three sites now route through the strict
helpers. tests/078 covers the matrix.

FR-003 (Important): the Date / DateTime insert path only validated
strings that contained `-`; dashless strings ("abc", "1234567") fell
through zval_get_long and landed as the epoch. DateTime64 had the same
gate. Time / Time64 had no string handling at all and coerced any
string through zval_get_long to 0 (midnight). Now: any string hits the
strict to_time_t / to_time_t_with_frac path which fully validates
(EOF after format, gmtime round-trip, no trailing fractional garbage).
Time and Time64 reject string inputs outright until a proper
"HH:MM:SS[.frac]" parser is added; numeric inputs continue to work.
tests/079 pins the rejection across all five temporal types.

FR-005 (Medium): insertAssoc derived col_order from the first row but
only checked for missing keys on later rows. An extra key silently
dropped its value; the method's documented "all rows must share the
same key set" contract was the runtime's responsibility to enforce,
not honoured. Per-row element count is now compared to col_order.size()
and any drift throws cleanly. Missing-key detection (the existing
sc_zend_hash_find loop) covers the rename case as a side-effect of
the count check. tests/080 covers extra / missing / renamed keys.

FR-006 (Medium): write()'s QueryActiveGuard was acquired after
buildColumnMajorRows, so a reentrant write() spent CPU and memory
transposing the user's input before the guard rejected it. Moved the
guard to fail fast, matching the insert() ordering. The has_insert_block
check rides along since it was implicitly attached to the prior guard
position.

tests/058 updated: the validator's error message changed from
"unsafe character" to "Placeholder value for {x} is invalid: ...";
the test's substring match needed to follow.

FR-004 (Medium, UInt64 > ZEND_LONG_MAX reads as negative) deferred —
needs a deliberate API call (opt-in UINT64_AS_STRING fetch_mode vs.
silent type change). The other three Mediums from the previous review
(CR-006/007/008) are now: 006 still open per FR-004, 007 closed by
this commit, 008 still open (transpose memory pressure).

Tests: 77/80 pass, 3 expected skips (TLS env + de_DE locale × 2),
0 failures across two deterministic re-runs. Four new regression
tests (077-080).
…l strict

Three Importants from the post-Round-9 follow-up review (scan.md
2026-04-30 update). One Medium (PHP 7.4 reflection ABI) caught by CI.

Round-9 placeholder validator accepted comma-separated lists in a
single-string {name}, which let `{tbl}` with value "a, b" turn
`FROM {tbl}` into a cross join over a and b (scan.md repro:
baseline=2 comma=6). Two-shape API now: string = exactly one
identifier or numeric literal; arrays = explicit identifier list,
elements joined with ", ". Each array element is validated as a
single token, so "a, b" inside an element is also rejected. Tests
002/003/004/005/006/007/008/010 updated to pass arrays for column-
list placeholders; 058 / 077 updated to assert the new
single-token-only contract for strings; 081 covers the array-shape
behavior end-to-end.

CR-002: strict_zval_long / strict_zval_double silently mapped IS_NULL
to 0 / 0.0, so a userland `null` landed as 0 / epoch / midnight in
non-Nullable Int*/UInt*/Float*/Date/DateTime/Time/Map columns. Now
the helpers reject IS_NULL by default; the Nullable insert path
bumps a thread-local AllowNullGuard so its recursive child build
accepts NULL → typed-zero placeholder while the null mask captures
the actual NULL. tests/082 covers both directions across six column
types.

CR-003: to_time_t_with_frac silently dropped any fractional suffix
when precision==0 ("00:00:00.garbage" became 00:00:00 cleanly),
accepted a bare dot at any precision ("00:00:00." parsed as
00:00:00.000), and didn't validate that the first character after
the dot was a digit. All three combinations now throw with a
contextual message. tests/083 walks the four boundary cases plus
two regular trailing-garbage variants.

CI fix (PHP 7.4 only): tests/076 dereferenced a `protected` property
via Reflection without `setAccessible(true)`. PHP 8.1+ implicitly
bypasses the visibility check on Reflection accessors; PHP 7.4
enforces it and threw `ReflectionException: Cannot access non-public
member`. Added the explicit setAccessible call.

Tests: 80/83 pass, 3 expected skips (TLS env + de_DE locale × 2),
0 failures across deterministic re-runs. Three new regression tests
(081-083); ten existing tests migrated to the new array-placeholder
shape.

Mediums still open: CR-004 UInt64 readback (needs API call:
opt-in UINT64_AS_STRING fetch_mode), CR-005 transpose memory
pressure (architecture change). CR-006 stale comment update folded
into this commit's placeholder rewrite.
…on, doc refresh

One Important from the Round-10 follow-up review (scan.md
2026-04-30 update). One CI regression on PHP 8.5. One Minor from
the same review.

CR-001 (Important): appendEnumColumn appended integer cells through
the unchecked numeric overload of ColumnEnum*::Append, so values like
0, 3, or 127 silently landed inside an Enum8('One'=1,'Two'=2)
column. The read path then threw `map::at` when it tried to look up
the name for the stored numeric — data-corruption on insert plus a
permanent read failure for the affected rows. NULL took the same path
as integer 0. Now: integer cells validate against the type's declared
value set via EnumType::HasEnumValue and the int16_t-narrowing check;
unknown integers throw with the original value in the message. NULL
is rejected on non-Nullable Enum* but accepted under AllowNullGuard
when wrapped in Nullable(Enum*), where the recursive child build
substitutes a declared placeholder value (the first declared enum
value, picked via BeginValueToName) so the Nullable column can be
constructed without poisoning the read path. tests/084 walks the
matrix: undeclared ints (0/3/127/-1/999), NULL on non-Nullable,
unknown name, empty name, declared int + name round-trip, and
Nullable(Enum) NULL plus declared-int round-trip.

CI fix (PHP 8.5): ReflectionProperty::setAccessible() is deprecated
since PHP 8.5 (it has been a no-op since 8.1). The Round-10 fix for
PHP 7.4 unconditionally called the method, which made 8.5 emit a
deprecation notice that run-tests.php classified as a test failure.
Gated on `PHP_VERSION_ID < 80100` so 7.4 still gets the visibility
toggle and 8.1+ skips the call.

CR-004 (Minor): the applyPlaceholders comment at the top of the
substitution path still documented the pre-Round-9 flat character
whitelist, including the `-` that Round 8 dropped and the comma
support that Round 10 dropped. README.md described an
"identifier-and-numerics character set" without mentioning the
scalar-vs-array contract introduced in Round 10. Both updated to
spell out the current rules: scalar = single token; array = explicit
identifier list.

Tests: 81/84 pass, 3 expected skips (TLS env + de_DE locale × 2),
0 failures.

Mediums still open: CR-002 UInt64 readback (needs API call), CR-003
transpose memory pressure (architecture change).
insert() now pre-flights each row in buildColumnMajorRows: extra
positional or named cells past the declared column count throw
ClickHouseException up front. The previous loop bounded by
columns_count silently dropped extras and returned success on
truncated rows.

UInt64 values above ZEND_LONG_MAX (2^63-1) surface as decimal
strings instead of negative PHP ints. The scalar read path, Map
keys, and Map values all share the same emitUInt64Cell helper, so
distinct unsigned values no longer collapse into the same signed
PHP key.

write() conversion failures now best-effort EndInsert() in the
catch path. Without it the open BeginInsert state stuck around
inside the vendored Client and the next select/execute threw
"cannot execute query while inserting", forcing resetConnection().

Tests 085, 086, 087 cover each fix. Test 020 was leaning on the
silent-drop behavior (one row of two cells against a one-column
table) and now passes one cell per row.
…rings

write() derived its row width from the first PHP row, so a row narrower
than the writeStart() column declaration silently sent a truncated
block. ClickHouse filled the missing columns from defaults and the call
returned success on incomplete data. write() now uses the BeginInsert
block's GetColumnCount() as the authoritative width and rejects narrow
rows up front.

write()'s catch path called EndInsert() best-effort to keep the
client usable. That kept the simple "first write() throws" case clean
but committed previously sent blocks when a later write() threw,
turning a thrown call into a silent partial commit. The struct now
tracks whether any block has been sent in the current writeStart()
session: clean-session failures still EndInsert(), dirty-session
failures ResetConnection() so the server discards the in-flight insert.

UInt64 inserts now accept decimal and hex strings above ZEND_LONG_MAX
on both the scalar and Map(*, UInt64) value paths via a shared
strict_zval_u64 parser. Map UInt64 values previously routed through
strict_zval_long, which capped at 2^63-1; PHP callers had no way to
write the upper half of UInt64 through Map values even though the
read path surfaces them as decimal strings.

Tests 088, 089, 090 cover each fix.
…ation

clickhouse_free_obj() called EndInsert() on any in-flight streaming
insert. EndInsert commits already-sent blocks, so writeStart() +
write() followed by unset()/teardown without writeEnd() implicitly
committed partial data. Round 13 moved write()'s catch to the same
clean-vs-dirty split; the destructor now follows that policy too:
clean session closes the empty insert, dirty session resets the
connection so the server discards in-flight data.

Replaces buildColumnMajorRows()'s full PHP zval transpose with a
streaming column-by-column build. The previous path materialized an
N_rows * N_cols zval matrix alongside the original input and the
native ClickHouse columns; insert() and write() both held it in
memory until the SendInsertBlock returned. The new path validates
row shapes once, then builds one column zval, hands it to
zvalToBlock(), and frees it before moving on. Peak intermediate PHP
memory drops from N_rows * N_cols to one column.

Test 091 covers the destructor contract (dirty unset, clean unset,
full cycle).
Server-side EndInsert() failures (CHECK constraint violations, schema
drift, transport errors after a block has been transmitted) left the
vendored client's inserting_ flag set. The wrapper threw the original
error to PHP but the next select/execute on the same handle threw
"cannot execute query while inserting" until userland called
resetConnection() by hand. Both insert() and writeEnd() now route
post-send failures through ResetConnection() so the handle stays
usable. Pre-send conversion failures keep the lighter EndInsert()
close, since no data has crossed the wire.

insertAssoc() previously built a full positional zval matrix from the
input rows before delegating to the shared insert helper. The new
column-by-column gather already handles assoc rows via name lookup,
so the second matrix is redundant. insertAssoc() now validates row
shapes up front and passes the original assoc rows directly to
do_insert_into.

Test 092 covers both EndInsert recovery paths: direct insert() with
a CHECK constraint, streaming writeEnd() with the same constraint,
and a fresh streaming cycle after each error.
…rty marker

insertAssoc() lost a piece of its shape contract when Round 15
removed the positional-copy path. The remaining check only counted
keys per row, so a later row like [0 => 99, "b" => 4] passed
validation. The shared column gatherer tries integer-index lookup
before name lookup, which silently routed 99 into column "a"
instead of throwing. Every row now must have string keys, and the
key set must match the first row's.

Direct insert() flipped block_sent = true only after
SendInsertBlock() returned, so a throw mid-call (transport error,
push-back packet) routed the catch path to EndInsert() — the same
"finalize on a dirty wire" path Round 14 fixed for streaming
write(). The flag now goes up before the call, mirroring the
streaming path.

Test 093 covers the assoc shape contract: integer-keyed later row,
mixed-positional first row, dropped/missing/extra keys, plus a
sanity case where a later row reorders the same string keys.
…ssoc

The vendored client sets its inserting_ flag before sending
BeginInsert's SendQuery and before reading the server's schema
block, so an exception during that phase (missing table, bad
column, permissions) left the flag stuck. The next select/execute
on the same handle threw "cannot execute query while inserting"
until userland called resetConnection() by hand. Both insert() and
writeStart() now wrap BeginInsert in a recovery try/catch that
ResetConnection()s before rethrowing, matching the policy already
applied to SendInsertBlock and EndInsert failures.

Round 16's insertAssoc() key-set check used std::unordered_set<
std::string>, allocating a fresh std::string for every cell key on
every membership lookup. zend_hash_exists takes the existing
zend_string and compares hashes without allocating, so the first
row's HashTable doubles as the expected-key oracle. unordered_set
include is gone too.

Test 094 covers the BeginInsert recovery for both call sites:
missing-table insert(), missing-table writeStart(), bad-column
writeStart(), and a fresh streaming cycle after each error.
The missing-table probe pointed at test.no_such_table, which the
test never dropped. A stale table left in the dev/CI database with
a compatible id column would let BeginInsert succeed and the test
would assert the wrong behavior. Switch to a test-owned name and
drop it up front.
php-src master (PHP 8.6.0-dev) removed the XtOffsetOf portability
shim in commit 7114314c5a9 (PR #21899, "tree-wide: Replace
XtOffsetOf by its definition", 2026-04-28). XtOffsetOf was always
defined as

    #define XtOffsetOf(s, f) offsetof(s, f)

in zend_portability.h, so the swap is mechanical and works on every
PHP version we ever shipped on. offsetof is in <stddef.h> which
php.h transitively includes; no new include or version guard
required.

Validated by rebuilding clickhouse.so against PHP 7.4, 8.3, 8.4,
and 8.5 -- all four phpize-configure-make passes clean with no
warnings under the project's existing -Wall -Wextra flags. PR
upstream is iliaal/php_excel#293 (same fix in ext/excel).
The clickhouse_skip_if_no_server helper already gates on
extension_loaded internally, so it's not strictly required for
correctness. But the --EXTENSIONS-- directive is checked by
run-tests.php before SKIPIF runs, which means a one-line skip
before the helper does an fsockopen(): saves a fork+boot per
test on machines without a backing server.

Convert all 94 tests to declare --EXTENSIONS-- clickhouse; the
single trivial SKIPIF (tests/001) drops its body entirely and
relies on the directive alone.

Convention now documented in
~/ai/wiki/architecture/php-extension-c-conventions.md.
fastchart is the fourth native PHP extension in the toolkit. Symmetric
cross-link added so all four READMEs (php_excel, mdparser,
php_clickhouse, fastchart) reference each other under the
"PHP Performance Toolkit" section.
The --EXTENSIONS-- clickhouse directive added in 616b692 broke the
PHP 7.4 matrix job. run-tests.php's EXTENSIONS handler probes the
parent for loaded extensions and, when a name is missing, appends
-d extension=$ext_dir/<name>.so to the test child using the system
extension_dir. With TEST_PHP_ARGS pointing at an absolute
modules/clickhouse.so, PHP 7.4 saw the extension loaded under name
"ClickHouse"; remap_loaded_extensions_names lowercases on PHP 8.x
so the in_array probe matched and the system fallback never fired,
but on 7.4 something in the probe path landed clickhouse outside
the lowercase $loaded set, so run-tests.php injected
-d extension=/usr/lib/php/20190902/clickhouse.so. That path doesn't
exist; the resulting startup warning broke the expected output of
every test (91/94 fails).

Switch TEST_PHP_ARGS to -d extension_dir=$(pwd)/modules
-d extension=clickhouse on the matrix, ZTS, and ASAN jobs. Now
$ext_dir reported by the probe is the project's modules/ dir, so
the EXTENSIONS fallback (if it fires at all) resolves to the actual
built .so. Verified locally on PHP 8.4: tests/001 passes, server-
required tests skip cleanly without a server.
Same call as mdparser bfd66d0: macos-13 runners queue 30+ min on
busy days, macOS 26 drops Intel support, Intel-Mac users source-
build via PIE's composer-default fallback. Matrix shrinks from 4
to 3 lanes; per-release Linux/macOS asset count drops 8 -> 6 and
total prebuilt assets 20 -> 18 (with the 12 Windows DLLs).
The previous attempt (3b5f658) overrode extension_dir to point at
$(pwd)/modules so --EXTENSIONS-- clickhouse would resolve, but
that broke the system dom/xml/phar/simplexml/tokenizer extensions
that setup-php loads by basename via /etc/php/X.Y/cli/conf.d/*.ini
-- those rely on extension_dir pointing at the system path.

Stage the built .so into the system extension_dir (the one PHP
itself reports via ini_get) before running tests. --EXTENSIONS--
clickhouse then resolves through the standard
$ext_dir/clickhouse.so path on every PHP version, with no
TEST_PHP_ARGS override, no extension_dir mutation, and no
PHP-7.4-specific dl.c quirks. Mirror the same pattern for the ZTS
and ASAN matrix jobs (which build their own PHP, so no sudo
needed).

Verified locally on PHP 8.4: tests/001 passes, server-required
tests skip cleanly without a server.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants