From 0f5eec90bfb9a392cde2847150bbe89b2bf97267 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Sun, 9 Aug 2026 01:05:54 -0600 Subject: [PATCH 1/2] Resolve Halcyon DB template mtimes without a query per check `Halcyon\Builder::getCached()` calls `isCacheBusted()` on every warm cache hit, which calls `datasource->lastModified()`. For database-backed templates that meant a full `SELECT *` per template per request just to read `updated_at`, so the object cache removed no database traffic at all -- it only saved the parse. `getAvailablePaths()` already builds a forever-cached manifest of the paths held by the datasource in a single query, and it is invalidated on every insert/update/delete. Recording each live record's modification time in that manifest lets consumers resolve mtimes with no additional query. Timestamps are truthy, so the existence/deletion contract of the map is unchanged and existing consumers keep working; the paths cache key is versioned so stale boolean payloads are rebuilt rather than misread. Because the manifest may also be supplied by the `halcyon.datasource.db.beforeGetAvailablePaths` event, which returns plain booleans, the timestamps are an optimization that consumers must not rely on being present. The docblocks on both the interface and this implementation are updated to describe the widened return shape rather than inheriting the old boolean-only contract. Also narrows `lastModified()` to select only `updated_at` instead of the whole row, which avoids dragging the template content across the wire on the paths that still query. The surrounding try/catch only existed to swallow the null-property error on a missing record, so it is replaced with an explicit check. Out of scope: `updated_at` is nullable, and `Carbon::parse(null)` resolves to "now" in `selectOne()`, `lastModified()` and the manifest alike, so records with a null `updated_at` never agree on an mtime and bust their cache on every request. That is pre-existing and unchanged here; such records cannot be produced through `insert()` or `update()`, which always set the column. Co-Authored-By: Claude Opus 5 (1M context) --- .../Datasource/DatasourceInterface.php | 12 +- src/Halcyon/Datasource/DbDatasource.php | 36 +++-- tests/Halcyon/DbDatasourceTest.php | 132 ++++++++++++++++++ 3 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 tests/Halcyon/DbDatasourceTest.php diff --git a/src/Halcyon/Datasource/DatasourceInterface.php b/src/Halcyon/Datasource/DatasourceInterface.php index 1c1fc25a9..4127d9b85 100644 --- a/src/Halcyon/Datasource/DatasourceInterface.php +++ b/src/Halcyon/Datasource/DatasourceInterface.php @@ -118,19 +118,23 @@ public function getPathsCacheKey(): string; /** * Get all available paths within this datasource. * - * This method returns an array, with all available paths as the key, and a boolean that represents whether the path - * can be handled or modified. + * This method returns an array, with all available paths as the key, and a value that represents whether the path + * can be handled or modified. A falsy value means the path cannot be handled; any truthy value means it can. + * + * Datasources that are able to determine a path's modification time cheaply may return that timestamp as the + * truthy value, allowing consumers to resolve modification times without a further lookup. * * Example: * * ```php * [ * 'path/to/file.md' => true, // (this path is available, and can be handled) - * 'path/to/file2.md' => false // (this path is available, but cannot be handled) + * 'path/to/file2.md' => 1559390400, // (as above, and was last modified at this timestamp) + * 'path/to/file3.md' => false // (this path is available, but cannot be handled) * ] * ``` * - * @return array An array of available paths alongside whether they can be handled. + * @return array An array of available paths alongside whether they can be handled. */ public function getAvailablePaths(): array; } diff --git a/src/Halcyon/Datasource/DbDatasource.php b/src/Halcyon/Datasource/DbDatasource.php index 5d83c4b04..90fb02169 100644 --- a/src/Halcyon/Datasource/DbDatasource.php +++ b/src/Halcyon/Datasource/DbDatasource.php @@ -341,25 +341,37 @@ public function delete(string $dirName, string $fileName, string $extension): bo */ public function lastModified(string $dirName, string $fileName, string $extension): ?int { - try { - return Carbon::parse($this->getQuery() - ->where('path', $this->makeFilePath($dirName, $fileName, $extension)) - ->first()->updated_at)->timestamp; - } catch (Exception $ex) { - return null; - } + $record = $this->getQuery() + ->select('updated_at') + ->where('path', $this->makeFilePath($dirName, $fileName, $extension)) + ->first(); + + return $record ? Carbon::parse($record->updated_at)->timestamp : null; } /** * @inheritDoc + * + * The key is versioned because the payload shape has changed; see getAvailablePaths(). */ public function getPathsCacheKey(): string { - return 'halcyon-datastore-db-' . $this->table . '-' . $this->source; + return 'halcyon-datastore-db-v2-' . $this->table . '-' . $this->source; } /** - * @inheritDoc + * Get all available paths within this datasource. + * + * Live records are mapped to their last modification timestamp rather than `true` so that + * consumers can resolve mtimes without a further query. Timestamps are truthy, so the + * existence / deletion contract of this map is unchanged. + * + * Note that the `halcyon.datasource.db.beforeGetAvailablePaths` event below may still + * return plain booleans, so consumers must treat the timestamps as an optimization rather + * than something they can rely on being present. + * + * @return array Paths that cannot be handled are `false`; live paths are + * a timestamp, or `true` when supplied by the event below. **/ public function getAvailablePaths(): array { @@ -377,14 +389,16 @@ public function getAvailablePaths(): array if (!$pathsCache = $this->fireEvent('halcyon.datasource.db.beforeGetAvailablePaths', [], true)) { // Only query for what is required $this->bindEventOnce('halcyon.datasource.db.extendQuery', function ($query, $ignoreDeleted) { - $query->addSelect('source', 'path', 'deleted_at'); + $query->addSelect('source', 'path', 'deleted_at', 'updated_at'); }); // Get all records stored in the DB $records = $this->getQuery(false)->get(); foreach ($records as $record) { - $pathsCache[$record->path] = !$record->deleted_at; + $pathsCache[$record->path] = $record->deleted_at + ? false + : Carbon::parse($record->updated_at)->timestamp; } } diff --git a/tests/Halcyon/DbDatasourceTest.php b/tests/Halcyon/DbDatasourceTest.php new file mode 100644 index 000000000..e9152714b --- /dev/null +++ b/tests/Halcyon/DbDatasourceTest.php @@ -0,0 +1,132 @@ +getSchemaBuilder()->create(self::TABLE, function ($table) { + $table->increments('id'); + $table->string('source')->index(); + $table->string('path')->index(); + $table->longText('content'); + $table->integer('file_size')->unsigned(); + $table->dateTime('updated_at')->nullable(); + $table->dateTime('deleted_at')->nullable(); + }); + + DB::table(self::TABLE)->insert([ + [ + 'source' => 'test', + 'path' => 'pages/index.htm', + 'content' => 'Index page', + 'file_size' => 10, + 'updated_at' => '2019-06-01 12:00:00', + 'deleted_at' => null, + ], + [ + 'source' => 'test', + 'path' => 'pages/deleted.htm', + 'content' => 'Deleted page', + 'file_size' => 12, + 'updated_at' => '2019-06-02 12:00:00', + 'deleted_at' => '2019-06-03 12:00:00', + ], + [ + 'source' => 'other', + 'path' => 'pages/other.htm', + 'content' => 'Other source page', + 'file_size' => 17, + 'updated_at' => '2019-06-04 12:00:00', + 'deleted_at' => null, + ], + ]); + + $this->datasource = new DbDatasource('test', self::TABLE); + } + + public function tearDown(): void + { + DB::connection()->getSchemaBuilder()->dropIfExists(self::TABLE); + + parent::tearDown(); + } + + public function testGetAvailablePathsReturnsTimestampsForLiveRecords() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertSame(strtotime('2019-06-01 12:00:00'), $paths['pages/index.htm']); + } + + public function testGetAvailablePathsReturnsFalseForDeletedRecords() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertFalse($paths['pages/deleted.htm']); + } + + public function testGetAvailablePathsIsScopedToTheSource() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertArrayNotHasKey('pages/other.htm', $paths); + } + + public function testGetAvailablePathsHonoursTheBeforeEvent() + { + // The documented event contract returns booleans, which must keep working + $this->datasource->bindEvent('halcyon.datasource.db.beforeGetAvailablePaths', function () { + return ['pages/from-event.htm' => true, 'pages/gone.htm' => false]; + }); + + $this->assertSame( + ['pages/from-event.htm' => true, 'pages/gone.htm' => false], + $this->datasource->getAvailablePaths() + ); + } + + public function testLastModifiedReturnsTheTimestamp() + { + $this->assertSame( + strtotime('2019-06-01 12:00:00'), + $this->datasource->lastModified('pages', 'index', 'htm') + ); + } + + public function testLastModifiedReturnsNullForMissingRecord() + { + $this->assertNull($this->datasource->lastModified('pages', 'nope', 'htm')); + } + + public function testLastModifiedDoesNotSelectTheContentColumn() + { + DB::connection()->flushQueryLog(); + DB::connection()->enableQueryLog(); + + $this->datasource->lastModified('pages', 'index', 'htm'); + + $queries = DB::connection()->getQueryLog(); + DB::connection()->disableQueryLog(); + + // Selecting the whole row just to read a timestamp drags the template content + // across the wire on every cache validation + $this->assertCount(1, $queries); + $this->assertStringNotContainsString('*', $queries[0]['query']); + $this->assertStringContainsString('updated_at', $queries[0]['query']); + } +} From 0e7d001b90a7329816c9b8a80ca74527f9e9434f Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Mon, 10 Aug 2026 02:21:05 -0600 Subject: [PATCH 2/2] Add full coverage for both Halcyon datasources Neither `DbDatasource` nor `FileDatasource` had any test coverage. Since this branch changes the manifest contract they share, cover both properly rather than only the lines that moved. `FileDatasource::getAvailablePaths()` deliberately still reports `true` rather than a modification time, and there is now a test that says so. Reporting timestamps there would be actively harmful: unlike a database round trip, a filesystem mtime is a cheap local stat, and baking it into the forever-cached paths manifest would mean template edits on disk -- a deploy, for instance -- go unnoticed until that manifest is rebuilt. `AutoDatasource` relies on the `true` value to fall through to a live `filemtime()`. Between them the two suites now cover selectOne/select (column, extension and fileMatch filters), insert/update/delete/forceDelete including renames and extension changes, lastModified, the paths manifest, cache keys and the post processor -- plus the behaviours specific to each: soft deletion, source scoping, reviving deleted records and the beforeInsert/beforeUpdate/extendQuery events for the database, and nested directory creation, path traversal handling and live mtimes for the filesystem. Two behaviours worth noting, both asserted as they actually are rather than as they might be assumed: `FileDatasource::selectOne()` swallows the path traversal exception along with every other read error, so escaping the base path returns null rather than throwing, and `DbDatasource::delete()` throws for an already soft-deleted record because its query excludes them. Co-Authored-By: Claude Opus 5 (1M context) --- tests/Halcyon/DbDatasourceTest.php | 314 ++++++++++++++++++++++++ tests/Halcyon/FileDatasourceTest.php | 347 +++++++++++++++++++++++++++ 2 files changed, 661 insertions(+) create mode 100644 tests/Halcyon/FileDatasourceTest.php diff --git a/tests/Halcyon/DbDatasourceTest.php b/tests/Halcyon/DbDatasourceTest.php index e9152714b..95a07513f 100644 --- a/tests/Halcyon/DbDatasourceTest.php +++ b/tests/Halcyon/DbDatasourceTest.php @@ -4,6 +4,9 @@ use Illuminate\Support\Facades\DB; use Winter\Storm\Halcyon\Datasource\DbDatasource; +use Winter\Storm\Halcyon\Exception\DeleteFileException; +use Winter\Storm\Halcyon\Exception\FileExistsException; +use Winter\Storm\Halcyon\Processors\Processor; use Winter\Storm\Tests\DbTestCase; class DbDatasourceTest extends DbTestCase @@ -38,6 +41,22 @@ public function setUp(): void 'updated_at' => '2019-06-01 12:00:00', 'deleted_at' => null, ], + [ + 'source' => 'test', + 'path' => 'pages/about.htm', + 'content' => 'About page', + 'file_size' => 10, + 'updated_at' => '2019-06-05 12:00:00', + 'deleted_at' => null, + ], + [ + 'source' => 'test', + 'path' => 'pages/notes.md', + 'content' => 'Some notes', + 'file_size' => 10, + 'updated_at' => '2019-06-06 12:00:00', + 'deleted_at' => null, + ], [ 'source' => 'test', 'path' => 'pages/deleted.htm', @@ -66,6 +85,18 @@ public function tearDown(): void parent::tearDown(); } + /** + * Reads a row straight out of the table, bypassing the datasource. + */ + protected function rawRecord(string $path, string $source = 'test') + { + return DB::table(self::TABLE)->where('source', $source)->where('path', $path)->first(); + } + + // + // getAvailablePaths() + // + public function testGetAvailablePathsReturnsTimestampsForLiveRecords() { $paths = $this->datasource->getAvailablePaths(); @@ -100,6 +131,234 @@ public function testGetAvailablePathsHonoursTheBeforeEvent() ); } + // + // selectOne() + // + + public function testSelectOneReturnsTheRecord() + { + $result = $this->datasource->selectOne('pages', 'index', 'htm'); + + $this->assertSame('index.htm', $result['fileName']); + $this->assertSame('Index page', $result['content']); + $this->assertSame(strtotime('2019-06-01 12:00:00'), $result['mtime']); + $this->assertSame('pages/index.htm', $result['record']->path); + } + + public function testSelectOneReturnsNullForMissingRecord() + { + $this->assertNull($this->datasource->selectOne('pages', 'nope', 'htm')); + } + + public function testSelectOneIgnoresDeletedRecords() + { + $this->assertNull($this->datasource->selectOne('pages', 'deleted', 'htm')); + } + + public function testSelectOneIsScopedToTheSource() + { + $this->assertNull($this->datasource->selectOne('pages', 'other', 'htm')); + } + + // + // select() + // + + public function testSelectReturnsEveryLiveRecordInTheDirectory() + { + $results = collect($this->datasource->select('pages'))->keyBy('fileName'); + + $this->assertEqualsCanonicalizing( + ['index.htm', 'about.htm', 'notes.md'], + $results->keys()->all() + ); + $this->assertSame('Index page', $results['index.htm']['content']); + $this->assertSame(strtotime('2019-06-01 12:00:00'), $results['index.htm']['mtime']); + } + + public function testSelectExcludesDeletedRecords() + { + $results = collect($this->datasource->select('pages'))->pluck('fileName'); + + $this->assertNotContains('deleted.htm', $results->all()); + } + + public function testSelectIsScopedToTheSource() + { + $results = collect($this->datasource->select('pages'))->pluck('fileName'); + + $this->assertNotContains('other.htm', $results->all()); + } + + public function testSelectFiltersByExtension() + { + $results = collect($this->datasource->select('pages', ['extensions' => ['md']])); + + $this->assertSame(['notes.md'], $results->pluck('fileName')->all()); + } + + public function testSelectFiltersByFileMatch() + { + $results = collect($this->datasource->select('pages', ['fileMatch' => 'ab*'])); + + $this->assertSame(['about.htm'], $results->pluck('fileName')->all()); + } + + public function testSelectLimitsReturnedColumns() + { + $results = $this->datasource->select('pages', ['columns' => ['fileName']]); + + $this->assertSame(['fileName'], array_keys($results[0])); + } + + public function testSelectTreatsWildcardColumnsAsAllColumns() + { + $results = $this->datasource->select('pages', ['columns' => ['*']]); + + $this->assertEqualsCanonicalizing( + ['fileName', 'content', 'mtime', 'record'], + array_keys($results[0]) + ); + } + + // + // insert() + // + + public function testInsertCreatesTheRecordAndReturnsItsSize() + { + $size = $this->datasource->insert('pages', 'created', 'htm', 'Created page'); + + $this->assertSame(12, $size); + + $record = $this->rawRecord('pages/created.htm'); + $this->assertSame('Created page', $record->content); + $this->assertSame(12, (int) $record->file_size); + $this->assertNotNull($record->updated_at); + $this->assertNull($record->deleted_at); + } + + public function testInsertThrowsWhenThePathAlreadyExists() + { + $this->expectException(FileExistsException::class); + + $this->datasource->insert('pages', 'index', 'htm', 'Replacement'); + } + + public function testInsertRevivesASoftDeletedRecord() + { + $this->datasource->insert('pages', 'deleted', 'htm', 'Revived page'); + + $record = $this->rawRecord('pages/deleted.htm'); + $this->assertSame('Revived page', $record->content); + $this->assertNull($record->deleted_at); + + // Revived in place rather than duplicated + $this->assertSame(1, DB::table(self::TABLE)->where('path', 'pages/deleted.htm')->count()); + } + + public function testInsertFiresTheBeforeInsertEvent() + { + $this->datasource->bindEvent('halcyon.datasource.db.beforeInsert', function (&$record) { + $record['content'] = 'Rewritten by the event'; + }); + + $this->datasource->insert('pages', 'created', 'htm', 'Created page'); + + $this->assertSame('Rewritten by the event', $this->rawRecord('pages/created.htm')->content); + } + + // + // update() + // + + public function testUpdateChangesContentAndTimestamp() + { + $size = $this->datasource->update('pages', 'index', 'htm', 'Updated page'); + + $this->assertSame(12, $size); + + $record = $this->rawRecord('pages/index.htm'); + $this->assertSame('Updated page', $record->content); + $this->assertGreaterThan( + strtotime('2019-06-01 12:00:00'), + strtotime($record->updated_at) + ); + } + + public function testUpdateRenamesTheRecord() + { + $this->datasource->update('pages', 'renamed', 'htm', 'Renamed page', 'index', 'htm'); + + $this->assertNull($this->rawRecord('pages/index.htm')); + $this->assertSame('Renamed page', $this->rawRecord('pages/renamed.htm')->content); + } + + public function testUpdateChangesTheExtension() + { + $this->datasource->update('pages', 'index', 'md', 'Now markdown', 'index', 'htm'); + + $this->assertNull($this->rawRecord('pages/index.htm')); + $this->assertSame('Now markdown', $this->rawRecord('pages/index.md')->content); + } + + public function testUpdateClearsTheDeletedFlag() + { + $this->datasource->update('pages', 'deleted', 'htm', 'Restored page'); + + $this->assertNull($this->rawRecord('pages/deleted.htm')->deleted_at); + } + + public function testUpdateFiresTheBeforeUpdateEvent() + { + $this->datasource->bindEvent('halcyon.datasource.db.beforeUpdate', function (&$data) { + $data['content'] = 'Rewritten by the event'; + }); + + $this->datasource->update('pages', 'index', 'htm', 'Updated page'); + + $this->assertSame('Rewritten by the event', $this->rawRecord('pages/index.htm')->content); + } + + // + // delete() + // + + public function testDeleteSoftDeletesTheRecord() + { + $this->assertTrue($this->datasource->delete('pages', 'index', 'htm')); + + $record = $this->rawRecord('pages/index.htm'); + $this->assertNotNull($record, 'The row should be retained'); + $this->assertNotNull($record->deleted_at); + $this->assertNull($this->datasource->selectOne('pages', 'index', 'htm')); + } + + public function testDeleteThrowsWhenNoRecordMatches() + { + $this->expectException(DeleteFileException::class); + + $this->datasource->delete('pages', 'nope', 'htm'); + } + + public function testDeleteThrowsForAnAlreadyDeletedRecord() + { + $this->expectException(DeleteFileException::class); + + $this->datasource->delete('pages', 'deleted', 'htm'); + } + + public function testForceDeleteRemovesTheRecord() + { + $this->assertTrue($this->datasource->forceDelete('pages', 'index', 'htm')); + + $this->assertNull($this->rawRecord('pages/index.htm')); + } + + // + // lastModified() + // + public function testLastModifiedReturnsTheTimestamp() { $this->assertSame( @@ -113,6 +372,11 @@ public function testLastModifiedReturnsNullForMissingRecord() $this->assertNull($this->datasource->lastModified('pages', 'nope', 'htm')); } + public function testLastModifiedIgnoresDeletedRecords() + { + $this->assertNull($this->datasource->lastModified('pages', 'deleted', 'htm')); + } + public function testLastModifiedDoesNotSelectTheContentColumn() { DB::connection()->flushQueryLog(); @@ -129,4 +393,54 @@ public function testLastModifiedDoesNotSelectTheContentColumn() $this->assertStringNotContainsString('*', $queries[0]['query']); $this->assertStringContainsString('updated_at', $queries[0]['query']); } + + // + // Misc + // + + public function testExtendQueryEventAppliesToReads() + { + $this->datasource->bindEvent('halcyon.datasource.db.extendQuery', function ($query) { + $query->where('path', 'pages/about.htm'); + }); + + $results = collect($this->datasource->select('pages'))->pluck('fileName'); + + $this->assertSame(['about.htm'], $results->all()); + } + + public function testGetPathsCacheKeyIsVersionedAndScoped() + { + $this->assertSame( + 'halcyon-datastore-db-v2-' . self::TABLE . '-test', + $this->datasource->getPathsCacheKey() + ); + + // The payload shape changed from booleans to timestamps, so the key must not + // collide with manifests written by earlier versions + $this->assertStringNotContainsString( + 'halcyon-datastore-db-' . self::TABLE, + $this->datasource->getPathsCacheKey() + ); + + $other = new DbDatasource('other', self::TABLE); + $this->assertNotSame($this->datasource->getPathsCacheKey(), $other->getPathsCacheKey()); + } + + public function testMakeCacheKeyIsDeterministic() + { + $this->assertSame( + $this->datasource->makeCacheKey('pages/index.htm'), + $this->datasource->makeCacheKey('pages/index.htm') + ); + $this->assertNotSame( + $this->datasource->makeCacheKey('pages/index.htm'), + $this->datasource->makeCacheKey('pages/about.htm') + ); + } + + public function testGetPostProcessor() + { + $this->assertInstanceOf(Processor::class, $this->datasource->getPostProcessor()); + } } diff --git a/tests/Halcyon/FileDatasourceTest.php b/tests/Halcyon/FileDatasourceTest.php new file mode 100644 index 000000000..f5514458d --- /dev/null +++ b/tests/Halcyon/FileDatasourceTest.php @@ -0,0 +1,347 @@ +files = new Filesystem; + $this->basePath = __DIR__ . '/../tmp/filedatasource'; + + $this->files->deleteDirectory($this->basePath); + + $this->seedFile('pages/home.htm', 'Home page'); + $this->seedFile('pages/about.htm', 'About page'); + $this->seedFile('pages/nested/deep.htm', 'Deep page'); + $this->seedFile('pages/notes.md', 'Some notes'); + $this->seedFile('content/welcome.md', 'Welcome'); + + // Fixed timestamps keep the mtime assertions deterministic + touch($this->basePath . '/pages/home.htm', strtotime('2019-06-01 12:00:00')); + + $this->datasource = new FileDatasource($this->basePath, $this->files); + } + + public function tearDown(): void + { + $this->files->deleteDirectory($this->basePath); + + parent::tearDown(); + } + + protected function seedFile(string $path, string $content): void + { + $full = $this->basePath . '/' . $path; + + $this->files->makeDirectory(dirname($full), 0777, true, true); + $this->files->put($full, $content); + } + + // + // getAvailablePaths() + // + + public function testGetAvailablePathsReturnsTrueForEveryPath() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertNotEmpty($paths); + + // Deliberately `true` rather than a modification time. DbDatasource reports + // timestamps so consumers can skip a database round trip, but resolving a file's + // mtime is a cheap local stat and must stay live -- baking it into the paths cache + // would mean template edits on disk (a deploy, for instance) are not picked up + // until that forever-cached manifest is rebuilt. + foreach ($paths as $path => $value) { + $this->assertTrue($value, "Expected true for {$path}"); + } + } + + public function testGetAvailablePathsListsEveryFileRecursively() + { + $paths = $this->datasource->getAvailablePaths(); + + $this->assertEqualsCanonicalizing([ + 'pages/home.htm', + 'pages/about.htm', + 'pages/nested/deep.htm', + 'pages/notes.md', + 'content/welcome.md', + ], array_keys($paths)); + } + + public function testGetAvailablePathsIsEmptyWhenBasePathIsMissing() + { + $datasource = new FileDatasource($this->basePath . '/nope', $this->files); + + $this->assertSame([], $datasource->getAvailablePaths()); + } + + // + // selectOne() + // + + public function testSelectOneReturnsContentAndMtime() + { + $result = $this->datasource->selectOne('pages', 'home', 'htm'); + + $this->assertSame('home.htm', $result['fileName']); + $this->assertSame('Home page', $result['content']); + $this->assertSame(strtotime('2019-06-01 12:00:00'), $result['mtime']); + } + + public function testSelectOneReadsNestedFiles() + { + $result = $this->datasource->selectOne('pages', 'nested/deep', 'htm'); + + $this->assertSame('Deep page', $result['content']); + } + + public function testSelectOneReturnsNullForMissingFile() + { + $this->assertNull($this->datasource->selectOne('pages', 'nope', 'htm')); + } + + // + // select() + // + + public function testSelectReturnsEveryFileInTheDirectory() + { + $results = collect($this->datasource->select('pages'))->keyBy('fileName'); + + $this->assertEqualsCanonicalizing( + ['home.htm', 'about.htm', 'notes.md', 'nested/deep.htm'], + $results->keys()->all() + ); + $this->assertSame('Home page', $results['home.htm']['content']); + $this->assertSame(strtotime('2019-06-01 12:00:00'), $results['home.htm']['mtime']); + } + + public function testSelectFiltersByExtension() + { + $results = collect($this->datasource->select('pages', ['extensions' => ['md']])); + + $this->assertSame(['notes.md'], $results->pluck('fileName')->all()); + } + + public function testSelectFiltersByFileMatch() + { + $results = collect($this->datasource->select('pages', ['fileMatch' => 'ab*'])); + + $this->assertSame(['about.htm'], $results->pluck('fileName')->all()); + } + + public function testSelectLimitsReturnedColumns() + { + $results = $this->datasource->select('pages', ['columns' => ['fileName']]); + + $this->assertSame(['fileName'], array_keys($results[0])); + } + + public function testSelectTreatsWildcardColumnsAsAllColumns() + { + $results = $this->datasource->select('pages', ['columns' => ['*']]); + + $this->assertEqualsCanonicalizing(['fileName', 'content', 'mtime'], array_keys($results[0])); + } + + public function testSelectReturnsEmptyForMissingDirectory() + { + $this->assertSame([], $this->datasource->select('nope')); + } + + // + // insert() + // + + public function testInsertCreatesTheFileAndReturnsItsSize() + { + $size = $this->datasource->insert('pages', 'created', 'htm', 'Created page'); + + $this->assertSame(12, $size); + $this->assertSame('Created page', $this->files->get($this->basePath . '/pages/created.htm')); + } + + public function testInsertCreatesMissingDirectories() + { + $this->datasource->insert('layouts', 'sub/created', 'htm', 'Created layout'); + + $this->assertSame('Created layout', $this->files->get($this->basePath . '/layouts/sub/created.htm')); + } + + public function testInsertThrowsWhenTheFileAlreadyExists() + { + $this->expectException(FileExistsException::class); + + $this->datasource->insert('pages', 'home', 'htm', 'Replacement'); + } + + // + // update() + // + + public function testUpdateOverwritesContentAndReturnsItsSize() + { + $size = $this->datasource->update('pages', 'home', 'htm', 'Updated page'); + + $this->assertSame(12, $size); + $this->assertSame('Updated page', $this->datasource->selectOne('pages', 'home', 'htm')['content']); + } + + public function testUpdateRenamesTheFile() + { + $this->datasource->update('pages', 'renamed', 'htm', 'Renamed page', 'home', 'htm'); + + $this->assertNull($this->datasource->selectOne('pages', 'home', 'htm')); + $this->assertSame('Renamed page', $this->datasource->selectOne('pages', 'renamed', 'htm')['content']); + } + + public function testUpdateChangesTheExtension() + { + $this->datasource->update('pages', 'home', 'md', 'Now markdown', 'home', 'htm'); + + $this->assertNull($this->datasource->selectOne('pages', 'home', 'htm')); + $this->assertSame('Now markdown', $this->datasource->selectOne('pages', 'home', 'md')['content']); + } + + public function testUpdateAllowsRenamingWhenOnlyTheCaseChanges() + { + $this->datasource->update('pages', 'Home', 'htm', 'Recased page', 'home', 'htm'); + + $this->assertSame('Recased page', $this->datasource->selectOne('pages', 'Home', 'htm')['content']); + } + + public function testUpdateThrowsWhenRenamingOntoAnExistingFile() + { + $this->expectException(FileExistsException::class); + + $this->datasource->update('pages', 'about', 'htm', 'Clobbered', 'home', 'htm'); + } + + // + // delete() + // + + public function testDeleteRemovesTheFile() + { + $this->assertTrue($this->datasource->delete('pages', 'home', 'htm')); + $this->assertNull($this->datasource->selectOne('pages', 'home', 'htm')); + } + + public function testForceDeleteRemovesTheFile() + { + $this->assertTrue($this->datasource->forceDelete('pages', 'home', 'htm')); + $this->assertNull($this->datasource->selectOne('pages', 'home', 'htm')); + } + + public function testDeleteReturnsFalseForMissingFile() + { + $this->assertFalse($this->datasource->delete('pages', 'nope', 'htm')); + } + + // + // lastModified() + // + + public function testLastModifiedReturnsTheFileMtime() + { + $this->assertSame( + strtotime('2019-06-01 12:00:00'), + $this->datasource->lastModified('pages', 'home', 'htm') + ); + } + + public function testLastModifiedReturnsNullForMissingFile() + { + $this->assertNull($this->datasource->lastModified('pages', 'nope', 'htm')); + } + + public function testLastModifiedTracksChangesOnDisk() + { + $before = $this->datasource->lastModified('pages', 'home', 'htm'); + + touch($this->basePath . '/pages/home.htm', strtotime('2020-01-01 12:00:00')); + + // Filesystem mtimes are resolved live, so edits on disk are picked up immediately + $this->assertNotSame($before, $this->datasource->lastModified('pages', 'home', 'htm')); + } + + // + // Path handling + // + + public function testReadingPathsOutsideTheBasePathReturnsNothing() + { + // makeDirectoryPath() throws for paths that escape the base path, but selectOne() + // swallows it along with every other read error, so traversal is denied quietly + $this->assertNull($this->datasource->selectOne('pages', '../../../etc/passwd', 'htm')); + $this->assertNull($this->datasource->lastModified('pages', '../../../etc/passwd', 'htm')); + } + + public function testInsertRejectsPathsOutsideTheBasePath() + { + $this->expectException(InvalidFileNameException::class); + + $this->datasource->insert('pages', '../escaped', 'htm', 'Nope'); + } + + // + // Misc + // + + public function testGetBasePath() + { + $this->assertSame($this->basePath, $this->datasource->getBasePath()); + } + + public function testGetPathsCacheKeyIsScopedToTheBasePath() + { + $this->assertSame('halcyon-datastore-file-' . $this->basePath, $this->datasource->getPathsCacheKey()); + + $other = new FileDatasource($this->basePath . '/other', $this->files); + $this->assertNotSame($this->datasource->getPathsCacheKey(), $other->getPathsCacheKey()); + } + + public function testMakeCacheKeyIsDeterministic() + { + $this->assertSame( + $this->datasource->makeCacheKey('pages/home.htm'), + $this->datasource->makeCacheKey('pages/home.htm') + ); + $this->assertNotSame( + $this->datasource->makeCacheKey('pages/home.htm'), + $this->datasource->makeCacheKey('pages/about.htm') + ); + } + + public function testGetPostProcessor() + { + $this->assertInstanceOf(Processor::class, $this->datasource->getPostProcessor()); + } +}