# Upgrading vault 4.x → 5.0

Four things moved off the query builder: results, DDL, the connection, and the interface names.
Most call sites do not change — 151 `->execute()` calls across Newton and the apexl packages
needed **zero** edits — but the ones that do are mechanical.

## Why

One `Vault` holds one driver instance per named vault, and that instance was everything at once:
query builder, result set, schema tool and connection. Because `select()` and `execute()` both
mutated it and returned `$this`, two "separate" queries were the same object:

```php
$a = $db->select('x')->execute();
$b = $db->select('y')->execute();   // $a === $b in 4.x
$a->fetch();                        // rows from y, silently
```

That reached production. Splitting the roles makes it unsayable rather than merely unlikely.

## At a glance

| 4.x | 5.0 |
|---|---|
| `$db->select('t')->fields('*')->execute()->fetchAll()` | unchanged |
| `$db->insert('t', $row)->execute();` | unchanged |
| `$q->execute(); $q->fetchAll();` | `$result = $q->execute(); $result->fetchAll();` |
| `$db->newQuery()->describe('t')->fetchAll()` | `$db->schema()->describe('t')` |
| `$db->createTable($t, $cols, true, 'INNODB')` | `$db->schema()->createTable($t, $cols, true)` |
| `$db->keyIsAutoIncrementing($k, $t)` | `$db->schema()->keyIsAutoIncrementing($k, $t)` |
| `$db->lastInsertId()` | `$db->connection()->lastInsertId()` |
| `$db->getConnection()` | `$db->connection()->handle()` |
| `use apexl\Vault\interfaces\driver;` | `use apexl\Vault\interfaces\QueryBuilderInterface;` |
| `use apexl\Vault\interfaces\operators;` | `QueryBuilderInterface`, or `ResultInterface` if you meant rows |
| — | `$db->connection()->transaction(fn() => ...)` |

## 1. `execute()` returns a result

`execute()`, `executeSql()` and `describe()` now hand back a `ResultInterface` instead of the
builder. The fetch methods live there and **are gone from the builder**.

```php
// unchanged — this is the overwhelming majority of call sites
$rows = $db->select('parts')->fields('*')->where('id', 7)->execute()->fetchAll();

// unchanged — writes discard the return
$db->delete('parts')->where('id', 7)->execute();
```

What breaks is holding the builder and fetching from it later:

```php
// 4.x
$query = $db->select('parts')->fields('*');
$query->execute();
$rows = $query->fetchAll();   // Error: method does not exist in 5.0

// 5.0
$rows = $db->select('parts')->fields('*')->execute()->fetchAll();
```

Also retype anything that declared the return of `execute()`:

```php
-public function delete(int|string $id): operators
+public function delete(int|string $id): ResultInterface
```

`ResultInterface` is `fetch`, `fetchColumn`, `fetchAll`, `fetchAllAssoc`, `fetchAssoc` and a new
`rowCount()`. Two implementations: `PdoResult` wraps an executed `PDOStatement` and the
`errorInfo()` read from it; `ArrayResult` carries rows already in memory.

## 2. `driver` → `QueryBuilderInterface`, and `operators` is gone

`driver` never said what it was — the query builder — and both names were lowercase where
everything alongside them is StudlyCaps.

`operators` was not renamed. Once row fetching moved to `ResultInterface`, what remained
(`execute`, `executeRaw`, `isCount`, `getQueryFields`) was builder contract, so it folded into
`QueryBuilderInterface`. `executeRaw()` had been declared on both and is now declared once.

Both old names survive as **real interfaces extending `QueryBuilderInterface`**, and every driver
declares them, so a 4.x type declaration still accepts a 5.0 driver and `instanceof driver` is
still true. They carry `@deprecated`, so your IDE and static analysis will flag each usage.

They cannot be `class_alias()`es. PHP resolves a declared type by comparing names against the
interfaces the object's class already lists, and never autoloads the declared type to do it — so
an aliased name is invisible to exactly the thing it exists to serve: `function f(driver $q)`
fatals with a TypeError and `$driver instanceof driver` quietly returns false, while
`interface_exists()` and `is_a()` report everything as fine.

**The contract behind the old names has narrowed** — code that types `driver` and calls
`->describe()` or `->lastInsertId()` will not find them. Treat them as a grace period, not a
destination: they are removed in 6.0.

The implements graph is untangled too. `pdo` is the abstract execution half and implements
nothing; `mysql` and `mongodb` each implement `QueryBuilderInterface`; `sqlite extends mysql`.

## 3. DDL moved to `SchemaInterface`

`describe()`, `createTable()` and `keyIsAutoIncrementing()` were on the builder, so every store
that could build a query had to claim it could do DDL. That is how the mongodb driver came to
implement `createTable(..., string $engine = 'INNODB')` — an argument meaning nothing to a store
with no tables and no engines, on a method that only ever threw.

```php
-$columns = $db->newQuery()->describe($table)->fetchAll();
+$columns = $db->schema()->describe($table);
```

Three changes to note:

- `describe()` returns `array` directly — no `->fetchAll()`.
- `createTable()` returns `void` and **has no `$engine` parameter**. `MysqlSchema` hardcodes
  INNODB, where it is actually true.
- **Drop the `newQuery()`.** It was there because `describe()` set the sql on the shared builder
  and would otherwise clobber an in-flight query — a rule every caller had to remember.
  `MysqlSchema` isolates its own statements now.

## 4. The connection owns transactions

4.x had no `beginTransaction`/`commit`/`rollBack` at all. It exposed the raw handle, so anything
wanting a transaction reached around vault and kept bookkeeping of its own — and bookkeeping kept
anywhere but beside the handle cannot tell "I opened this" from "someone else did". Wizaplace's
ETL `Transaction` tracks whether one is open *per instance* while the connection has exactly one,
so a nested loader's commit ended the outer table's transaction.

```php
$db->connection()->transaction(function () use ($db) {
    $db->insert('orders', $order)->execute();
    $db->insert('order_lines', $line)->execute();
});
```

Commits when the callable returns, rolls back if it throws, and returns whatever the callable
returned. **Nested calls join the transaction already in flight** rather than starting or ending
one of their own, so a helper can ask for atomicity without knowing what its caller did. MySQL has
no true nested transactions — a `BEGIN` inside one silently commits it — so joining is the only
honest behaviour.

`lastInsertId()` moved here as well; it is a property of the connection, not of whatever query you
happen to be holding.

### Getting at the raw handle

`getConnection()` still works but is deprecated and raises `E_USER_DEPRECATED` when called, which
is how you find the call sites you have left. Use `connection()->handle()` when a library must
genuinely be handed a `PDO` — Wizaplace's ETL manager and Monolog's `MySQLHandler` both must be:

```php
-return $this->db->{$config['name']}->getConnection();
+return $this->db->{$config['name']}->connection()->handle();
```

Reaching for the handle to run your own queries or your own transactions is what this interface
exists to stop. Use the builder, and `transaction()`.

The connection is memoised per driver: `$db->connection() === $db->connection()`. A fresh wrapper
per call would put the depth count back on the caller, which is the bug.

## Also worth knowing

- **`sqlite::describe()` was removed.** It was a byte-identical copy of the parent's body that
  existed only to narrow the return type. Note it emits MySQL-only `DESCRIBE` and has never
  worked on sqlite — moved, not fixed.
- **Mongo:** `MongoConnection::transaction()` throws. Mongo transactions need a replica set, and
  running the work without one would promise atomicity the connection cannot deliver.
  `MongoSchema::describe()` returns `[]` — "I know nothing about this collection's structure",
  which is what consumers can work with.

## Migration checklist

1. `grep` for `interfaces\driver` and `interfaces\operators` and switch the imports and type
   hints. Type positions only — leave prose that says "driver" alone.
2. `grep` for `->fetch`, `->fetchAll`, `->fetchAssoc`, `->fetchAllAssoc`, `->fetchColumn` and
   confirm each receiver is a result, not a builder held in a variable.
3. `grep` for `->describe(`, `->createTable(`, `->keyIsAutoIncrementing(` → route via `schema()`,
   drop any surrounding `newQuery()`, drop `->fetchAll()` after `describe()`, drop the `$engine`
   argument.
4. `grep` for `->getConnection()` and `->lastInsertId()` → route via `connection()`.
5. Retype any method that returns the value of `execute()`.
6. Run with deprecations visible. `getConnection()` reports itself at runtime, and the old
   interface names report themselves to your IDE and static analysis — between them they will
   find what step 1 missed.
7. **Run the test suite, not just `php -l`.** Lint does not catch cross-file signature covariance:
   a subclass overriding a method whose parent return type changed fatals at class-load time and
   linting every file clean will not tell you. It will not catch an unresolvable type declaration
   either — only passing a real object through one does that.
