Added Support for Fetch Modes#4
Conversation
769344359
left a comment
There was a problem hiding this comment.
why remove shutdown callback ?
| SEASCLICK_RES_NAME, | ||
| SeasClick_functions, | ||
| PHP_MINIT(SeasClick), | ||
| PHP_MSHUTDOWN(SeasClick), |
There was a problem hiding this comment.
why remove shutdown callback?
There was a problem hiding this comment.
It doesn't do anything, so recommended approach is to not invlude PHP_MSHUT / PHP_MINIT unless they do something
| }; | ||
|
|
||
| #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); |
There was a problem hiding this comment.
Please do some compatibility tests, such as zend_long type does not support PHP 5 version
| 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); |
There was a problem hiding this comment.
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
Upgraded clickhouse lib to v1.1.0 Added ability control connection, recv, timeouts & retry settings
|
@769344359 @Neeke Any chance to get it merged? |
it will have been merged when i finsh unit tests! |
|
@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.
|
@Rock-520 Not sure if this repo is still alive, but PR updated and merges cleanly |
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.
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.

Couple of feature enhancements to simplify data retrieval
Also added SeasClickException class so that exceptions thrown are specific to extension as opposed to using generic Exception class.