Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ the full alignment plan.

```bash
composer install # install dependencies
composer test # PHPUnit (197 tests)
composer test # PHPUnit (238 tests)
composer analyse # PHPStan level max
composer check # tests + static analysis
```
Expand All @@ -46,10 +46,14 @@ The client is layered into pure request builders and an HTTP execution layer:

- **Query, Filter, EntityRef, Url** — build pure request data (URLs, filter
expressions, key references). No direct cURL calls.
- **ScriptInvoker** — FileMaker script invocation at database, entity-set,
and record scope (by name or FMSID). Reuses spec-php script helpers.
- **ContainerRef** — container field download (binary), upload (binary or
base64), and clear. MIME sniffing and Content-Disposition via spec-php.
- **HttpClient** — handles auth, OData headers, 401 retry, JSON/XML decoding,
and error conversion. Uses a `TransportInterface` for the actual HTTP call.
- **CurlTransport** — the production transport, using PHP's built-in cURL
extension. The only transport shipped with the MVP.
extension. The only transport shipped.
- **MockTransport** (test only) — deterministic in-memory transport for
offline test execution.
- **MetadataParser / MetadataFetcher** — parse `$metadata` XML and cache the
Expand All @@ -60,7 +64,8 @@ The client is layered into pure request builders and an HTTP execution layer:
## Key source files

- `src/Client.php` — `Client` entry point, base URL construction, version
detection, feature gating, low-level request methods
detection, feature gating, low-level request methods, `script()` /
`scriptById()`
- `src/FMSOData.php` — thin alias subclass of `Client`
- `src/ClientOptions.php` — configuration DTO (host, database, token,
transport, timeout, SSL)
Expand All @@ -70,10 +75,18 @@ The client is layered into pure request builders and an HTTP execution layer:
- `src/Http/TransportInterface.php` — single request/response contract
- `src/Http/Request.php`, `Response.php` — neutral HTTP DTOs
- `src/Query/Query.php` — fluent query builder + serialization + execution
+ `script()` (entity-set scope)
- `src/Query/Filter.php`, `FilterFactory.php` — $filter expression builder
- `src/Entity/EntityRef.php` — single-record CRUD handle
- `src/Entity/EntityRef.php` — single-record CRUD handle + `script()`
(record scope) + `container()` + `patchContainers()`
- `src/Entity/EntityWriteOptions.php` — PATCH/DELETE options (If-Match,
return representation)
- `src/Scripts/ScriptInvoker.php` — script invocation handle (3 scopes,
name/FMSID, envelope parsing, error promotion)
- `src/Containers/ContainerRef.php` — container field I/O (get, getStream,
upload binary/base64, delete, Content-Disposition parsing)
- `src/Containers/ContainerDownloadResult.php` — download result DTO with
`size` field (parity with JS/Python)
- `src/Metadata/MetadataParser.php` — regex-based CSDL XML parser
- `src/Metadata/MetadataFetcher.php` — cached $metadata fetcher

Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,38 @@ All notable changes to this package are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Script execution** — `ScriptInvoker` class providing FileMaker script
invocation at three scopes (database, entity-set, record) by name or by
FMSID (v26+). Integrated into `Client::script()`, `Client::scriptById()`,
`Query::script()`, and `EntityRef::script()`. Non-zero script errors are
promoted to `FMScriptError`. Handles both the nested (v26+) and flat
(older FMS) response envelopes via the spec-php `Scripts` facade.
- **Container field I/O** — `ContainerRef` class providing container
download (`get()`, `getStream()`), upload (binary and base64 encoding),
and clear (`delete()`). MIME sniffing from magic bytes, Content-Disposition
parsing (RFC 6266 + RFC 5987), and base64 JSON annotation building are
reused from the spec-php `Containers` facade. Integrated into
`EntityRef::container()` and `EntityRef::patchContainers()` (multi-field
container + regular field updates in a single PATCH).
- `ContainerDownloadResult` DTO extending the spec-php
`ContainerDownload` shape with a `size` field for parity with the JS and
Python clients.
- `ResponseFactory::binary()` test helper for binary response construction.
- 41 new tests covering script invocation (URLs, scopes, FMSID, envelopes,
error promotion) and container I/O (download, upload, sniffing, base64,
clear, patchContainers, Content-Disposition parsing).

### Docs

- README: updated Status section (no longer "MVP"), added script and
container usage examples to Quick start, added ScriptInvoker and
ContainerRef to Architecture, added Roadmap section listing unported
features ($batch, schema DDL, webhooks), updated test count to 238.

## [1.0.1] — 2026-07-15

### Fixed
Expand Down
74 changes: 69 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ and [fms-odata-py](https://github.com/fsans/fms-odata-py).

## Status

**MVP** — core client functionality is implemented: configuration, cURL-backed
HTTP transport with auth and 401 retry, fluent query builder, entity CRUD,
metadata parsing, version detection, and feature gating.
Core client functionality is implemented: configuration, cURL-backed HTTP
transport with auth and 401 retry, fluent query builder, entity CRUD,
script execution, container field I/O, metadata parsing, version
detection, and feature gating.

> **Note:** `$batch`, schema DDL, and webhook management are not yet
> ported from the JS/Python clients. See the roadmap section below.

## Spec alignment

Expand Down Expand Up @@ -79,6 +83,53 @@ $client->from('Contacts')->byKey(42)->patch(
// Delete a record
$client->from('Contacts')->byKey(42)->delete();

// Run a FileMaker script at database scope
$result = $client->script('Greet', parameter: 'world');
echo $result->resultParameter; // text result from Exit Script

// Run a script by FMSID (v26+; survives renames)
$client->scriptById(42, parameter: 'hello');

// Run a script at entity-set scope
$client->from('Contacts')->script('ValidateAll');

// Run a script at record scope (current record = this entity)
$client->from('Contacts')->byKey(42)->script('Notify', parameter: 'updated');

// Download a container field
$dl = $client->from('Contacts')->byKey(42)->container('Photo')->get();
echo $dl->size; // byte length
echo $dl->contentType; // e.g. image/png
echo $dl->filename; // parsed from Content-Disposition
\file_put_contents('/tmp/photo.png', $dl->data);

// Upload to a container field (binary, MIME auto-sniffed from magic bytes)
$client->from('Contacts')->byKey(42)->container('Photo')->upload(
data: \file_get_contents('/tmp/photo.png'),
filename: 'photo.png',
);

// Upload via base64 JSON (for multi-field or mixed container+regular updates)
use FmsOData\Spec\Containers\ContainerEncoding;
$client->from('Contacts')->byKey(42)->container('Photo')->upload(
data: \file_get_contents('/tmp/photo.png'),
contentType: 'image/png',
filename: 'photo.png',
encoding: ContainerEncoding::BASE64,
);

// Update multiple container fields + regular fields in one request
$client->from('Contacts')->byKey(42)->patchContainers(
[
'Photo' => ['data' => $pngBytes, 'contentType' => 'image/png', 'filename' => 'p.png'],
'Doc' => ['data' => $pdfBytes, 'contentType' => 'application/pdf'],
],
['name' => 'Jane'],
);

// Clear a container field
$client->from('Contacts')->byKey(42)->container('Photo')->delete();

// Fetch metadata
$metadata = $client->metadata();
echo $metadata->namespace . "\n";
Expand All @@ -98,10 +149,14 @@ The client is layered into pure request builders and an HTTP execution layer:

- **Query, Filter, EntityRef, Url** — build pure request data (URLs, filter
expressions, key references). No direct cURL calls.
- **ScriptInvoker** — FileMaker script invocation at database, entity-set,
and record scope (by name or FMSID). Reuses spec-php script helpers.
- **ContainerRef** — container field download (binary), upload (binary or
base64), and clear. MIME sniffing and Content-Disposition via spec-php.
- **HttpClient** — handles auth, OData headers, 401 retry, JSON/XML decoding,
and error conversion. Uses a `TransportInterface` for the actual HTTP call.
- **CurlTransport** — the production transport, using PHP's built-in cURL
extension. The only transport shipped with the MVP.
extension. The only transport shipped.
- **MockTransport** (test only) — deterministic in-memory transport for
offline test execution.
- **MetadataParser / MetadataFetcher** — parse `$metadata` XML and cache the
Expand All @@ -125,11 +180,20 @@ are sourced from `FmsOData\Spec` (the `fsans/fms-odata-spec-php` package).
- `Prefer` header: `return=minimal` (default) or `return=representation`.
- `ETag`/`If-Match`: for optimistic concurrency on PATCH and DELETE.

## Roadmap

The following features are implemented in the JS and Python clients but not
yet ported to PHP:

- `$batch` multipart request composer
- Schema DDL (create/delete tables, fields, indexes)
- Webhook management (create, remove, get, invoke)

## Development

```bash
composer install
composer test # PHPUnit (197 tests)
composer test # PHPUnit (238 tests)
composer analyse # PHPStan level max
composer check # tests + static analysis
```
Expand Down
29 changes: 29 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
use FmsOData\Metadata\MetadataFetcher;
use FmsOData\Metadata\MetadataOptions;
use FmsOData\Query\Query;
use FmsOData\Scripts\ScriptInvoker;
use FmsOData\Spec\Metadata\FMServerVersion;
use FmsOData\Spec\Scripts\ScriptResult;
use FmsOData\Spec\Metadata\Metadata;
use FmsOData\Spec\Metadata\ODataMetadata;
use FmsOData\Spec\Versions\FMVersionInfo;
Expand Down Expand Up @@ -94,6 +96,33 @@ public function from(string $entitySet): Query
return new Query($this->http, $this->baseUrl, $entitySet);
}

/**
* Invoke a FileMaker script at database scope.
*
* A non-zero `scriptError` is thrown as `FMScriptError`.
*
* @param string|int|float|array<string, mixed>|null $parameter
*
* @see https://github.com/fsans/fms-odata-spec/blob/main/docs/06-scripts.md
*/
public function script(string $name, string|int|float|array|null $parameter = null): ScriptResult
{
return (new ScriptInvoker($this->http, $this->baseUrl))->run($name, $parameter);
}

/**
* Invoke a FileMaker script by its immutable FMSID.
*
* Requires FileMaker Server 2026+ (v26). Use
* `$client->hasFeature('scriptsByFMSID')` to check before calling.
*
* @param string|int|float|array<string, mixed>|null $parameter
*/
public function scriptById(int $fmsid, string|int|float|array|null $parameter = null): ScriptResult
{
return (new ScriptInvoker($this->http, $this->baseUrl))->runById($fmsid, $parameter);
}

public function request(string $pathOrUrl, ?HttpRequestOptions $options = null): mixed
{
return $this->http->sendJson($this->resolveUrl($pathOrUrl), $options);
Expand Down
24 changes: 24 additions & 0 deletions src/Containers/ContainerDownloadResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace FmsOData\Containers;

/**
* Result of a successful container download.
*
* Mirrors the JS (`ContainerDownload.size`) and Python
* (`ContainerDownload.size`) client result shapes. The spec-php
* {@see \FmsOData\Spec\Containers\ContainerDownload} is `final` and lacks
* a `size` field, so this client-side DTO carries the same fields plus
* `size` for parity across the three language clients.
*/
final readonly class ContainerDownloadResult
{
public function __construct(
public string $data,
public string $contentType,
public ?string $filename = null,
public int $size = 0,
) {}
}
Loading
Loading