diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ca503f0a2..5bf14ab4a 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -104,6 +104,70 @@ jobs: - name: Validate Doctrine schema run: APP_ENV=prod php bin/console doctrine:schema:validate + validate-doctrine-schema-postgres: + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://db:db@127.0.0.1:5432/db?serverVersion=16&charset=utf8 + strategy: + fail-fast: false + matrix: + php: ["8.3"] + name: Validate Doctrine Schema on Postgres (PHP ${{ matrix.php }}) + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: db + POSTGRES_PASSWORD: db + POSTGRES_DB: db + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U db -d db" + --health-interval=5s + --health-timeout=3s + --health-retries=10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php}} + # pgsql/pdo_pgsql added on top of the standard extension list so + # Doctrine can connect to Postgres for the portability gate. + extensions: apcu, ctype, iconv, imagick, json, pdo_pgsql, pgsql, redis, soap, xmlreader, zip + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ matrix.php }}-composer- + + - name: 'Composer install with exported .env variables' + run: | + set -a && source .env && set +a + APP_ENV=prod composer install --no-dev -o + + # The 2.x historical migrations use raw MariaDB SQL and can't run on + # Postgres — that's the whole reason this PR exists. We instead apply + # the entity layer directly via schema:update, which tests the + # load-bearing claim that the *metadata* (after the rename) is + # platform-portable. doctrine:schema:validate then catches any drift + # between Postgres' generated DDL and what Doctrine expects. + - name: Apply entity metadata to Postgres (schema:update) + run: APP_ENV=prod php bin/console doctrine:schema:update --force --complete --no-interaction + + - name: Validate Doctrine schema (Postgres) + run: APP_ENV=prod php bin/console doctrine:schema:validate + php-cs-fixer: runs-on: ubuntu-latest strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa2d98a3..3f6d8436e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. ## [2.7.0] - 2026-05-01 +- [#444](https://github.com/os2display/display-api-service/pull/444) + - Renamed 15 `changed_idx` indexes to `_changed_idx` for cross-platform portability (Postgres scopes index names schema-wide). + - Quoted `user` table identifier in entity metadata so Doctrine emits the platform-native quote on every reference. + - Added Postgres CI gate that runs `doctrine:schema:update --force --complete` + `doctrine:schema:validate` against a Postgres 16 service container. - [#363](https://github.com/os2display/display-api-service/pull/363) - Added optional 'area' and 'facility' configuration fields - [#362](https://github.com/os2display/display-api-service/pull/362) diff --git a/migrations/Version20260507120000.php b/migrations/Version20260507120000.php new file mode 100644 index 000000000..0b9f36d03 --- /dev/null +++ b/migrations/Version20260507120000.php @@ -0,0 +1,59 @@ +addSql(sprintf('ALTER TABLE `%s` RENAME INDEX `changed_idx` TO `%s_changed_idx`', $table, $table)); + } + } + + public function down(Schema $schema): void + { + foreach (self::CHANGED_IDX_TABLES as $table) { + $this->addSql(sprintf('ALTER TABLE `%s` RENAME INDEX `%s_changed_idx` TO `changed_idx`', $table, $table)); + } + } +} diff --git a/src/Entity/ScreenLayout.php b/src/Entity/ScreenLayout.php index 89b66e5af..fea344753 100644 --- a/src/Entity/ScreenLayout.php +++ b/src/Entity/ScreenLayout.php @@ -17,7 +17,7 @@ #[ORM\Entity(repositoryClass: ScreenLayoutRepository::class)] #[ORM\EntityListeners([\App\EventListener\ScreenLayoutDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_layout_changed_idx')] class ScreenLayout extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface { use MultiTenantTrait; diff --git a/src/Entity/ScreenLayoutRegions.php b/src/Entity/ScreenLayoutRegions.php index 0c5466511..e27a7cfda 100644 --- a/src/Entity/ScreenLayoutRegions.php +++ b/src/Entity/ScreenLayoutRegions.php @@ -17,7 +17,7 @@ #[ORM\Entity(repositoryClass: ScreenLayoutRegionsRepository::class)] #[ORM\EntityListeners([\App\EventListener\ScreenLayoutRegionsDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_layout_regions_changed_idx')] class ScreenLayoutRegions extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface { use MultiTenantTrait; diff --git a/src/Entity/Template.php b/src/Entity/Template.php index b85245388..1539ed124 100644 --- a/src/Entity/Template.php +++ b/src/Entity/Template.php @@ -17,7 +17,7 @@ #[ORM\Entity(repositoryClass: TemplateRepository::class)] #[ORM\EntityListeners([\App\EventListener\TemplateDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'template_changed_idx')] class Template extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface { use MultiTenantTrait; diff --git a/src/Entity/Tenant/Feed.php b/src/Entity/Tenant/Feed.php index 4c7ed2cd6..70cc438e5 100644 --- a/src/Entity/Tenant/Feed.php +++ b/src/Entity/Tenant/Feed.php @@ -11,7 +11,7 @@ #[ORM\Entity(repositoryClass: FeedRepository::class)] #[ORM\EntityListeners([\App\EventListener\FeedDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'feed_changed_idx')] class Feed extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/FeedSource.php b/src/Entity/Tenant/FeedSource.php index b580bfe7d..26edbb3ca 100644 --- a/src/Entity/Tenant/FeedSource.php +++ b/src/Entity/Tenant/FeedSource.php @@ -14,7 +14,7 @@ #[ORM\Entity(repositoryClass: FeedSourceRepository::class)] #[ORM\EntityListeners([\App\EventListener\FeedSourceDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'feed_source_changed_idx')] class FeedSource extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php index 1b285648e..dff8894c0 100644 --- a/src/Entity/Tenant/Media.php +++ b/src/Entity/Tenant/Media.php @@ -18,7 +18,7 @@ #[Vich\Uploadable] #[ORM\Entity(repositoryClass: MediaRepository::class)] #[ORM\EntityListeners([\App\EventListener\MediaDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'media_changed_idx')] class Media extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/Tenant/Playlist.php b/src/Entity/Tenant/Playlist.php index d565ff84a..f81f19731 100644 --- a/src/Entity/Tenant/Playlist.php +++ b/src/Entity/Tenant/Playlist.php @@ -17,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: PlaylistRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'playlist_changed_idx')] class Playlist extends AbstractTenantScopedEntity implements MultiTenantInterface, RelationsChecksumInterface { use EntityPublishedTrait; diff --git a/src/Entity/Tenant/PlaylistScreenRegion.php b/src/Entity/Tenant/PlaylistScreenRegion.php index 97a7a92b8..59cc1282c 100644 --- a/src/Entity/Tenant/PlaylistScreenRegion.php +++ b/src/Entity/Tenant/PlaylistScreenRegion.php @@ -12,7 +12,7 @@ #[ORM\UniqueConstraint(name: 'unique_playlist_screen_region', columns: ['playlist_id', 'screen_id', 'region_id'])] #[ORM\Entity(repositoryClass: PlaylistScreenRegionRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'playlist_screen_region_changed_idx')] class PlaylistScreenRegion extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/PlaylistSlide.php b/src/Entity/Tenant/PlaylistSlide.php index ae8f0a1da..a3a444909 100644 --- a/src/Entity/Tenant/PlaylistSlide.php +++ b/src/Entity/Tenant/PlaylistSlide.php @@ -10,7 +10,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: PlaylistSlideRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'playlist_slide_changed_idx')] class PlaylistSlide extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/Screen.php b/src/Entity/Tenant/Screen.php index 9fde5553e..d735c09c1 100644 --- a/src/Entity/Tenant/Screen.php +++ b/src/Entity/Tenant/Screen.php @@ -15,7 +15,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ScreenRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_changed_idx')] class Screen extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/Tenant/ScreenCampaign.php b/src/Entity/Tenant/ScreenCampaign.php index 1101cbf31..599f8ac91 100644 --- a/src/Entity/Tenant/ScreenCampaign.php +++ b/src/Entity/Tenant/ScreenCampaign.php @@ -10,7 +10,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ScreenCampaignRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_campaign_changed_idx')] class ScreenCampaign extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/ScreenGroup.php b/src/Entity/Tenant/ScreenGroup.php index 228ebe82d..f36047614 100644 --- a/src/Entity/Tenant/ScreenGroup.php +++ b/src/Entity/Tenant/ScreenGroup.php @@ -13,7 +13,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ScreenGroupRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_group_changed_idx')] class ScreenGroup extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/Tenant/ScreenGroupCampaign.php b/src/Entity/Tenant/ScreenGroupCampaign.php index 8337d3795..8d530575b 100644 --- a/src/Entity/Tenant/ScreenGroupCampaign.php +++ b/src/Entity/Tenant/ScreenGroupCampaign.php @@ -10,7 +10,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ScreenGroupCampaignRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_group_campaign_changed_idx')] class ScreenGroupCampaign extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/Slide.php b/src/Entity/Tenant/Slide.php index 82e1fbd7f..43e5e933f 100644 --- a/src/Entity/Tenant/Slide.php +++ b/src/Entity/Tenant/Slide.php @@ -15,7 +15,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: SlideRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'slide_changed_idx')] class Slide extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityPublishedTrait; diff --git a/src/Entity/Tenant/Theme.php b/src/Entity/Tenant/Theme.php index 915e48c4d..25af364b7 100644 --- a/src/Entity/Tenant/Theme.php +++ b/src/Entity/Tenant/Theme.php @@ -14,7 +14,7 @@ #[ORM\Entity(repositoryClass: ThemeRepository::class)] #[ORM\EntityListeners([\App\EventListener\ThemeDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'theme_changed_idx')] class Theme extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/User.php b/src/Entity/User.php index 6f5b191a9..8e3a1932b 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -18,6 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: UserRepository::class)] +#[ORM\Table(name: '`user`')] class User extends AbstractBaseEntity implements UserInterface, PasswordAuthenticatedUserInterface, \JsonSerializable, TenantScopedUserInterface { #[Assert\NotBlank]