feat: M2 database migration runner with Phinx and initial schema
This commit is contained in:
1 parent
d3e8cf8fae
commit
93d991703c
20 files changed
+2000
-14
No files matched your search
+2
-2
@@ -30,8 +30,8 @@ composer test
|
|||||||
```
|
```
|
||||||
|
|
||||||
Commit both `composer.lock` and `package-lock.json` for reproducible builds.
|
Commit both `composer.lock` and `package-lock.json` for reproducible builds.
|
||||||
`migrate` and `seed` are unavailable until M2 (#12); they exit nonzero and leave
|
`migrate` and `seed` are available via `./dev.sh` (M2 #12). Never remove volumes
|
||||||
#10 open. Never remove volumes with `down -v`; the wrapper forbids it.
|
with `down -v`; the wrapper forbids it.
|
||||||
|
|
||||||
`composer lint` enforces PSR-12 on PHP in `src`, `public`, `config`, `bin`,
|
`composer lint` enforces PSR-12 on PHP in `src`, `public`, `config`, `bin`,
|
||||||
`templates`, `db/migrations` and `tests`. `composer test` runs foundation checks
|
`templates`, `db/migrations` and `tests`. `composer test` runs foundation checks
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ and Node tooling runs inside containers.
|
|||||||
```
|
```
|
||||||
|
|
||||||
`composer test` and `composer lint` also work on a host with PHP 8.3+ and Composer 2.
|
`composer test` and `composer lint` also work on a host with PHP 8.3+ and Composer 2.
|
||||||
`composer audit` checks locked PHP dependencies. `migrate` and `seed` are unavailable
|
`composer audit` checks locked PHP dependencies. `migrate` and `seed` are available
|
||||||
until M2 (#12); they exit nonzero and leave #10 open.
|
via `./dev.sh` (M2 #12).
|
||||||
|
|
||||||
Asset builds use a locked `package-lock.json` inside a container; host Node is not
|
Asset builds use a locked `package-lock.json` inside a container; host Node is not
|
||||||
required. The generated `public/build/app.css` is served statically by nginx.
|
required. The generated `public/build/app.css` is served statically by nginx.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^12.0",
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"robmorgan/phinx": "^0.16.12",
|
||||||
"squizlabs/php_codesniffer": "^3.13.5"
|
"squizlabs/php_codesniffer": "^3.13.5"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
Generated
+1534
-1
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial schema: folders mirroring the managed library tree.
|
||||||
|
*
|
||||||
|
* Folders form a recursive tree structure where each folder has an optional
|
||||||
|
* parent folder. The root folder has parent_id = NULL. The path is stored
|
||||||
|
* as a materialized absolute path from the library root for efficient queries.
|
||||||
|
*/
|
||||||
|
final class CreateFolders extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function change(): void
|
||||||
|
{
|
||||||
|
$this->table('folders', ['id' => 'id', 'primary_key' => 'id'])
|
||||||
|
->addColumn('parent_id', 'integer', ['null' => true, 'signed' => false])
|
||||||
|
->addColumn('name', 'string', ['limit' => 255])
|
||||||
|
->addColumn('path', 'text', ['null' => false])
|
||||||
|
->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
|
||||||
|
->addForeignKey('parent_id', 'folders', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->addIndex(['parent_id', 'name'], ['unique' => true, 'name' => 'idx_folders_parent_name'])
|
||||||
|
->addIndex(['path'], ['unique' => true, 'name' => 'idx_folders_path'])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Root folder entry (will be created by application on first run)
|
||||||
|
// Application ensures root folder exists with path = '/' and parent_id = NULL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema: models as logical catalog items.
|
||||||
|
*
|
||||||
|
* Each model belongs to a folder and represents a logical 3D model consisting
|
||||||
|
* of one or more files (STL parts, images, documents). Models have metadata
|
||||||
|
* including categories, tags, notes, and optional source URL.
|
||||||
|
*/
|
||||||
|
final class CreateModels extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function change(): void
|
||||||
|
{
|
||||||
|
$this->table('models', ['id' => 'id', 'primary_key' => 'id'])
|
||||||
|
->addColumn('folder_id', 'integer', ['null' => false, 'signed' => false])
|
||||||
|
->addColumn('name', 'string', ['limit' => 255])
|
||||||
|
->addColumn('notes', 'text', ['null' => true])
|
||||||
|
->addColumn('source_url', 'string', ['limit' => 2048, 'null' => true])
|
||||||
|
->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
|
||||||
|
->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
|
||||||
|
->addForeignKey('folder_id', 'folders', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
|
||||||
|
->addIndex(['folder_id', 'name'], ['unique' => true, 'name' => 'idx_models_folder_name'])
|
||||||
|
->addIndex(['added_at'], ['name' => 'idx_models_added_at'])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema: files with hash, size, and lifecycle status.
|
||||||
|
*
|
||||||
|
* Files belong to a model and represent physical files in the managed library.
|
||||||
|
* Each file has a SHA-256 hash for change detection, size for space tracking,
|
||||||
|
* and a lifecycle status (Present, Missing, Updated, Duplicate).
|
||||||
|
* The managed path is relative to the library root.
|
||||||
|
*/
|
||||||
|
final class CreateFiles extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function change(): void
|
||||||
|
{
|
||||||
|
$this->table('files', ['id' => 'id', 'primary_key' => 'id'])
|
||||||
|
->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
|
||||||
|
->addColumn('folder_id', 'integer', ['null' => false, 'signed' => false])
|
||||||
|
->addColumn('name', 'string', ['limit' => 255])
|
||||||
|
->addColumn('managed_path', 'text', ['null' => false])
|
||||||
|
->addColumn('hash', 'string', ['limit' => 64, 'null' => true]) // SHA-256 hex
|
||||||
|
->addColumn('size', 'biginteger', ['null' => true, 'signed' => false])
|
||||||
|
->addColumn('mime_type', 'string', ['limit' => 128, 'null' => true])
|
||||||
|
->addColumn('status', 'string', ['limit' => 32, 'default' => 'Present'])
|
||||||
|
->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
|
||||||
|
->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
|
||||||
|
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->addForeignKey('folder_id', 'folders', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
|
||||||
|
->addIndex(['model_id', 'managed_path'], ['unique' => true, 'name' => 'idx_files_model_path'])
|
||||||
|
->addIndex(['hash'], ['name' => 'idx_files_hash'])
|
||||||
|
->addIndex(['status'], ['name' => 'idx_files_status'])
|
||||||
|
->addIndex(['folder_id'], ['name' => 'idx_files_folder_id'])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema: categories, tags, and model-tag associations.
|
||||||
|
*
|
||||||
|
* Categories are hierarchical (optional parent) for broad classification.
|
||||||
|
* Tags are flat, free-form labels. Models can have multiple tags via a
|
||||||
|
* many-to-many association table. Each model has exactly one category (nullable).
|
||||||
|
*/
|
||||||
|
final class CreateCategoriesTags extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function change(): void
|
||||||
|
{
|
||||||
|
// Categories - hierarchical
|
||||||
|
$this->table('categories', ['id' => 'id', 'primary_key' => 'id'])
|
||||||
|
->addColumn('parent_id', 'integer', ['null' => true, 'signed' => false])
|
||||||
|
->addColumn('name', 'string', ['limit' => 128])
|
||||||
|
->addColumn('description', 'text', ['null' => true])
|
||||||
|
->addForeignKey('parent_id', 'categories', 'id', ['delete' => 'SET NULL', 'update' => 'NO_ACTION'])
|
||||||
|
->addIndex(['parent_id', 'name'], ['unique' => true, 'name' => 'idx_categories_parent_name'])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Tags - flat
|
||||||
|
$this->table('tags', ['id' => 'id', 'primary_key' => 'id'])
|
||||||
|
->addColumn('name', 'string', ['limit' => 128])
|
||||||
|
->addIndex(['name'], ['unique' => true, 'name' => 'idx_tags_name'])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Model-Tag many-to-many
|
||||||
|
$this->table('model_tags', ['id' => false, 'primary_key' => ['model_id', 'tag_id']])
|
||||||
|
->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
|
||||||
|
->addColumn('tag_id', 'integer', ['null' => false, 'signed' => false])
|
||||||
|
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->addForeignKey('tag_id', 'tags', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema: uploaded thumbnails.
|
||||||
|
*
|
||||||
|
* Thumbnails are user-uploaded images associated with models.
|
||||||
|
* They are stored in the managed library under a thumbnails/ subdirectory
|
||||||
|
* and tracked separately from model files.
|
||||||
|
*/
|
||||||
|
final class CreateThumbnails extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function change(): void
|
||||||
|
{
|
||||||
|
$this->table('thumbnails', ['id' => 'id', 'primary_key' => 'id'])
|
||||||
|
->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
|
||||||
|
->addColumn('managed_path', 'text', ['null' => false])
|
||||||
|
->addColumn('hash', 'string', ['limit' => 64, 'null' => false]) // SHA-256 hex
|
||||||
|
->addColumn('size', 'biginteger', ['null' => false, 'signed' => false])
|
||||||
|
->addColumn('mime_type', 'string', ['limit' => 128, 'null' => false])
|
||||||
|
->addColumn('width', 'integer', ['null' => true, 'signed' => false])
|
||||||
|
->addColumn('height', 'integer', ['null' => true, 'signed' => false])
|
||||||
|
->addColumn('uploaded_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
|
||||||
|
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->addIndex(['model_id'], ['name' => 'idx_thumbnails_model_id'])
|
||||||
|
->addIndex(['hash'], ['unique' => true, 'name' => 'idx_thumbnails_hash'])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema: import jobs and per-file import items.
|
||||||
|
*
|
||||||
|
* Import jobs track the overall import operation with progress, totals,
|
||||||
|
* and status. Import items represent individual files being imported,
|
||||||
|
* with their source path, destination, status, and any failure reason.
|
||||||
|
*/
|
||||||
|
final class CreateImportJobs extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function change(): void
|
||||||
|
{
|
||||||
|
// Import jobs - overall operation tracking
|
||||||
|
$this->table('import_jobs', ['id' => 'id', 'primary_key' => 'id'])
|
||||||
|
->addColumn('source_path', 'text', ['null' => false])
|
||||||
|
// Status: Pending, Scanning, Copying, Completed, Failed, Cancelled
|
||||||
|
->addColumn('status', 'string', ['limit' => 32, 'default' => 'Pending'])
|
||||||
|
->addColumn('total_files', 'integer', ['null' => false, 'default' => 0, 'signed' => false])
|
||||||
|
->addColumn('processed_files', 'integer', ['null' => false, 'default' => 0, 'signed' => false])
|
||||||
|
->addColumn('total_bytes', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false])
|
||||||
|
->addColumn('copied_bytes', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false])
|
||||||
|
->addColumn('started_at', 'timestamp', ['null' => true, 'timezone' => true])
|
||||||
|
->addColumn('completed_at', 'timestamp', ['null' => true, 'timezone' => true])
|
||||||
|
->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
|
||||||
|
->addIndex(['status'], ['name' => 'idx_import_jobs_status'])
|
||||||
|
->addIndex(['created_at'], ['name' => 'idx_import_jobs_created_at'])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Import items - per-file tracking
|
||||||
|
$this->table('import_items', ['id' => 'id', 'primary_key' => 'id'])
|
||||||
|
->addColumn('job_id', 'integer', ['null' => false, 'signed' => false])
|
||||||
|
->addColumn('source_path', 'text', ['null' => false])
|
||||||
|
->addColumn('managed_path', 'text', ['null' => true])
|
||||||
|
->addColumn('model_id', 'integer', ['null' => true, 'signed' => false])
|
||||||
|
->addColumn('hash', 'string', ['limit' => 64, 'null' => true]) // SHA-256 hex
|
||||||
|
->addColumn('size', 'biginteger', ['null' => true, 'signed' => false])
|
||||||
|
// Status: Pending, Copying, Completed, Failed, Skipped
|
||||||
|
->addColumn('status', 'string', ['limit' => 32, 'default' => 'Pending'])
|
||||||
|
->addColumn('failure_reason', 'text', ['null' => true])
|
||||||
|
->addColumn('started_at', 'timestamp', ['null' => true, 'timezone' => true])
|
||||||
|
->addColumn('completed_at', 'timestamp', ['null' => true, 'timezone' => true])
|
||||||
|
->addForeignKey('job_id', 'import_jobs', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->addForeignKey('model_id', 'models', 'id', ['delete' => 'SET NULL', 'update' => 'NO_ACTION'])
|
||||||
|
->addIndex(['job_id', 'status'], ['name' => 'idx_import_items_job_status'])
|
||||||
|
->addIndex(['job_id', 'managed_path'], ['name' => 'idx_import_items_job_path'])
|
||||||
|
->addIndex(['hash'], ['name' => 'idx_import_items_hash'])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Seed\AbstractSeed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed script for default reference data.
|
||||||
|
*
|
||||||
|
* Populates initial categories and tags. Safe to re-run (idempotent).
|
||||||
|
*/
|
||||||
|
final class DefaultDataSeed extends AbstractSeed
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Seed default categories
|
||||||
|
$categories = [
|
||||||
|
[
|
||||||
|
'name' => 'Aircraft',
|
||||||
|
'description' => 'Planes, helicopters, and drones',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Automotive',
|
||||||
|
'description' => 'Cars, trucks, and vehicles',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Characters',
|
||||||
|
'description' => 'Humanoid and creature models',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Props',
|
||||||
|
'description' => 'Objects and accessories',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Terrain',
|
||||||
|
'description' => 'Landscapes, buildings, and scenery',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Tools',
|
||||||
|
'description' => 'Functional items and gadgets',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
$exists = $this->getAdapter()->fetchRow(
|
||||||
|
"SELECT id FROM categories WHERE name = '"
|
||||||
|
. $category['name'] . "'"
|
||||||
|
);
|
||||||
|
if (!$exists) {
|
||||||
|
$this->getAdapter()->execute(
|
||||||
|
"INSERT INTO categories (name, description) VALUES ('"
|
||||||
|
. $category['name'] . "', '"
|
||||||
|
. $category['description'] . "')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default tags
|
||||||
|
$tags = [
|
||||||
|
['name' => 'wip'],
|
||||||
|
['name' => 'completed'],
|
||||||
|
['name' => 'high-poly'],
|
||||||
|
['name' => 'low-poly'],
|
||||||
|
['name' => 'print-ready'],
|
||||||
|
['name' => 'needs-supports'],
|
||||||
|
['name' => 'articulated'],
|
||||||
|
['name' => 'modular'],
|
||||||
|
['name' => 'multi-part'],
|
||||||
|
['name' => 'single-piece'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$exists = $this->getAdapter()->fetchRow(
|
||||||
|
"SELECT id FROM tags WHERE name = '" . $tag['name'] . "'"
|
||||||
|
);
|
||||||
|
if (!$exists) {
|
||||||
|
$this->getAdapter()->execute(
|
||||||
|
"INSERT INTO tags (name) VALUES ('" . $tag['name'] . "')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,9 +4,11 @@ $Command = if ($args.Count) { $args[0] } else { '' }
|
|||||||
$Forward = @($args | Select-Object -Skip 1)
|
$Forward = @($args | Select-Object -Skip 1)
|
||||||
try {
|
try {
|
||||||
switch ($Command) {
|
switch ($Command) {
|
||||||
{ $_ -in 'migrate', 'seed' } {
|
'migrate' {
|
||||||
[Console]::Error.WriteLine("$Command unavailable until M2 (#12); #10 remains open")
|
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'app', '-c', "vendor/bin/phinx migrate $($Forward -join ' ')")
|
||||||
exit 1
|
}
|
||||||
|
'seed' {
|
||||||
|
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'app', '-c', "vendor/bin/phinx seed:run $($Forward -join ' ')")
|
||||||
}
|
}
|
||||||
'up' { $ComposeArgs = @('up', '-d', '--build') }
|
'up' { $ComposeArgs = @('up', '-d', '--build') }
|
||||||
'down' {
|
'down' {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ cd -- "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
|
|||||||
cmd=${1:-}
|
cmd=${1:-}
|
||||||
if [ "$#" -gt 0 ]; then shift; fi
|
if [ "$#" -gt 0 ]; then shift; fi
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
migrate|seed) echo "$cmd unavailable until M2 (#12); #10 remains open" >&2; exit 1 ;;
|
migrate)
|
||||||
|
exec docker compose run --rm --no-deps -T --entrypoint sh app -c "vendor/bin/phinx migrate $*" ;;
|
||||||
|
seed)
|
||||||
|
exec docker compose run --rm --no-deps -T --entrypoint sh app -c "vendor/bin/phinx seed:run $*" ;;
|
||||||
down)
|
down)
|
||||||
for arg do
|
for arg do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
- ./docker-compose.yml:/var/www/html/docker-compose.yml:ro
|
- ./docker-compose.yml:/var/www/html/docker-compose.yml:ro
|
||||||
- ./docker:/var/www/html/docker:ro
|
- ./docker:/var/www/html/docker:ro
|
||||||
- ./assets:/var/www/html/assets:ro
|
- ./assets:/var/www/html/assets:ro
|
||||||
|
- ./phinx.php:/var/www/html/phinx.php:ro
|
||||||
- type: bind
|
- type: bind
|
||||||
source: ${MESHVAULT_LIBRARY_HOST_DIR:-./library}
|
source: ${MESHVAULT_LIBRARY_HOST_DIR:-./library}
|
||||||
target: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
|
target: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
|
||||||
|
|||||||
+2
-2
@@ -53,8 +53,8 @@ independent of your current working directory and propagate exit codes.
|
|||||||
./dev.sh lint # run composer lint inside the app container
|
./dev.sh lint # run composer lint inside the app container
|
||||||
./dev.sh assets # build minified CSS inside the assets container
|
./dev.sh assets # build minified CSS inside the assets container
|
||||||
./dev.sh watch # start Tailwind watch inside the assets container
|
./dev.sh watch # start Tailwind watch inside the assets container
|
||||||
./dev.sh migrate # unavailable until M2 (#12); exits nonzero
|
./dev.sh migrate # run database migrations (M2 #12)
|
||||||
./dev.sh seed # unavailable until M2 (#12); exits nonzero
|
./dev.sh seed # seed default reference data (M2 #12)
|
||||||
```
|
```
|
||||||
|
|
||||||
`down --volumes` and `down -v` are blocked by the wrapper. Never remove the named
|
`down --volumes` and `down -v` are blocked by the wrapper. Never remove the named
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM php:8.3-fpm-bookworm
|
FROM php:8.3-fpm-bookworm
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev unzip \
|
RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev libpq-dev unzip \
|
||||||
&& docker-php-ext-install mbstring dom \
|
&& docker-php-ext-install mbstring dom pdo_pgsql \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Config\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phinx configuration for MeshVault database migrations.
|
||||||
|
*
|
||||||
|
* Uses environment variables from the application configuration (AppConfig)
|
||||||
|
* for database connection. Migrations are stored in db/migrations/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Load environment from getenv() - Compose injects these into the container
|
||||||
|
$dbHost = getenv('MESHVAULT_DB_HOST') ?: 'db';
|
||||||
|
$dbPort = (int) (getenv('MESHVAULT_DB_PORT') ?: 5432);
|
||||||
|
$dbName = getenv('MESHVAULT_DB_NAME') ?: 'meshvault';
|
||||||
|
$dbUser = getenv('MESHVAULT_DB_USER') ?: 'meshvault';
|
||||||
|
$dbPass = getenv('MESHVAULT_DB_PASSWORD') ?: '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'paths' => [
|
||||||
|
'migrations' => 'db/migrations',
|
||||||
|
'seeds' => 'db/seeds',
|
||||||
|
],
|
||||||
|
'environments' => [
|
||||||
|
'default_migration_table' => 'phinxlog',
|
||||||
|
'default_database' => 'development',
|
||||||
|
'development' => [
|
||||||
|
'adapter' => 'pgsql',
|
||||||
|
'host' => $dbHost,
|
||||||
|
'port' => $dbPort,
|
||||||
|
'name' => $dbName,
|
||||||
|
'user' => $dbUser,
|
||||||
|
'pass' => $dbPass,
|
||||||
|
'charset' => 'utf8',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'version_order' => 'creation',
|
||||||
|
'migration_base_class' => 'Phinx\Migration\AbstractMigration',
|
||||||
|
];
|
||||||
@@ -10,5 +10,12 @@
|
|||||||
<file>bin</file>
|
<file>bin</file>
|
||||||
<file>templates</file>
|
<file>templates</file>
|
||||||
<file>db/migrations</file>
|
<file>db/migrations</file>
|
||||||
|
<file>db/seeds</file>
|
||||||
<file>tests</file>
|
<file>tests</file>
|
||||||
|
<!-- Phinx migration/seed classes are global by framework convention. -->
|
||||||
|
<rule ref="PSR1.Classes.ClassDeclaration.MissingNamespace">
|
||||||
|
<include-pattern>db/migrations/*</include-pattern>
|
||||||
|
<include-pattern>db/seeds/*</include-pattern>
|
||||||
|
<severity>0</severity>
|
||||||
|
</rule>
|
||||||
</ruleset>
|
</ruleset>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MigrationTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var \PDO|null */
|
||||||
|
private static ?\PDO $pdo = null;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
parent::setUpBeforeClass();
|
||||||
|
|
||||||
|
$host = getenv('MESHVAULT_DB_HOST') ?: 'db';
|
||||||
|
$port = (int) (getenv('MESHVAULT_DB_PORT') ?: 5432);
|
||||||
|
$name = getenv('MESHVAULT_DB_NAME') ?: 'meshvault';
|
||||||
|
$user = getenv('MESHVAULT_DB_USER') ?: 'meshvault';
|
||||||
|
$pass = getenv('MESHVAULT_DB_PASSWORD') ?: '';
|
||||||
|
|
||||||
|
if ($pass === '') {
|
||||||
|
self::markTestSkipped('Database password not configured; skipping integration tests.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dsn = sprintf('pgsql:host=%s;port=%d;dbname=%s', $host, $port, $name);
|
||||||
|
try {
|
||||||
|
self::$pdo = new \PDO($dsn, $user, $pass, [
|
||||||
|
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||||||
|
]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
self::markTestSkipped('Database connection failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMigrationTablesExist(): void
|
||||||
|
{
|
||||||
|
if (self::$pdo === null) {
|
||||||
|
$this->markTestSkipped('Database connection not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedTables = [
|
||||||
|
'categories',
|
||||||
|
'files',
|
||||||
|
'folders',
|
||||||
|
'import_items',
|
||||||
|
'import_jobs',
|
||||||
|
'model_tags',
|
||||||
|
'models',
|
||||||
|
'tags',
|
||||||
|
'thumbnails',
|
||||||
|
'phinxlog',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($expectedTables as $table) {
|
||||||
|
$stmt = self::$pdo->query(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{$table}'"
|
||||||
|
);
|
||||||
|
$this->assertNotFalse($stmt, "Query failed for table {$table}");
|
||||||
|
$this->assertNotEmpty($stmt->fetch(), "Table {$table} does not exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeedDataExists(): void
|
||||||
|
{
|
||||||
|
if (self::$pdo === null) {
|
||||||
|
$this->markTestSkipped('Database connection not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = self::$pdo->query("SELECT name FROM categories ORDER BY name")->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
$this->assertContains('Aircraft', $categories);
|
||||||
|
$this->assertContains('Automotive', $categories);
|
||||||
|
$this->assertContains('Tools', $categories);
|
||||||
|
|
||||||
|
$tags = self::$pdo->query("SELECT name FROM tags ORDER BY name")->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
$this->assertContains('wip', $tags);
|
||||||
|
$this->assertContains('completed', $tags);
|
||||||
|
$this->assertContains('print-ready', $tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,13 @@ final class TaskRunnerTest extends TestCase
|
|||||||
self::assertStringContainsString('two words', $output);
|
self::assertStringContainsString('two words', $output);
|
||||||
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
|
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
|
||||||
}
|
}
|
||||||
foreach (['unknown', 'migrate', 'seed'] as $command) {
|
foreach (['migrate', 'seed'] as $command) {
|
||||||
|
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
|
||||||
|
self::assertSame(37, $code, $command . ': ' . $output);
|
||||||
|
self::assertStringContainsString('compose', $output);
|
||||||
|
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
|
||||||
|
}
|
||||||
|
foreach (['unknown'] as $command) {
|
||||||
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
|
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
|
||||||
self::assertNotSame(0, $code);
|
self::assertNotSame(0, $code);
|
||||||
self::assertStringNotContainsString('cwd=', $output);
|
self::assertStringNotContainsString('cwd=', $output);
|
||||||
|
|||||||
Reference in new issue
Block a user