Skip to content

Releases: crazy-goat/ladybug-php

v0.5.0 — API freeze

Choose a tag to compare

@s2x s2x released this 17 Aug 20:39

Supports liblbug 0.19.x. Prebuilt binaries for PHP 8.2–8.5 on linux-x86_64, linux-aarch64
and macos-arm64 are attached below, with SHA256SUMS.

This release adds no features. It writes down what the package promises to keep, ahead of the
SemVer declaration in 1.0.0 — because after that, a class nobody thought about is a class that
went public by accident and cannot be taken back.

The promise is a test, not a paragraph

tests/Unit/ApiSurfaceTest requires every class under src/ to be listed as public or internal.
A new class fails the suite until someone chooses. It also asserts that everything except the
exception classes is final, and that no internal class has lost its @internal.

Verified that it is not vacuous: dropping a stray class Scratch {} into src/ fails two of
its four tests with messages naming the offender.

Three things stated rather than implied

  • Calling Connector is covered by SemVer; implementing it is not. It carries one method per
    liblbug C call, and liblbug is itself pre-1.0, so methods will be added in minor releases as the
    C API grows. Freezing it at our 1.0 would have cost a 2.0.0 for the first new C call worth
    exposing. The same applies to Handle, public only because those signatures need a type.
  • A liblbug minor release requires a release here. The README now carries the compatibility
    matrix and the reason: patch releases inside a supported series pass the runtime check, 0.20
    is refused until we ship support, because the alternative is reading a struct at wrong offsets.
  • Windows is not supported — not "not yet". Nothing has ever run there. The platform table
    says which combinations are covered by CI, which ship as binaries, and which merely ought to work.

Also

  • Config::with() rejects names it does not know. A typo used to escape as a bare \Error that
    no catch (LadybugException) would see. PHPStan cannot catch this — a mixed ...$overrides
    signature is opaque to it — which is why the check is hand-written.
  • Cdef, ValueReader, ExtHandle and FfiHandle are marked @internal.
  • QueryException::$parameters holds the values that were bound. __toString() never prints them,
    so an uncaught exception or a log line does not leak them, but var_dump() and anything that
    serialises the object will. Documented as the trade it is.

Found by releasing

build/ was not ignored, so 0.4.0 committed the generated PIE mirror into the repository. That
deadlocked this release: the mirror script refuses to publish from a dirty working tree, and its
own output was what made the tree dirty. Reading the script would not have shown it; running it
did.

v0.4.0 — distribution

Choose a tag to compare

@s2x s2x released this 17 Aug 19:24

Supports liblbug 0.19.x. This release is about getting the client onto machines that are not a
development checkout. Nothing in the query path changed.

Four ways to install

needs speed
FFI ext-ffi + liblbug on disk baseline
prebuilt binary (below) matching PHP version fastest
pie install crazy-goat/ladybug-ext toolchain + liblbug on disk fastest
build from source phpize, C toolchain, 80 MB archive fastest
composer require crazy-goat/ladybug-php

The package is on Packagist, so no
repositories entry is needed any more.

Prebuilt binaries

Attached below: ladybug.so for PHP 8.2, 8.3, 8.4 and 8.5 on linux-x86_64, linux-aarch64
and macos-arm64, with SHA256SUMS. The PHP version has to match exactly. liblbug is linked
in, so nothing else is needed on the machine — which is also why each one is around 20 MB.

Static linking is a correctness requirement here, not a size preference: it is the only linkage
immune to the INSTALL crash fixed in 0.3.1. A shared-linked extension segfaults on any host
with intl loaded.

Every binary is verified before it is attached — no liblbug dependency, no stray libstdc++
exports, both suites passing with intl in the process, and a clean-room run that queries
through the extension with liblbug moved off the machine entirely.

PIE

The extension is published separately as
crazy-goat/ladybug-ext, generated from
ext/ on each release. PIE requires an extension's Composer name to differ from any regular
package's, so it needs its own repository; that one is a mirror and issues belong here.

bash tools/fetch-liblbug.sh 0.19.1 --static
pie install crazy-goat/ladybug-ext --enable-ladybug-static --with-liblbug="$PWD/lib"

Two bugs found by building what we distribute

  • Linking liblbug.a re-exported 281 std:: symbols and 23 STB_GNU_UNIQUE locale facet ids,
    which would have made this extension the first half of the crash it avoids: glibc binds unique
    symbols process-wide and ignores RTLD_DEEPBIND. -Wl,--exclude-libs,ALL removes them and
    breaks LOAD json, because LadybugDB's downloaded extensions resolve 91 lbug:: symbols from
    whoever loaded liblbug. ext/ladybug.map splits by owner instead of by language.
  • PIE runs ./configure with no arguments unless the installer names a declared option, and
    PHP_ARG_ENABLE([ladybug]) defaulted to no — a bare configure planned a build of nothing and
    exited 0, so pie install would have reported success and installed no extension.

Also: the extension reported version 0.1.0 through three releases. It is 0.4.0 now, with a
test to keep it current.

The INSTALL crash diagnosis from 0.3.1 is confirmed on x86_64 as well; every measurement
behind it had been taken on arm64.

v0.3.1 — the INSTALL crash on Linux

Choose a tag to compare

@s2x s2x released this 17 Aug 16:21

Recommended if you run on Linux. Without this, INSTALL segfaults in any process that has a PHP extension linking libstdc++ loaded — intl is the common one — taking the whole process with it, with no exception to catch.

Supports liblbug 0.19.x. No API changes.

What was happening

liblbug 0.19.1's prebuilt Linux .so statically links libstdc++ and exports its symbols, 130 of them with STB_GNU_UNIQUE binding — std::locale facet ids and their init guards:

nm -D --defined-only liblbug.so | grep -c '^[0-9a-f]* u '     # 130

glibc binds STB_GNU_UNIQUE symbols process-wide and ignores RTLD_DEEPBIND for them, while ordinary globals honour it. Zend's DL_LOAD uses RTLD_LAZY|RTLD_GLOBAL|RTLD_DEEPBIND for every extension and for ext/ffi's dlopen. So with a system libstdc++ also in the process, liblbug's locale registry is split across two C++ runtimes, and the first std::regex compiled — which INSTALL does, building its HTTP client — dies inside std::codecvt.

tools/repro-install-crash-dlopen.c shows it in C with no PHP at all: DEEPBIND on liblbug plus a libstdc++ loaded first gives 139, dropping either gives 0.

What changed here

The FFI connector fixes itself. It now dlopens liblbug before ext/ffi can, with plain RTLD_LAZY — an already-loaded object keeps its original binding, so DEEPBIND never applies. Linux only, only when a libstdc++ is already mapped, and LADYBUG_NO_PRELOAD=1 opts out. Nothing to configure.

The native extension needs --enable-ladybug-static. There liblbug is a link-time dependency, so PHP's flags apply before any of our code runs. The static build avoids the problem because liblbug.a carries no libstdc++ of its own: the .so links the system one dynamically, leaving a single runtime. This is now the documented linkage for distributing on Linux.

One thing to know: loading a dynamically linked ladybug.so re-exposes the FFI connector too, because liblbug is bound at PHP startup. Don't mix a shared-linked extension with the FFI connector.

Failing both, LD_PRELOAD=/path/to/liblbug.so php … works for either connector. maxThreads: 1 does not help.

The real fix belongs upstream — -Wl,--exclude-libs,ALL and -fno-gnu-unique on the Linux release. The macOS dylib exports none of these symbols, so it is a packaging difference rather than a design choice. Nothing has been filed yet.

Also

  • make docker-test runs the whole suite, both connectors, on Linux from a macOS workstation — which is how this was diagnosed after a first attempt through a throwaway CI branch.
  • The Linux image installs PHPUnit as a phar; pulling the full dev set hit codeload's rate limit often enough to break builds for no reason. CI now authenticates Composer downloads for the same reason.

Full notes in CHANGELOG.md.

v0.3.0 — types and data

Choose a tag to compare

@s2x s2x released this 17 Aug 13:24

Every LadybugDB type this client can read now has a PHP shape, and there is a bulk path that does not plan a query per row.

Supports liblbug 0.19.x.

Paths are typed

RECURSIVE_REL used to arrive as liblbug's own text. It turns out to be a STRUCT of two lists, and liblbug's struct accessors do read it — so the members are the same Node and Rel objects every other query produces:

$path = $connection->query('MATCH p = (a:Person)-[:Knows*1..3]->(b:Person) RETURN p')->fetchOne();

$path->nodes;      // list<Node>, in traversal order
$path->rels;       // list<Rel>
$path->length();   // hops
$path->start();  $path->end();

Deliberately neither iterable nor countable: both would have to choose between nodes and relationships, and each reading is defensible.

Bulk loading

$connection->copyInto('Person', [
    ['name' => 'Ada', 'age' => 36],
    ['name' => 'Alan', 'age' => 41],
]);                                              // → 2

$connection->copyInto('Knows', [['Ada', 'Alan', 2001]]);   // REL: from, to, then properties

Rows spool to a temporary CSV and go through liblbug's COPY FROM. Associative or positional, any iterable — a generator is never materialised. Table and column names are validated rather than escaped, because a Cypher identifier has no quoting form that makes arbitrary input safe.

An empty string is refused. liblbug reads an empty CSV field as NULL and offers no sentinel to separate them, so copying '' would silently store NULL. Pass null, or insert that row with CREATE.

Extensions

json, fts and vector work through plain query()INSTALL and LOAD are Cypher. Two things needed fixing:

  • DataType::Json maps type id 60, which lbug.h does not declare (the core header stops at UUID = 59). Reading a JSON column used to throw "liblbug is newer than this client", and columnTypes() threw a raw ValueError.
  • An unrecognised type id now degrades to DataType::Unknown plus liblbug's rendering. Extensions can introduce types at any time, and one unmapped column should not fail the query.

Vector search works end to end. Embedding columns are FLOAT[n] — an ARRAY — so they arrive as text; cast(col AS FLOAT[]) gives floats.

A Linux test environment

make docker-test runs the whole suite, both connectors, on Linux (DOCKER_PHP=8.4 picks a version). It caught an overstatement in the previous release notes within minutes of existing.

Documented liblbug limitations

  • ARRAY and UNION arrive as liblbug's own text: its list and struct accessors reject both types. The values are intact — cast to a LIST in Cypher for structure.
  • INSTALL segfaults on GitHub Actions' Linux runners. Not on Debian containers, either architecture, including on a crashing runner; not under gdb; and every network failure gives a clean exception. A core dump places it inside liblbug's own C++ runtime.

Full notes in CHANGELOG.md.

v0.2.1 — memory-safety fixes

Choose a tag to compare

@s2x s2x released this 17 Aug 11:10

Recommended over 0.2.0, where reading an ARRAY column corrupts the heap.

Supports liblbug 0.19.x. No API changes.

Both bugs were found by probing whether ARRAY and UNION work at all — neither has anything to do with those types.

Heap corruption in the extension

Three error paths freed return_value without resetting it, so the engine freed the same array again. It reported as zend_mm_heap corrupted with no hint of the origin, and AddressSanitizer could not see it: the corruption lives inside Zend's own memory pool, which the sanitizer sees as a single allocation. Three sibling paths already had the reset — the pattern had just been applied inconsistently.

ValueReader ignored every lbug_state

liblbug leaves the out parameter untouched on failure, so an unchecked getter returned a plausible wrong value — a zeroed struct reads as an empty list — or, for the getters that yield an lbug_value, a garbage handle that segfaulted when read.

All 21 calls now check, throwing ConnectorException with the same messages the extension uses, so the same failure is catchable the same way on both backends. The extension had always checked them; this was a straight divergence between the two connectors that the shared suite never caught, because it only covered types that succeed.

ARRAY and UNION

A liblbug limitation, not a bug here: lbug_value_get_list_size() fails on a fixed-size array, and lbug_value_get_struct_field_value() fails on a union's second field. The values are intact, so both backends now fall back to liblbug's own rendering — the policy RECURSIVE_REL already used — rather than discarding reachable data:

$c->query('RETURN cast([1, 2, 3] AS INT64[3]) AS a')->fetchOne();                 // '[1,2,3]'
$c->query('RETURN cast(cast([1, 2, 3] AS INT64[3]) AS INT64[]) AS l')->fetchOne(); // [1, 2, 3]

Cast to a LIST in Cypher when you want structure.

Full notes in CHANGELOG.md.

v0.2.0 — correctness infrastructure

Choose a tag to compare

@s2x s2x released this 17 Aug 10:48

No API changes. This release is about knowing the existing code is correct rather than adding to it.

Supports liblbug 0.19.x.

Refuses to run against an unverified liblbug

Both connectors depend on liblbug's exact struct layout — lbug_system_config is passed by value and Cdef spells out its fields one by one — and liblbug is pre-1.0, so a minor release may rearrange them. Nothing about that failure is loud: lbug_database_init() would read a config struct that means something else.

check on mismatch
FFI constructor IncompatibleLibraryException
extension MINIT module does not load

LADYBUG_ALLOW_ANY_LIBRARY=1 downgrades both to a warning for development. phpinfo() now reports the supported series, the built-against version and liblbug's storage version.

AddressSanitizer

make ext-asan && make test-asan runs the integration suite against a poisoned heap. Verified not to be decorative: a deliberate one-byte overflow in MINIT is reported with a symbolised stack.

The CI job runs on macOS. On Linux PHP dlopens every extension with RTLD_DEEPBIND, which the sanitizer runtime refuses to run alongside — including the extension under test.

Leak detection

memory_get_usage() cannot see a stranded lbug_database. MemoryTest watches resident memory over four workloads instead. Also verified against a deliberate leak: disabling closeResult() reports 513 KB per iteration.

Benchmarks

make bench runs both backends in one process. On an M4 Pro, PHP 8.5:

scenario ext ffi ratio
fetch scalars 1,056,357/s 164,399/s 6.4x
fetch nodes 411,958/s 73,117/s 5.6x
fetch temporal 513,081/s 169,262/s 3.0x
insert prepared 10,556/s 10,753/s 1.0x
tiny queries 7,469/s 7,551/s 1.0x

The gap is entirely in value conversion. Writes and per-query overhead are bound by liblbug itself, so a write-heavy workload gains nothing from compiling the extension.

Also

  • coverage measured on both backends and merged by unioning covered lines, gated in CI (78.7%)
  • CHANGELOG.md, CONTRIBUTING.md, issue templates
  • fixed: switching between plain, static and instrumented extension builds now forces a rebuild

Full notes in CHANGELOG.md.

v0.1.0

Choose a tag to compare

@s2x s2x released this 17 Aug 08:04

First release of the PHP client for LadybugDB — an embedded graph database (formerly Kuzu).

Two backends, one API. The library talks to liblbug either through a native PHP extension or through FFI, and picks whichever is available at runtime.

$database = new Ladybug\Database('/var/data/graph.lbdb');
$connection = $database->connect();

$connection->run('CREATE (:Person {name: $name, age: $age})', ['name' => 'Ada', 'age' => 36]);

foreach ($connection->query('MATCH (p:Person) WHERE p.age > $min RETURN p.name', ['min' => 30]) as $row) {
    echo $row['p.name'], PHP_EOL;
}

What is in it

  • Native extension (ext/) — value conversion in C, linked against liblbug dynamically (~90 KB .so) or statically (--enable-ladybug-static, ~20 MB, no liblbug dependency)
  • FFI connector — no compilation, just the shared library
  • Connector factory — explicit choice via Config, LADYBUG_CONNECTOR override, or highest-priority available backend, with diagnostics that name every path searched
  • Streaming results, prepared statement cache, transactions, multi-statement results
  • Full type mapping: NODE, REL, temporal types onto DateTimeImmutable/DateInterval, DECIMAL and INT128 as numeric strings so nothing is lost

Verified

CI runs on PHP 8.2, 8.3, 8.4 and 8.5 across Linux x86_64 and macOS arm64, executing the integration suite once per connector on each runtime — that is what holds the C and PHP conversion code to identical behaviour. 173 PHP tests (107 integration) plus 4 .phpt, PHPStan level 8, Rector and PHP-CS-Fixer clean. Against liblbug 0.19.1.

Known gaps

  • RECURSIVE_REL arrives as liblbug's own rendering rather than a typed path object
  • No Arrow / bulk-copy ingestion
  • No PIE / PECL package for the extension yet
  • Windows is untested

Requires PHP 8.2+. MIT licensed.