[backend] from mariadb to postgres

This commit is contained in:
2025-10-20 13:50:56 +02:00
parent 49733e03b7
commit c334452426
69 changed files with 1082 additions and 1506 deletions
+2 -2
View File
@@ -48,8 +48,8 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://root:root@mariadb:3306/app?serverVersion=Mariadb-10.11.11&charset=utf8mb4"
#DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
#DATABASE_URL="mysql://root:root@mariadb:3306/app?serverVersion=Mariadb-10.11.11&charset=utf8mb4"
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> lexik/jwt-authentication-bundle ###
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
SOURCES=(
"africanewsrdc.net"
"angazainstitute.ac.cd"
"b-onetv.cd"
"bukavufm.com"
"changement7.net"
"congoactu.net"
"congoindependant.com"
"congoquotidien.com"
"cumulard.cd"
"environews-rdc.net"
"freemediardc.info"
"geopolismagazine.org"
"habarirdc.net"
"infordc.com"
"kilalopress.net"
"laprosperiteonline.net"
"laprunellerdc.cd"
"lesmedias.net"
"lesvolcansnews.net"
"netic-news.net"
"objectif-infos.cd"
"scooprdc.net"
"journaldekinshasa.com"
"lepotentiel.cd"
"acturdc.com"
"matininfos.net"
)
BASE_CMD="/usr/bin/php /var/www/html/news.devscast.tech/bin/console app:crawl"
LOG_DIR="/var/www/html/news.devscast.tech/var"
mkdir -p "$LOG_DIR"
rm -f "${LOG_DIR}"/*.log
for SOURCE in "${SOURCES[@]}"; do
LOG_FILE="${LOG_DIR}/crawling-${SOURCE}.log"
nohup $BASE_CMD "$SOURCE" -vvv > "$LOG_FILE" 2>&1 &
done
echo "All crawlers started in the background."
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONSOLE_BIN="$PROJECT_ROOT/bin/console"
APPLY_MIGRATIONS=0
PGLOADER_EXTRA_ARGS=()
SOURCE_DSN=""
TARGET_DSN=""
usage() {
cat <<USAGE
Usage: $(basename "$0") [options] <mariadb_dsn> <postgres_dsn>
Environment variables:
SOURCE_DATABASE_URL MariaDB connection string (fallback for <mariadb_dsn>)
TARGET_DATABASE_URL PostgreSQL connection string (fallback for <postgres_dsn>)
Options:
--apply-migrations Run Doctrine migrations after data transfer (uses local PHP runtime)
--pgloader-arg ARG Append a raw argument when calling pgloader (can be provided multiple times)
-h, --help Show this help message
Examples:
SOURCE_DATABASE_URL="mysql://user:pass@host/db" \\
TARGET_DATABASE_URL="postgresql://user:pass@host/db" \\
$(basename "$0") --apply-migrations
$(basename "$0") --pgloader-arg "--with no schema" \\
mysql://root:root@127.0.0.1:3306/app \\
postgresql://app:secret@127.0.0.1:5432/app
USAGE
}
log() {
printf '[migration] %s\n' "$*"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--apply-migrations)
APPLY_MIGRATIONS=1
;;
--pgloader-arg)
shift
if [[ $# -eq 0 ]]; then
echo "--pgloader-arg requires a value" >&2
exit 1
fi
PGLOADER_EXTRA_ARGS+=("$1")
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
*)
if [[ -z "$SOURCE_DSN" ]]; then
SOURCE_DSN="$1"
elif [[ -z "$TARGET_DSN" ]]; then
TARGET_DSN="$1"
else
PGLOADER_EXTRA_ARGS+=("$1")
fi
;;
esac
shift
done
if [[ -z "$SOURCE_DSN" ]]; then
SOURCE_DSN="${SOURCE_DATABASE_URL:-}"
fi
if [[ -z "$TARGET_DSN" ]]; then
TARGET_DSN="${TARGET_DATABASE_URL:-}"
fi
if [[ -z "$SOURCE_DSN" || -z "$TARGET_DSN" ]]; then
echo "Source and target DSNs are required (pass as arguments or set SOURCE_DATABASE_URL/TARGET_DATABASE_URL)." >&2
usage >&2
exit 1
fi
if ! command -v pgloader >/dev/null 2>&1; then
echo "pgloader is required but not available on PATH. Install it (https://pgloader.readthedocs.io) and retry." >&2
exit 1
fi
log "Starting data copy"
log " source : $SOURCE_DSN"
log " target : $TARGET_DSN"
pgloader "${PGLOADER_EXTRA_ARGS[@]}" "$SOURCE_DSN" "$TARGET_DSN"
log "Data copy finished"
if [[ $APPLY_MIGRATIONS -eq 1 ]]; then
if ! command -v php >/dev/null 2>&1; then
echo "PHP CLI is required to run Doctrine migrations." >&2
exit 1
fi
if [[ ! -x "$CONSOLE_BIN" ]]; then
echo "Symfony console not found at $CONSOLE_BIN" >&2
exit 1
fi
log "Running Doctrine migrations"
(cd "$PROJECT_ROOT" && php "$CONSOLE_BIN" doctrine:migrations:migrate --no-interaction)
fi
log "Migration helper completed"
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
SOURCES=("7sur7.cd" "actualite.cd" "radiookapi.net" "mediacongo.net" "newscd.net")
BASE_CMD="/usr/bin/php /var/www/html/news.devscast.tech/bin/console app:open-graph"
LOG_DIR="/var/www/html/news.devscast.tech/var"
mkdir -p "$LOG_DIR"
rm -f "${LOG_DIR}"/*.log
for SOURCE in "${SOURCES[@]}"; do
LOG_FILE="${LOG_DIR}/${SOURCE}.log"
nohup $BASE_CMD "$SOURCE" -vvv --no-interaction > "$LOG_FILE" 2>&1 &
done
echo "All open graph crawlers started in the background."
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
ps aux | grep '/bin/console app:' | grep -v grep | awk '{print $2}' | xargs -r kill -9
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
SOURCES=("7sur7.cd" "actualite.cd" "radiookapi.net" "mediacongo.net" "newscd.net")
BASE_CMD="/usr/bin/php /var/www/html/news.devscast.tech/bin/console app:update"
LOG_DIR="/var/www/html/news.devscast.tech/var"
mkdir -p "$LOG_DIR"
rm -f "${LOG_DIR}"/*.log
for SOURCE in "${SOURCES[@]}"; do
if [[ "$SOURCE" == "7sur7.cd" ]]; then
CATEGORIES=("politique" "economie" "culture" "sport" "societe")
for CATEGORY in "${CATEGORIES[@]}"; do
LOG_FILE="${LOG_DIR}/${SOURCE}.${CATEGORY}.log"
nohup $BASE_CMD "$SOURCE" --direction=forward -vvv --category="$CATEGORY" > "$LOG_FILE" 2>&1 &
done
else
LOG_FILE="${LOG_DIR}/${SOURCE}.log"
nohup $BASE_CMD "$SOURCE" --direction=forward -vvv > "$LOG_FILE" 2>&1 &
fi
done
echo "All crawlers started in the background."
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
SOURCES=("7sur7.cd" "actualite.cd" "radiookapi.net" "mediacongo.net" "newscd.net")
BASE_CMD="/usr/bin/php /var/www/html/news.devscast.tech/bin/console app:update"
LOG_DIR="/var/www/html/news.devscast.tech/var"
mkdir -p "$LOG_DIR"
rm -f "${LOG_DIR}"/*.log
for SOURCE in "${SOURCES[@]}"; do
if [[ "$SOURCE" == "7sur7.cd" ]]; then
CATEGORIES=("politique" "economie" "culture" "sport" "societe")
for CATEGORY in "${CATEGORIES[@]}"; do
LOG_FILE="${LOG_DIR}/${SOURCE}.${CATEGORY}.log"
$BASE_CMD "$SOURCE" --direction=forward -vvv --category="$CATEGORY" 2>&1 | tee "$LOG_FILE"
done
else
LOG_FILE="${LOG_DIR}/${SOURCE}.log"
$BASE_CMD "$SOURCE" --direction=forward -vvv 2>&1 | tee "$LOG_FILE"
fi
done
echo "All crawlers finished."
+3 -2
View File
@@ -6,6 +6,7 @@
"require": {
"php": ">=8.4",
"ext-ctype": "*",
"ext-dom": "*",
"ext-iconv": "*",
"cweagans/composer-patches": "^1.7.3",
"doctrine/dbal": "^3.9.4",
@@ -17,6 +18,7 @@
"knplabs/knp-paginator-bundle": "^6.7",
"league/csv": "^9.21",
"lexik/jwt-authentication-bundle": "^3.1",
"martin-georgiev/postgresql-for-doctrine": "^3.5",
"matomo/device-detector": "^6.4",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
@@ -43,8 +45,7 @@
"symfony/yaml": "7.2.*",
"twig/extra-bundle": "^2.12|^3.19",
"twig/twig": "^2.12|^3.19",
"webmozart/assert": "^1.11",
"ext-dom": "*"
"webmozart/assert": "^1.11"
},
"require-dev": {
"behat/behat": "^3.22",
+87 -3
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b542c5edda5afefd4907959fe98f9e10",
"content-hash": "2629764bb519b7236ab2236acc4fee1d",
"packages": [
{
"name": "composer/ca-bundle",
@@ -2139,6 +2139,90 @@
],
"time": "2025-01-06T16:34:57+00:00"
},
{
"name": "martin-georgiev/postgresql-for-doctrine",
"version": "v3.5.1",
"source": {
"type": "git",
"url": "https://github.com/martin-georgiev/postgresql-for-doctrine.git",
"reference": "5d1621e48edd7c7306cf2b9e73e374727867d6af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/5d1621e48edd7c7306cf2b9e73e374727867d6af",
"reference": "5d1621e48edd7c7306cf2b9e73e374727867d6af",
"shasum": ""
},
"require": {
"doctrine/dbal": "~2.10||~3.0||~4.0",
"ext-ctype": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.1"
},
"require-dev": {
"deptrac/deptrac": "^4.0",
"doctrine/orm": "~2.14||~3.0",
"ekino/phpstan-banned-code": "^3.0",
"friendsofphp/php-cs-fixer": "^3.87.1",
"phpstan/phpstan": "^2.1.22",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
"phpstan/phpstan-doctrine": "^2.0.4",
"phpstan/phpstan-phpunit": "^2.0.7",
"phpunit/phpunit": "^10.5.53",
"rector/rector": "^2.1.5",
"symfony/cache": "^6.4||^7.0"
},
"suggest": {
"doctrine/orm": "~2.14||~3.0",
"php": "^8.3"
},
"type": "library",
"autoload": {
"psr-4": {
"MartinGeorgiev\\": "src/MartinGeorgiev/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Martin Georgiev",
"email": "martin.georgiev@gmail.com",
"role": "author"
}
],
"description": "Adds PostgreSQL enhancements to Doctrine. Provides support for JSON, JSONB and some array data types. Provides functions, operators and common expressions used when working with JSON data, arrays and features related to text search.",
"keywords": [
"array data types",
"dbal",
"doctrine",
"json",
"jsonb",
"martin georgiev",
"postgres",
"postgresql",
"text search",
"tsvector"
],
"support": {
"issues": "https://github.com/martin-georgiev/postgresql-for-doctrine/issues",
"source": "https://github.com/martin-georgiev/postgresql-for-doctrine/tree/v3.5.1"
},
"funding": [
{
"url": "https://github.com/sponsors/martin-georgiev",
"type": "custom"
},
{
"url": "https://github.com/martin-georgiev",
"type": "github"
}
],
"time": "2025-09-12T10:54:26+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
@@ -11540,8 +11624,8 @@
"platform": {
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-dom": "*"
"ext-dom": "*",
"ext-iconv": "*"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
@@ -11,17 +11,12 @@
<id name="id" type="article_id">
<generator strategy="NONE" />
</id>
<indexes>
<index fields="hash" />
<index fields="publishedAt" />
<index name="IDX_PUBLISHED_AT_ID" fields="publishedAt, id" />
</indexes>
<field name="title" length="1024" />
<field name="body" type="text" />
<embedded name="link" class="Basango\Aggregator\Domain\Model\ValueObject\Link" use-column-prefix="false" />
<field name="hash" length="32" />
<field name="categories" nullable="true" />
<field name="categories" type="text[]" nullable="true" />
<many-to-one field="source" target-entity="Basango\Aggregator\Domain\Model\Entity\Source">
<join-column nullable="false" on-delete="CASCADE" />
@@ -39,13 +34,13 @@
<field name="image"
insertable="false"
updatable="false"
column-definition="VARCHAR(1024) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.image'))) STORED"
column-definition="VARCHAR(1024) GENERATED ALWAYS AS ((metadata->>'image')) STORED"
/>
<field
name="excerpt"
insertable="false"
updatable="false"
column-definition="VARCHAR(255) GENERATED ALWAYS AS (CONCAT(LEFT(body, 200), '...')) STORED"
column-definition="VARCHAR(255) GENERATED ALWAYS AS ((left(body, 200) || '...')) STORED"
/>
<field name="publishedAt" type="datetime_immutable" />
@@ -13,7 +13,7 @@
</id>
<field name="url" />
<field name="name" unique="true" />
<field name="name" />
<embedded name="credibility" class="Basango\Aggregator\Domain\Model\ValueObject\Scoring\Credibility" use-column-prefix="false" />
<field name="displayName" nullable="true" />
@@ -12,7 +12,6 @@
<generator strategy="NONE"/>
</id>
<!-- fetching eager cause will always need to check the user's id whenever we deal with a bookmark -->
<many-to-one field="user" target-entity="Basango\IdentityAndAccess\Domain\Model\Entity\User" fetch="EAGER">
<join-column nullable="false" on-delete="CASCADE" />
</many-to-one>
@@ -16,7 +16,7 @@
<join-column nullable="false" on-delete="CASCADE" />
</many-to-one>
<field name="ipAddress" nullable="true" length="15" />
<field name="ipAddress" type="inet" nullable="true" length="15" />
<embedded name="device" class="Basango\SharedKernel\Domain\Model\ValueObject\Tracking\Device" />
<embedded name="location" class="Basango\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation" />
@@ -15,19 +15,19 @@
<field name="name"/>
<field name="email" type="email" />
<field name="password" length="512" />
<embedded name="roles" class="Basango\IdentityAndAccess\Domain\Model\ValueObject\Roles" use-column-prefix="false" />
<field name="isLocked" type="boolean">
<options>
<option name="default">0</option>
<option name="default">false</option>
</options>
</field>
<field name="isConfirmed" type="boolean">
<options>
<option name="default">0</option>
<option name="default">false</option>
</options>
</field>
<embedded name="roles" class="Basango\IdentityAndAccess\Domain\Model\ValueObject\Roles" use-column-prefix="false" />
<field name="createdAt" type="datetime_immutable" />
<field name="updatedAt" type="datetime_immutable" nullable="true" />
</entity>
@@ -5,6 +5,6 @@
https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="Basango\IdentityAndAccess\Domain\Model\ValueObject\Roles">
<field name="roles" type="json"/>
<field name="roles" type="jsonb"/>
</embeddable>
</doctrine-mapping>
@@ -10,7 +10,7 @@
<field name="device" type="string" nullable="true" />
<field name="isBot" type="boolean" nullable="false" >
<options>
<option name="default">0</option>
<option name="default">false</option>
</options>
</field>
</embeddable>
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20241008030057.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20241008030057 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'Add article table';
}
#[\Override]
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE article (id BINARY(16) NOT NULL COMMENT \'(DC2Type:uuid)\', title VARCHAR(255) NOT NULL, body LONGTEXT NOT NULL, link VARCHAR(255) NOT NULL, source VARCHAR(255) NOT NULL, categories VARCHAR(255) DEFAULT NULL, published_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', crawled_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_23A0E6636AC99F1 (link), INDEX IDX_23A0E665F8A7F73 (source), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE article');
}
}
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20241010041217.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20241010041217 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'remove unique index on article link';
}
#[\Override]
public function up(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_23A0E6636AC99F1 ON article');
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql('CREATE UNIQUE INDEX UNIQ_23A0E6636AC99F1 ON article (link)');
}
}
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20241010041432.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20241010041432 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'increase link column size';
}
#[\Override]
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE article CHANGE link link VARCHAR(2048) NOT NULL');
}
#[\Override]
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE article CHANGE link link VARCHAR(255) NOT NULL');
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20241010042241.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20241010042241 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'add hash column to article';
}
#[\Override]
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE article ADD hash VARCHAR(32) NOT NULL');
$this->addSql('UPDATE article SET hash = MD5(link)');
$this->addSql('CREATE INDEX IDX_23A0E66D1B862B8 ON article (hash)');
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IDX_23A0E66D1B862B8 ON article');
$this->addSql('ALTER TABLE article DROP hash');
}
}
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250314140326.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250314140326 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'add user table';
}
#[\Override]
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE user (id BINARY(16) NOT NULL COMMENT \'(DC2Type:user_id)\', name VARCHAR(255) NOT NULL, email VARCHAR(500) NOT NULL, password VARCHAR(4098) NOT NULL, created_at DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', updated_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', roles JSON NOT NULL COMMENT \'(DC2Type:json)\', password_reset_token_token VARCHAR(255) DEFAULT NULL, password_reset_token_generated_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE user');
}
}
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250314145254.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250314145254 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'add refresh token';
}
#[\Override]
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE refresh_tokens (id INT AUTO_INCREMENT NOT NULL, refresh_token VARCHAR(128) NOT NULL, username VARCHAR(255) NOT NULL, valid DATETIME NOT NULL, UNIQUE INDEX UNIQ_9BACE7E1C74F2195 (refresh_token), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE refresh_tokens');
}
}
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250315154326 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'fix password_reset_token_generated_at column type';
}
#[\Override]
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user CHANGE password_reset_token_generated_at password_reset_token_generated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE user CHANGE password_reset_token_generated_at password_reset_token_generated_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\'');
}
}
@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250423183329.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250423183329 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'refactoring identity and access module';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE login_attempt (id BINARY(16) NOT NULL COMMENT '(DC2Type:login_attempt_id)', user_id BINARY(16) NOT NULL COMMENT '(DC2Type:user_id)', created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)', INDEX IDX_8C11C1BA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE login_history (id BINARY(16) NOT NULL COMMENT '(DC2Type:login_history_id)', user_id BINARY(16) NOT NULL COMMENT '(DC2Type:user_id)', created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)', device_operating_system VARCHAR(255) DEFAULT NULL, device_client VARCHAR(255) DEFAULT NULL, device_device VARCHAR(255) DEFAULT NULL, device_is_bot TINYINT(1) DEFAULT 0 NOT NULL, location_time_zone VARCHAR(255) DEFAULT NULL, location_longitude DOUBLE PRECISION DEFAULT NULL, location_latitude DOUBLE PRECISION DEFAULT NULL, location_accuracy_radius INT DEFAULT NULL, INDEX IDX_37976E36A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE verification_token (id BINARY(16) NOT NULL COMMENT '(DC2Type:verification_token_id)', user_id BINARY(16) NOT NULL COMMENT '(DC2Type:user_id)', purpose VARCHAR(255) NOT NULL, created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)', token_token VARCHAR(255) DEFAULT NULL, INDEX IDX_C1CC006BA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_attempt ADD CONSTRAINT FK_8C11C1BA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_history ADD CONSTRAINT FK_37976E36A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE verification_token ADD CONSTRAINT FK_C1CC006BA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE id id BINARY(16) NOT NULL COMMENT '(DC2Type:article_id)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user ADD is_locked TINYINT(1) DEFAULT 0 NOT NULL, ADD is_confirmed TINYINT(1) DEFAULT 0 NOT NULL, DROP password_reset_token_token, DROP password_reset_token_generated_at
SQL);
$this->addSql(<<<'SQL'
UPDATE user SET is_locked = 0, is_confirmed = 1
SQL);
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE login_attempt DROP FOREIGN KEY FK_8C11C1BA76ED395
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_history DROP FOREIGN KEY FK_37976E36A76ED395
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE verification_token DROP FOREIGN KEY FK_C1CC006BA76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE login_attempt
SQL);
$this->addSql(<<<'SQL'
DROP TABLE login_history
SQL);
$this->addSql(<<<'SQL'
DROP TABLE verification_token
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user ADD password_reset_token_token VARCHAR(255) DEFAULT NULL, ADD password_reset_token_generated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', DROP is_locked, DROP is_confirmed
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE id id BINARY(16) NOT NULL COMMENT '(DC2Type:uuid)'
SQL);
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250423185205.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250423185205 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'add ip to login history';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE login_history ADD ip VARCHAR(45) DEFAULT NULL
SQL);
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE login_history DROP ip
SQL);
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250423190105 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'remove column prefix';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE verification_token CHANGE token_token token VARCHAR(255) DEFAULT NULL
SQL);
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE verification_token CHANGE token token_token VARCHAR(255) DEFAULT NULL
SQL);
}
}
@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Symfony\Component\Uid\Uuid;
/**
* Class Version20250501041246.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250501041246 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'introduce new source entity';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE source (name VARCHAR(255) NOT NULL, url VARCHAR(255) NOT NULL, updated_at DATE DEFAULT NULL COMMENT '(DC2Type:date_immutable)', bias VARCHAR(255) DEFAULT 'neutral' NOT NULL, reliability VARCHAR(255) DEFAULT 'reliable' NOT NULL, transparency VARCHAR(255) DEFAULT 'medium' NOT NULL, PRIMARY KEY(name)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE article ADD updated_at DATE DEFAULT NULL COMMENT '(DC2Type:date_immutable)', ADD bias VARCHAR(255) DEFAULT 'neutral' NOT NULL, ADD reliability VARCHAR(255) DEFAULT 'reliable' NOT NULL, ADD transparency VARCHAR(255) DEFAULT 'medium' NOT NULL
SQL);
$this->write("Fetching sources from crawled articles...");
$sources = $this->connection
->executeQuery("SELECT DISTINCT source FROM article WHERE source IS NOT NULL")
->fetchFirstColumn();
$this->write(sprintf("%d unique sources found", count($sources)));
foreach ($sources as $sourceName) {
$this->addSql("INSERT INTO source (name, url) VALUES (:name, :url)", [
"name" => $sourceName,
"url" => 'https://' . $sourceName
]);
}
$this->addSql("UPDATE article SET categories = LOWER(categories)");
$this->addSql(<<<'SQL'
ALTER TABLE article ADD CONSTRAINT FK_23A0E665F8A7F73 FOREIGN KEY (source) REFERENCES source (name) ON DELETE RESTRICT
SQL);
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article DROP FOREIGN KEY FK_23A0E665F8A7F73
SQL);
$this->addSql(<<<'SQL'
DROP TABLE source
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE article DROP updated_at, DROP bias, DROP reliability, DROP transparency
SQL);
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250501041950.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250501041950 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'increase title length';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE title title VARCHAR(2048) NOT NULL
SQL);
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE title title VARCHAR(255) NOT NULL
SQL);
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250501143015.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250501143015 extends AbstractMigration
{
public function getDescription(): string
{
return 'add sentiment score';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article ADD sentiment VARCHAR(255) DEFAULT 'neutral' NOT NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article DROP sentiment
SQL);
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250502181706.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250502181706 extends AbstractMigration
{
public function getDescription(): string
{
return 'add metadata column to article table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article ADD metadata JSON DEFAULT NULL COMMENT '(DC2Type:open_graph)'
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article DROP metadata
SQL);
}
}
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250502184108.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250502184108 extends AbstractMigration
{
public function getDescription(): string
{
return 'relative url to absolue';
}
public function up(Schema $schema): void
{
$this->addSql('UPDATE article SET link = CONCAT("https://", source, "/", TRIM(BOTH "/" FROM link)) WHERE link NOT LIKE "http%"');
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException(
'This migration is irreversible. You cannot revert the link to relative url.'
);
}
}
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250513081958.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250513081958 extends AbstractMigration
{
public function getDescription(): string
{
return 'adding reading time to articles';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article ADD reading_time INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
UPDATE article SET reading_time = FLOOR(LENGTH(body) - LENGTH(REPLACE(body, ' ', '')) + 1) / 200
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article DROP reading_time
SQL);
}
}
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250514211949.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250514211949 extends AbstractMigration
{
public function getDescription(): string
{
return '[FeedManagement] add bookmark';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE bookmark (id BINARY(16) NOT NULL COMMENT '(DC2Type:bookmark_id)', user_id BINARY(16) NOT NULL COMMENT '(DC2Type:user_id)', name VARCHAR(255) NOT NULL, description VARCHAR(2048) DEFAULT NULL, is_public TINYINT(1) DEFAULT 0 NOT NULL, created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)', updated_at DATE DEFAULT NULL COMMENT '(DC2Type:date_immutable)', INDEX IDX_DA62921DA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE bookmark_article (bookmark_id BINARY(16) NOT NULL COMMENT '(DC2Type:bookmark_id)', article_id BINARY(16) NOT NULL COMMENT '(DC2Type:article_id)', INDEX IDX_6FE2655D92741D25 (bookmark_id), INDEX IDX_6FE2655D7294869C (article_id), PRIMARY KEY(bookmark_id, article_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark_article ADD CONSTRAINT FK_6FE2655D92741D25 FOREIGN KEY (bookmark_id) REFERENCES bookmark (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark_article ADD CONSTRAINT FK_6FE2655D7294869C FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE bookmark DROP FOREIGN KEY FK_DA62921DA76ED395
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark_article DROP FOREIGN KEY FK_6FE2655D92741D25
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark_article DROP FOREIGN KEY FK_6FE2655D7294869C
SQL);
$this->addSql(<<<'SQL'
DROP TABLE bookmark
SQL);
$this->addSql(<<<'SQL'
DROP TABLE bookmark_article
SQL);
}
}
@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250515023707.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250515023707 extends AbstractMigration
{
public function getDescription(): string
{
return 'date_immutable to datetime_immutable';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE updated_at updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark CHANGE created_at created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', CHANGE updated_at updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_attempt CHANGE created_at created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_history CHANGE created_at created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE source CHANGE updated_at updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user CHANGE created_at created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', CHANGE updated_at updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE verification_token CHANGE created_at created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)'
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user CHANGE created_at created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)', CHANGE updated_at updated_at DATE DEFAULT NULL COMMENT '(DC2Type:date_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark CHANGE created_at created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)', CHANGE updated_at updated_at DATE DEFAULT NULL COMMENT '(DC2Type:date_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE source CHANGE updated_at updated_at DATE DEFAULT NULL COMMENT '(DC2Type:date_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_attempt CHANGE created_at created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE verification_token CHANGE created_at created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_history CHANGE created_at created_at DATE NOT NULL COMMENT '(DC2Type:date_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE updated_at updated_at DATE DEFAULT NULL COMMENT '(DC2Type:date_immutable)'
SQL);
}
}
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;final class Version20250516123343 extends AbstractMigration
{
public function getDescription(): string
{
return '[source] add display_name and description';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE source ADD display_name VARCHAR(255) DEFAULT NULL, ADD description VARCHAR(2048) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE source DROP display_name, DROP description
SQL);
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250517055913.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250517055913 extends AbstractMigration
{
public function getDescription(): string
{
return '[article] add index on publication date';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE INDEX IDX_23A0E66E0D4FDE1 ON article (published_at)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
DROP INDEX IDX_23A0E66E0D4FDE1 ON article
SQL);
}
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250522140030.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250522140030 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create FollowedSource table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE followed_source (id BINARY(16) NOT NULL COMMENT '(DC2Type:followed_source_id)', follower_id BINARY(16) NOT NULL COMMENT '(DC2Type:user_id)', source VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_7A763A3EAC24F853 (follower_id), INDEX IDX_7A763A3E5F8A7F73 (source), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE followed_source ADD CONSTRAINT FK_7A763A3EAC24F853 FOREIGN KEY (follower_id) REFERENCES user (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE followed_source ADD CONSTRAINT FK_7A763A3E5F8A7F73 FOREIGN KEY (source) REFERENCES source (name) ON DELETE CASCADE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE followed_source DROP FOREIGN KEY FK_7A763A3EAC24F853
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE followed_source DROP FOREIGN KEY FK_7A763A3E5F8A7F73
SQL);
$this->addSql(<<<'SQL'
DROP TABLE followed_source
SQL);
}
}
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250525183408.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250525183408 extends AbstractMigration
{
public function getDescription(): string
{
return 'optimize data lengths for various fields in the database schema';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE title title VARCHAR(1024) NOT NULL, CHANGE link link VARCHAR(1024) NOT NULL, CHANGE bias bias VARCHAR(30) DEFAULT 'neutral' NOT NULL, CHANGE reliability reliability VARCHAR(30) DEFAULT 'reliable' NOT NULL, CHANGE transparency transparency VARCHAR(30) DEFAULT 'medium' NOT NULL, CHANGE reading_time reading_time INT UNSIGNED DEFAULT 1
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark CHANGE description description VARCHAR(512) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_history ADD ip_address VARCHAR(15) DEFAULT NULL, DROP ip
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE source CHANGE bias bias VARCHAR(30) DEFAULT 'neutral' NOT NULL, CHANGE reliability reliability VARCHAR(30) DEFAULT 'reliable' NOT NULL, CHANGE transparency transparency VARCHAR(30) DEFAULT 'medium' NOT NULL, CHANGE description description VARCHAR(1024) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user CHANGE email email VARCHAR(255) NOT NULL, CHANGE password password VARCHAR(512) NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE verification_token CHANGE token token VARCHAR(60) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE user CHANGE email email VARCHAR(500) NOT NULL, CHANGE password password VARCHAR(4098) NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE bookmark CHANGE description description VARCHAR(2048) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE source CHANGE description description VARCHAR(2048) DEFAULT NULL, CHANGE bias bias VARCHAR(255) DEFAULT 'neutral' NOT NULL, CHANGE reliability reliability VARCHAR(255) DEFAULT 'reliable' NOT NULL, CHANGE transparency transparency VARCHAR(255) DEFAULT 'medium' NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE verification_token CHANGE token token VARCHAR(255) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE login_history ADD ip VARCHAR(45) DEFAULT NULL, DROP ip_address
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE title title VARCHAR(2048) NOT NULL, CHANGE link link VARCHAR(2048) NOT NULL, CHANGE bias bias VARCHAR(255) DEFAULT 'neutral' NOT NULL, CHANGE reliability reliability VARCHAR(255) DEFAULT 'reliable' NOT NULL, CHANGE transparency transparency VARCHAR(255) DEFAULT 'medium' NOT NULL, CHANGE reading_time reading_time INT DEFAULT NULL
SQL);
}
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250526101759.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250526101759 extends AbstractMigration
{
public function getDescription(): string
{
return 'add comment table with foreign keys to user and article tables';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE comment (id BINARY(16) NOT NULL COMMENT '(DC2Type:comment_id)', user_id BINARY(16) NOT NULL COMMENT '(DC2Type:user_id)', article_id BINARY(16) NOT NULL COMMENT '(DC2Type:article_id)', content VARCHAR(512) NOT NULL, sentiment VARCHAR(30) DEFAULT 'neutral' NOT NULL, is_spam TINYINT(1) DEFAULT 0 NOT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_9474526CA76ED395 (user_id), INDEX IDX_9474526C7294869C (article_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE comment ADD CONSTRAINT FK_9474526CA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE comment ADD CONSTRAINT FK_9474526C7294869C FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE comment DROP FOREIGN KEY FK_9474526CA76ED395
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE comment DROP FOREIGN KEY FK_9474526C7294869C
SQL);
$this->addSql(<<<'SQL'
DROP TABLE comment
SQL);
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250526102035.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250526102035 extends AbstractMigration
{
public function getDescription(): string
{
return 'optimize sentiment column in article table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE sentiment sentiment VARCHAR(30) DEFAULT 'neutral' NOT NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article CHANGE sentiment sentiment VARCHAR(255) DEFAULT 'neutral' NOT NULL
SQL);
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250526164157.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250526164157 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add image and excerpt columns to article table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article
ADD image VARCHAR(1024) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.image'))) STORED,
ADD excerpt VARCHAR(255) GENERATED ALWAYS AS (CONCAT(LEFT(body, 200), '...')) STORED
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article DROP image, DROP excerpt
SQL);
}
}
@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Basango\Aggregator\Domain\Model\Identity\SourceId;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250526231341.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250526231341 extends AbstractMigration
{
public function getDescription(): string
{
return 'move from source_name to source_id in article and followed_source tables';
}
public function up(Schema $schema): void
{
$this->addSql("SET FOREIGN_KEY_CHECKS = 0");
// delete the old indexes and foreign keys
$this->addSql("DROP INDEX `primary` ON source");
$this->addSql("ALTER TABLE article DROP FOREIGN KEY FK_23A0E665F8A7F73");
$this->addSql("ALTER TABLE followed_source DROP FOREIGN KEY FK_7A763A3E5F8A7F73");
$this->addSql("DROP INDEX IDX_23A0E665F8A7F73 ON article");
$this->addSql("DROP INDEX IDX_7A763A3E5F8A7F73 ON followed_source");
// add the new id column to source table
$this->addSql("ALTER TABLE source ADD id BINARY(16) DEFAULT NULL COMMENT '(DC2Type:source_id)' FIRST");
$sources = $this->connection
->executeQuery("SELECT name FROM source")
->fetchFirstColumn();
foreach ($sources as $source) {
$this->addSql("UPDATE source SET id = :id WHERE name = :name", [
"id" => new SourceId()->toBinary(),
"name" => $source,
]);
}
// set the id column as NOT NULL and create a unique index
$this->addSql("ALTER TABLE source MODIFY id BINARY(16) NOT NULL COMMENT '(DC2Type:source_id)'");
$this->addSql("CREATE UNIQUE INDEX UNIQ_5F8A7F735E237E06 ON source (name)");
$this->addSql("ALTER TABLE source ADD PRIMARY KEY (id)");
// Update article table
$this->addSql("ALTER TABLE article ADD source_id BINARY(16) NOT NULL COMMENT '(DC2Type:source_id)'");
$this->addSql("UPDATE article JOIN source ON article.source = source.name SET article.source_id = source.id");
$this->addSql("ALTER TABLE article DROP source");
$this->addSql("ALTER TABLE article ADD CONSTRAINT FK_23A0E66953C1C61 FOREIGN KEY (source_id) REFERENCES source (id) ON DELETE CASCADE");
$this->addSql(" CREATE INDEX IDX_23A0E66953C1C61 ON article (source_id)");
// Update followed_source table
$this->addSql("ALTER TABLE followed_source ADD source_id BINARY(16) NOT NULL COMMENT '(DC2Type:source_id)'");
$this->addSql("ALTER TABLE followed_source DROP source");
$this->addSql("ALTER TABLE followed_source ADD CONSTRAINT FK_7A763A3E953C1C61 FOREIGN KEY (source_id) REFERENCES source (id) ON DELETE CASCADE");
$this->addSql("CREATE INDEX IDX_7A763A3E953C1C61 ON followed_source (source_id)");
// Re-enable foreign key checks
$this->addSql("SET FOREIGN_KEY_CHECKS = 1");
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('This migration is irreversible.');
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20250530121647.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20250530121647 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create index on article table for published_at and id columns';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE article ADD INDEX IDX_PUBLISHED_AT_ID (published_at DESC, id DESC);
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
DROP INDEX IDX_PUBLISHED_AT_ID ON article
SQL);
}
}
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\IrreversibleMigration;
/**
* Class Version20251019151441.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Version20251019151441 extends AbstractMigration
{
public function getDescription(): string
{
return 'initial postgresql schema';
}
public function up(Schema $schema): void
{
$this->addSql("CREATE EXTENSION IF NOT EXISTS pg_trgm;"); // for trigram indexes (links, titles, etc.)
$this->addSql("SET SESSION TIME ZONE 'UTC';");
// -- ---------- TABLE: article ----------
$this->addSql(<<<SQL
CREATE TABLE article (
id UUID NOT NULL,
source_id UUID NOT NULL,
title VARCHAR(1024) NOT NULL,
body TEXT NOT NULL,
hash VARCHAR(32) NOT NULL,
categories TEXT[] DEFAULT NULL,
sentiment VARCHAR(30) DEFAULT 'neutral' NOT NULL,
metadata JSONB DEFAULT NULL,
image VARCHAR(1024) GENERATED ALWAYS AS ((metadata->>'image')) STORED,
excerpt VARCHAR(255) GENERATED ALWAYS AS ((LEFT(body, 200) || '...')) STORED,
published_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
crawled_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
link VARCHAR(1024) NOT NULL,
bias VARCHAR(30) DEFAULT 'neutral' NOT NULL,
reliability VARCHAR(30) DEFAULT 'reliable' NOT NULL,
transparency VARCHAR(30) DEFAULT 'medium' NOT NULL,
reading_time INT DEFAULT 1,
CONSTRAINT CHK_ARTICLE_READING_TIME CHECK (reading_time >= 0),
CONSTRAINT CHK_ARTICLE_SENTIMENT CHECK (sentiment IN ('positive','neutral','negative')),
CONSTRAINT CHK_ARTICLE_METADATA_JSON CHECK (metadata IS NULL OR JSONB_TYPEOF(metadata) IN ('object','array')),
PRIMARY KEY (id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_23A0E66953C1C61 ON article (source_id)');
$this->addSql('CREATE INDEX IDX_ARTICLE_PUBLISHED_AT ON article (published_at DESC)');
$this->addSql('CREATE INDEX IDX_ARTICLE_PUBLISHED_ID ON article (published_at DESC, id DESC)');
$this->addSql('CREATE UNIQUE INDEX UNQ_ARTICLE_HASH ON article (hash)');
$this->addSql(<<<SQL
ALTER TABLE article ADD COLUMN tsv TSVECTOR GENERATED ALWAYS AS (
SETWEIGHT(TO_TSVECTOR('french', COALESCE(title,'')), 'A') ||
SETWEIGHT(TO_TSVECTOR('french', COALESCE(body ,'')), 'B')
) STORED;
SQL
);
$this->addSql('CREATE INDEX GIN_ARTICLE_TSV ON article USING GIN(tsv)');
$this->addSql('CREATE INDEX GIN_ARTICLE_LINK_TRGM ON article USING GIN (link gin_trgm_ops)');
$this->addSql('CREATE INDEX GIN_ARTICLE_TITLE_TRGM ON article USING GIN (title gin_trgm_ops)');
$this->addSql('CREATE INDEX GIN_ARTICLE_CATEGORIES ON article USING GIN (categories)');
$this->addSql("COMMENT ON COLUMN article.id IS '(DC2Type:article_id)';");
$this->addSql("COMMENT ON COLUMN article.source_id IS '(DC2Type:source_id)';");
$this->addSql("COMMENT ON COLUMN article.published_at IS '(DC2Type:datetime_immutable)'");
$this->addSql("COMMENT ON COLUMN article.crawled_at IS '(DC2Type:datetime_immutable)'");
$this->addSql("COMMENT ON COLUMN article.updated_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- TABLE: bookmark ----------
$this->addSql(<<<SQL
CREATE TABLE bookmark (
id UUID NOT NULL,
user_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description VARCHAR(512) DEFAULT NULL,
is_public BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_DA62921DA76ED395 ON bookmark (user_id)');
$this->addSql('CREATE INDEX IDX_BOOKMARK_USER_CREATED ON bookmark (user_id, created_at DESC)');
$this->addSql("COMMENT ON COLUMN bookmark.id IS '(DC2Type:bookmark_id)'");
$this->addSql("COMMENT ON COLUMN bookmark.user_id IS '(DC2Type:user_id)'");
$this->addSql("COMMENT ON COLUMN bookmark.created_at IS '(DC2Type:datetime_immutable)'");
$this->addSql("COMMENT ON COLUMN bookmark.updated_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- TABLE: bookmark_article ----------
$this->addSql(<<<SQL
CREATE TABLE bookmark_article (
bookmark_id UUID NOT NULL,
article_id UUID NOT NULL,
PRIMARY KEY(bookmark_id, article_id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_6FE2655D92741D25 ON bookmark_article (bookmark_id)');
$this->addSql('CREATE INDEX IDX_6FE2655D7294869C ON bookmark_article (article_id)');
$this->addSql("COMMENT ON COLUMN bookmark_article.bookmark_id IS '(DC2Type:bookmark_id)'");
$this->addSql("COMMENT ON COLUMN bookmark_article.article_id IS '(DC2Type:article_id)'");
// -- ---------- TABLE: comment ----------
$this->addSql(<<<SQL
CREATE TABLE comment (
id UUID NOT NULL,
user_id UUID NOT NULL,
article_id UUID NOT NULL,
content VARCHAR(512) NOT NULL,
sentiment VARCHAR(30) DEFAULT 'neutral' NOT NULL,
is_spam BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_9474526CA76ED395 ON comment (user_id)');
$this->addSql('CREATE INDEX IDX_9474526C7294869C ON comment (article_id)');
$this->addSql('CREATE INDEX IDX_COMMENT_ARTICLE_CREATED ON comment (article_id, created_at DESC)');
$this->addSql("COMMENT ON COLUMN comment.id IS '(DC2Type:comment_id)'");
$this->addSql("COMMENT ON COLUMN comment.user_id IS '(DC2Type:user_id)'");
$this->addSql("COMMENT ON COLUMN comment.article_id IS '(DC2Type:article_id)'");
$this->addSql("COMMENT ON COLUMN comment.created_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- TABLE: followed_source ----------
$this->addSql(<<<SQL
CREATE TABLE followed_source (
id UUID NOT NULL,
follower_id UUID NOT NULL,
source_id UUID NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_7A763A3EAC24F853 ON followed_source (follower_id)');
$this->addSql('CREATE INDEX IDX_7A763A3E953C1C61 ON followed_source (source_id)');
$this->addSql('CREATE INDEX IDX_FOLLOWED_SOURCE_FOLLOWER_CREATED ON followed_source (follower_id, created_at DESC)');
$this->addSql("COMMENT ON COLUMN followed_source.id IS '(DC2Type:followed_source_id)'");
$this->addSql("COMMENT ON COLUMN followed_source.follower_id IS '(DC2Type:user_id)'");
$this->addSql("COMMENT ON COLUMN followed_source.source_id IS '(DC2Type:source_id)'");
$this->addSql("COMMENT ON COLUMN followed_source.created_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- TABLE: login_attempt ----------
$this->addSql(<<<SQL
CREATE TABLE login_attempt (
id UUID NOT NULL,
user_id UUID NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_8C11C1BA76ED395 ON login_attempt (user_id)');
$this->addSql('CREATE INDEX IDX_LOGIN_ATTEMPT_CREATED_AT ON login_attempt (created_at DESC)');
$this->addSql("COMMENT ON COLUMN login_attempt.id IS '(DC2Type:login_attempt_id)'");
$this->addSql("COMMENT ON COLUMN login_attempt.user_id IS '(DC2Type:user_id)'");
$this->addSql("COMMENT ON COLUMN login_attempt.created_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- TABLE: login_history ----------
$this->addSql(<<<SQL
CREATE TABLE login_history (
id UUID NOT NULL,
user_id UUID NOT NULL,
ip_address INET DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
device_operating_system VARCHAR(255) DEFAULT NULL,
device_client VARCHAR(255) DEFAULT NULL,
device_device VARCHAR(255) DEFAULT NULL,
device_is_bot BOOLEAN DEFAULT false NOT NULL,
location_time_zone VARCHAR(255) DEFAULT NULL,
location_longitude DOUBLE PRECISION DEFAULT NULL,
location_latitude DOUBLE PRECISION DEFAULT NULL,
location_accuracy_radius INT DEFAULT NULL,
PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_37976E36A76ED395 ON login_history (user_id)');
$this->addSql('CREATE INDEX IDX_LOGIN_HISTORY_CREATED_AT ON login_history (user_id, created_at DESC)');
$this->addSql('CREATE INDEX IDX_LOGIN_HISTORY_IP_ADDRESS ON login_history (ip_address)');
$this->addSql("COMMENT ON COLUMN login_history.id IS '(DC2Type:login_history_id)'");
$this->addSql("COMMENT ON COLUMN login_history.user_id IS '(DC2Type:user_id)'");
$this->addSql("COMMENT ON COLUMN login_history.created_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- TABLE: refresh_tokens ----------
$this->addSql('CREATE SEQUENCE refresh_tokens_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql(<<<SQL
CREATE TABLE refresh_tokens (
id INT NOT NULL,
refresh_token VARCHAR(128) NOT NULL,
username VARCHAR(255) NOT NULL,
valid TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE UNIQUE INDEX UNIQ_9BACE7E1C74F2195 ON refresh_tokens (refresh_token)');
// -- ---------- TABLE: source ----------
$this->addSql(<<<SQL
CREATE TABLE source (
id UUID NOT NULL,
url VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
display_name VARCHAR(255) DEFAULT NULL,
description VARCHAR(1024) DEFAULT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
bias VARCHAR(30) DEFAULT 'neutral' NOT NULL,
reliability VARCHAR(30) DEFAULT 'reliable' NOT NULL,
transparency VARCHAR(30) DEFAULT 'medium' NOT NULL,
PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE UNIQUE INDEX UNQ_SOURCE_NAME ON source (LOWER(name))');
$this->addSql('CREATE UNIQUE INDEX UNQ_SOURCE_URL ON source (LOWER(url))');
$this->addSql("COMMENT ON COLUMN source.id IS '(DC2Type:source_id)'");
$this->addSql("COMMENT ON COLUMN source.updated_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- TABLE: user ----------
$this->addSql(<<<SQL
CREATE TABLE "user" (
id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
password VARCHAR(512) NOT NULL,
is_locked BOOLEAN DEFAULT false NOT NULL,
is_confirmed BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
roles JSONB NOT NULL,
PRIMARY KEY(id),
CONSTRAINT CHK_USER_ROLES_JSON CHECK (JSONB_TYPEOF(roles) = 'array')
)
SQL
);
$this->addSql(<<<SQL
CREATE UNIQUE INDEX UNQ_USER_EMAIL ON "user" (LOWER(email));
SQL
);
$this->addSql("COMMENT ON COLUMN \"user\".id IS '(DC2Type:user_id)'");
$this->addSql("COMMENT ON COLUMN \"user\".created_at IS '(DC2Type:datetime_immutable)'");
$this->addSql("COMMENT ON COLUMN \"user\".updated_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- TABLE: verification_token ----------
$this->addSql(<<<SQL
CREATE TABLE verification_token (
id UUID NOT NULL,
user_id UUID NOT NULL,
purpose VARCHAR(255) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
token VARCHAR(60) DEFAULT NULL,
PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_C1CC006BA76ED395 ON verification_token (user_id)');
$this->addSql('CREATE INDEX IDX_VERIF_TOKEN_CREATED_AT ON verification_token (created_at DESC)');
$this->addSql('CREATE UNIQUE INDEX UNQ_VERIF_USER_PURPOSE_TOKEN ON verification_token (user_id, purpose) WHERE token IS NOT NULL');
$this->addSql("COMMENT ON COLUMN verification_token.id IS '(DC2Type:verification_token_id)'");
$this->addSql("COMMENT ON COLUMN verification_token.user_id IS '(DC2Type:user_id)'");
$this->addSql("COMMENT ON COLUMN verification_token.created_at IS '(DC2Type:datetime_immutable)'");
// -- ---------- FOREIGN KEY CONSTRAINTS ----------
$this->addSql('ALTER TABLE article ADD CONSTRAINT FK_23A0E66953C1C61 FOREIGN KEY (source_id) REFERENCES source (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bookmark_article ADD CONSTRAINT FK_6FE2655D92741D25 FOREIGN KEY (bookmark_id) REFERENCES bookmark (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bookmark_article ADD CONSTRAINT FK_6FE2655D7294869C FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526CA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526C7294869C FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE followed_source ADD CONSTRAINT FK_7A763A3EAC24F853 FOREIGN KEY (follower_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE followed_source ADD CONSTRAINT FK_7A763A3E953C1C61 FOREIGN KEY (source_id) REFERENCES source (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE login_attempt ADD CONSTRAINT FK_8C11C1BA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE login_history ADD CONSTRAINT FK_37976E36A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE verification_token ADD CONSTRAINT FK_C1CC006BA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
throw new IrreversibleMigration('Sometimes in life you have to accept that you can\'t go back.');
}
}
@@ -0,0 +1,2 @@
maker:
root_namespace: Basango
+282 -32
View File
@@ -1,14 +1,67 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
server_version: '16'
profiling_collect_backtrace: false
idle_connection_ttl: 172800
use_savepoints: true
result_cache: 'cache.dbal'
mapping_types:
# Array type mappings
'bool[]': 'bool[]'
_bool: 'bool[]'
'smallint[]': 'smallint[]'
_int2: 'smallint[]'
'integer[]': 'integer[]'
_int4: 'integer[]'
'bigint[]': 'bigint[]'
_int8: 'bigint[]'
'double precision[]': 'double precision[]'
_float8: 'double precision[]'
'real[]': 'real[]'
_float4: 'real[]'
'text[]': 'text[]'
_text: 'text[]'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
# JSON type mappings
jsonb: jsonb
'jsonb[]': 'jsonb[]'
_jsonb: 'jsonb[]'
profiling_collect_backtrace: false
use_savepoints: true
result_cache: 'cache.dbal'
# Network type mappings
cidr: cidr
'cidr[]': 'cidr[]'
_cidr: 'cidr[]'
inet: inet
'inet[]': 'inet[]'
_inet: 'inet[]'
macaddr: macaddr
'macaddr[]': 'macaddr[]'
_macaddr: 'macaddr[]'
# Spatial type mappings
point: point
'point[]': 'point[]'
_point: 'point[]'
geometry: geometry
'geometry[]': 'geometry[]'
_geometry: 'geometry[]'
geography: geography
'geography[]': 'geography[]'
_geography: 'geography[]'
# Range type mappings
daterange: daterange
int4range: int4range
int8range: int8range
numrange: numrange
tsrange: tsrange
tstzrange: tstzrange
# Hierarchical type mappings
ltree: ltree
types:
# Shared Kernel
email: Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Types\EmailType
@@ -28,34 +81,231 @@ doctrine:
bookmark_id: Basango\FeedManagement\Infrastructure\Persistence\Doctrine\DBAL\Types\BookmarkIdType
followed_source_id: Basango\FeedManagement\Infrastructure\Persistence\Doctrine\DBAL\Types\FollowedSourceIdType
comment_id: Basango\FeedManagement\Infrastructure\Persistence\Doctrine\DBAL\Types\CommentIdType
# PostgreSQL Specific Types
# Array types
'bool[]': MartinGeorgiev\Doctrine\DBAL\Types\BooleanArray
'smallint[]': MartinGeorgiev\Doctrine\DBAL\Types\SmallIntArray
'integer[]': MartinGeorgiev\Doctrine\DBAL\Types\IntegerArray
'bigint[]': MartinGeorgiev\Doctrine\DBAL\Types\BigIntArray
'double precision[]': MartinGeorgiev\Doctrine\DBAL\Types\DoublePrecisionArray
'real[]': MartinGeorgiev\Doctrine\DBAL\Types\RealArray
'text[]': MartinGeorgiev\Doctrine\DBAL\Types\TextArray
# JSON types
jsonb: MartinGeorgiev\Doctrine\DBAL\Types\Jsonb
'jsonb[]': MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray
# Network types
cidr: MartinGeorgiev\Doctrine\DBAL\Types\Cidr
'cidr[]': MartinGeorgiev\Doctrine\DBAL\Types\CidrArray
inet: MartinGeorgiev\Doctrine\DBAL\Types\Inet
'inet[]': MartinGeorgiev\Doctrine\DBAL\Types\InetArray
macaddr: MartinGeorgiev\Doctrine\DBAL\Types\Macaddr
'macaddr[]': MartinGeorgiev\Doctrine\DBAL\Types\MacaddrArray
# Spatial types
point: MartinGeorgiev\Doctrine\DBAL\Types\Point
'point[]': MartinGeorgiev\Doctrine\DBAL\Types\PointArray
geometry: MartinGeorgiev\Doctrine\DBAL\Types\Geometry
'geometry[]': MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray
geography: MartinGeorgiev\Doctrine\DBAL\Types\Geography
'geography[]': MartinGeorgiev\Doctrine\DBAL\Types\GeographyArray
# Range types
daterange: MartinGeorgiev\Doctrine\DBAL\Types\DateRange
int4range: MartinGeorgiev\Doctrine\DBAL\Types\Int4Range
int8range: MartinGeorgiev\Doctrine\DBAL\Types\Int8Range
numrange: MartinGeorgiev\Doctrine\DBAL\Types\NumRange
tsrange: MartinGeorgiev\Doctrine\DBAL\Types\TsRange
tstzrange: MartinGeorgiev\Doctrine\DBAL\Types\TstzRange
# Hierarchical types
ltree: MartinGeorgiev\Doctrine\DBAL\Types\Ltree
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: false
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
Aggregator:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/Aggregator'
prefix: 'Basango\Aggregator\Domain\Model'
IdentityAndAccess:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/IdentityAndAccess'
prefix: 'Basango\IdentityAndAccess\Domain\Model'
FeedManagement:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/FeedManagement'
prefix: 'Basango\FeedManagement\Domain\Model'
SharedKernel:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/SharedKernel'
prefix: 'Basango\SharedKernel\Domain\Model'
entity_managers:
default:
validate_xml_mapping: false
report_fields_where_declared: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
dql:
string_functions:
# alternative implementation of ALL() and ANY() where subquery is not required, useful for arrays
ALL_OF: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\All
ANY_OF: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Any
# operators for working with array and json(b) data
GREATEST: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Greatest
LEAST: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Least
CONTAINS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Contains # @>
IS_CONTAINED_BY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\IsContainedBy # <@
OVERLAPS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Overlaps # &&
RIGHT_EXISTS_ON_LEFT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\TheRightExistsOnTheLeft # ?
ALL_ON_RIGHT_EXIST_ON_LEFT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\AllOnTheRightExistOnTheLeft # ?&
ANY_ON_RIGHT_EXISTS_ON_LEFT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\AnyOnTheRightExistsOnTheLeft # ?|
RETURNS_VALUE_FOR_JSON_VALUE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ReturnsValueForJsonValue # @?
DELETE_AT_PATH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DeleteAtPath # #-
# array and string specific functions
IN_ARRAY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\InArray
ANY_VALUE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\AnyValue
ARRAY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Arr
ARRAY_APPEND: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAppend
ARRAY_CARDINALITY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayCardinality
ARRAY_CAT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayCat
ARRAY_DIMENSIONS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayDimensions
ARRAY_LENGTH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayLength
ARRAY_NUMBER_OF_DIMENSIONS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayNumberOfDimensions
ARRAY_POSITION: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayPosition
ARRAY_POSITIONS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayPositions
ARRAY_PREPEND: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayPrepend
ARRAY_REMOVE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayRemove
ARRAY_REPLACE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayReplace
ARRAY_SHUFFLE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayShuffle
ARRAY_TO_JSON: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayToJson
ARRAY_TO_STRING: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayToString
SPLIT_PART: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\SplitPart
STARTS_WITH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StartsWith
STRING_TO_ARRAY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringToArray
UNNEST: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Unnest
# json specific functions
JSON_ARRAY_LENGTH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonArrayLength
JSON_BUILD_OBJECT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonBuildObject
JSON_EACH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonEach
JSON_EACH_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonEachText
JSON_EXISTS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonExists
JSON_GET_FIELD: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField
JSON_GET_FIELD_AS_INTEGER: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsInteger
JSON_GET_FIELD_AS_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsText
JSON_GET_OBJECT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetObject
JSON_GET_OBJECT_AS_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetObjectAsText
JSON_OBJECT_KEYS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectKeys
JSON_QUERY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonQuery
JSON_SCALAR: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonScalar
JSON_SERIALIZE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonSerialize
JSON_STRIP_NULLS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonStripNulls
JSON_TYPEOF: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonTypeof
JSON_VALUE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonValue
TO_JSON: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToJson
ROW_TO_JSON: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RowToJson
# jsonb specific functions
JSONB_ARRAY_ELEMENTS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayElements
JSONB_ARRAY_ELEMENTS_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayElementsText
JSONB_ARRAY_LENGTH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayLength
JSONB_BUILD_OBJECT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbBuildObject
JSONB_EACH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbEach
JSONB_EACH_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbEachText
JSONB_EXISTS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbExists
JSONB_INSERT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbInsert
JSONB_OBJECT_KEYS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectKeys
JSONB_PATH_EXISTS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPathExists
JSONB_PATH_MATCH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPathMatch
JSONB_PATH_QUERY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPathQuery
JSONB_PATH_QUERY_ARRAY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPathQueryArray
JSONB_PATH_QUERY_FIRST: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPathQueryFirst
JSONB_PRETTY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPretty
JSONB_SET: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbSet
JSONB_SET_LAX: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbSetLax
JSONB_STRIP_NULLS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbStripNulls
TO_JSONB: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToJsonb
# text search specific
TO_TSQUERY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTsquery
TO_TSVECTOR: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTsvector
TSMATCH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Tsmatch
# date specific functions
DATE_ADD: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateAdd
DATE_BIN: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateBin
DATE_EXTRACT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract
DATE_OVERLAPS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps
DATE_SUBTRACT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract
# range functions
DATERANGE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Daterange
INT4RANGE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Int4range
INT8RANGE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Int8range
NUMRANGE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Numrange
TSRANGE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Tsrange
TSTZRANGE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Tstzrange
# Arithmetic functions
CBRT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Cbrt
CEIL: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Ceil
DEGREES: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Degrees
EXP: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exp
FLOOR: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Floor
LN: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Ln
LOG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Log
PI: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Pi
POWER: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Power
RADIANS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Radians
RANDOM: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Random
ROUND: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Round
SIGN: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Sign
TRUNC: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Trunc
WIDTH_BUCKET: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\WidthBucket
# other operators
CAST: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Cast
ILIKE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Ilike
SIMILAR_TO: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\SimilarTo
NOT_SIMILAR_TO: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\NotSimilarTo
UNACCENT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Unaccent
REGEXP: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Regexp
IREGEXP: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\IRegexp
NOT_REGEXP: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\NotRegexp
NOT_IREGEXP: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\NotIRegexp
REGEXP_COUNT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RegexpCount
REGEXP_INSTR: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RegexpInstr
REGEXP_LIKE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RegexpLike
REGEXP_MATCH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RegexpMatch
REGEXP_REPLACE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RegexpReplace
REGEXP_SUBSTR: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RegexpSubstr
ROW: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Row
STRCONCAT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StrConcat
DISTANCE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Distance
# aggregation functions
ARRAY_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAgg
JSON_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonAgg
JSON_OBJECT_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectAgg
JSONB_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbAgg
JSONB_OBJECT_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectAgg
STRING_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg
XML_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\XmlAgg
# data type formatting functions
TO_CHAR: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToChar
TO_DATE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToDate
TO_NUMBER: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToNumber
TO_TIMESTAMP: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTimestamp
mappings:
Aggregator:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/Aggregator'
prefix: 'Basango\Aggregator\Domain\Model'
IdentityAndAccess:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/IdentityAndAccess'
prefix: 'Basango\IdentityAndAccess\Domain\Model'
FeedManagement:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/FeedManagement'
prefix: 'Basango\FeedManagement\Domain\Model'
SharedKernel:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/SharedKernel'
prefix: 'Basango\SharedKernel\Domain\Model'
controller_resolver:
auto_mapping: false
@@ -32,7 +32,7 @@ final readonly class GetArticlesForExportDbalHandler implements GetArticlesForEx
'a.id as article_id',
'a.title as article_title',
'a.link as article_link',
'a.categories as article_categories',
"array_to_string(a.categories, ',') as article_categories",
'a.body as article_body',
's.name as article_source',
'a.hash as article_hash',
@@ -49,7 +49,7 @@ final readonly class GetArticlesForExportDbalHandler implements GetArticlesForEx
}
if ($query->date instanceof DateRange) {
$qb->andWhere('a.published_at BETWEEN :start AND :end')
$qb->andWhere('a.published_at BETWEEN to_timestamp(:start) AND to_timestamp(:end)')
->setParameter('start', $query->date->start)
->setParameter('end', $query->date->end);
}
@@ -34,8 +34,8 @@ final readonly class GetEarliestPublicationDateDBalHandler implements GetEarlies
->setParameter('source', $query->source);
if ($query->category !== null) {
$qb->andWhere('a.categories LIKE :category')
->setParameter('category', sprintf('%%%s%%', $query->category));
$qb->andWhere(':category = ANY(a.categories)')
->setParameter('category', $query->category);
}
try {
@@ -34,8 +34,8 @@ final readonly class GetLatestPublicationDateDBalHandler implements GetLatestPub
->setParameter('source', $query->source);
if ($query->category !== null) {
$qb->andWhere('a.categories LIKE :category')
->setParameter('category', sprintf('%%%s%%', $query->category));
$qb->andWhere(':category = ANY(a.categories)')
->setParameter('category', $query->category);
}
try {
@@ -34,7 +34,7 @@ final readonly class GetSourceStatisticsListDbalHandler implements GetSourceStat
)
->from('source', 's')
->leftJoin('s', 'article', 'a', 'a.source_id = s.id')
->groupBy('s.id')
->groupBy('s.id, s.name')
->orderBy('s.name', 'ASC');
try {
@@ -20,6 +20,7 @@ final class OpenGraphType extends Type
{
return $platform->getJsonTypeDeclarationSQL([
'nullable' => true,
'jsonb' => true,
]);
}
@@ -9,6 +9,7 @@ use Basango\Aggregator\Domain\Model\Entity\Article;
use Basango\Aggregator\Domain\Model\Identity\ArticleId;
use Basango\Aggregator\Domain\Model\Repository\ArticleRepository;
use Basango\SharedKernel\Domain\Model\ValueObject\DateRange;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -69,9 +70,9 @@ final class ArticleOrmRepository extends ServiceEntityRepository implements Arti
}
if ($date instanceof DateRange) {
$qb->andWhere('a.publishedAt BETWEEN FROM_UNIXTIME(:start) AND FROM_UNIXTIME(:end)')
->setParameter('start', $date->start)
->setParameter('end', $date->end);
$qb->andWhere('a.publishedAt BETWEEN :startDate AND :endDate')
->setParameter('startDate', new DateTimeImmutable()->setTimestamp($date->start))
->setParameter('endDate', new DateTimeImmutable()->setTimestamp($date->end));
}
$limit = 1000;
@@ -11,7 +11,6 @@ use Basango\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Features\PaginationQuery;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
/**
* Class GetArticleCommentListDbalHandler.
@@ -41,7 +40,7 @@ final readonly class GetArticleCommentListDbalHandler implements GetArticleComme
->innerJoin('c', 'user', 'u', 'c.user_id = u.id')
->where('c.article_id = :articleId')
->orderBy('c.created_at', 'DESC')
->setParameter('articleId', $query->articleId->toBinary(), ParameterType::BINARY);
->setParameter('articleId', $query->articleId->toRfc4122());
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('c.id', 'c.created_at'));
@@ -13,7 +13,6 @@ use Basango\FeedManagement\Infrastructure\Persistence\Doctrine\DBAL\Queries\Book
use Basango\FeedManagement\Infrastructure\Persistence\Doctrine\DBAL\Queries\SourceQuery;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
/**
* Class GetArticleDetailsDbalHandler.
@@ -42,8 +41,8 @@ final readonly class GetArticleDetailsDbalHandler implements GetArticleDetailsHa
$qb->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->from('article', 'a')
->where('a.id = :articleId')
->setParameter('articleId', $query->id->toBinary(), ParameterType::BINARY)
->setParameter('userId', $query->userId->toBinary(), ParameterType::BINARY)
->setParameter('articleId', $query->id->toRfc4122())
->setParameter('userId', $query->userId->toRfc4122())
;
try {
@@ -14,7 +14,6 @@ use Basango\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Features\PaginationQuery;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
/**
* Class GetArticleOverviewListDbalHandler.
@@ -44,7 +43,7 @@ final readonly class GetArticleOverviewListDbalHandler implements GetArticleOver
$qb->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
//->orderBy('a.published_at', $query->filters->sortDirection->value)
->setParameter('userId', $query->userId->toBinary(), ParameterType::BINARY)
->setParameter('userId', $query->userId->toRfc4122())
;
$qb = $this->applyArticleFilters($qb, $query->filters);
@@ -12,7 +12,6 @@ use Basango\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Features\PaginationQuery;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
/**
* Class GetBookmarkListDbalHandler.
@@ -39,7 +38,7 @@ final readonly class GetBookmarkListDbalHandler implements GetBookmarkListHandle
->where('b.user_id = :userId')
->groupBy('b.id')
->orderBy('b.id', 'DESC')
->setParameter('userId', $query->userId->toBinary(), ParameterType::BINARY)
->setParameter('userId', $query->userId->toRfc4122())
;
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('b.id'));
@@ -13,7 +13,6 @@ use Basango\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Features\PaginationQuery;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
/**
* Class GetBookmarkedArticleListDbalHandler.
@@ -44,8 +43,8 @@ final readonly class GetBookmarkedArticleListDbalHandler implements GetBookmarke
->innerJoin('ba', 'bookmark', 'b', 'b.id = ba.bookmark_id AND b.user_id = :userId')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where('b.id = :bookmarkId')
->setParameter('bookmarkId', $query->bookmarkId->toBinary(), ParameterType::BINARY)
->setParameter('userId', $query->userId->toBinary(), ParameterType::BINARY)
->setParameter('bookmarkId', $query->bookmarkId->toRfc4122())
->setParameter('userId', $query->userId->toRfc4122())
;
$qb = $this->applyArticleFilters($qb, $query->filters);
@@ -14,7 +14,6 @@ use Basango\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Features\PaginationQuery;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
/**
* Class GetArticleOverviewListDbalHandler.
@@ -45,8 +44,8 @@ final readonly class GetSourceArticleOverviewListDbalHandler implements GetSourc
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where('s.id = :sourceId')
->orderBy('a.published_at', $query->filters->sortDirection->value)
->setParameter('userId', $query->userId->toBinary(), ParameterType::BINARY)
->setParameter('sourceId', $query->sourceId->toBinary(), ParameterType::BINARY)
->setParameter('userId', $query->userId->toRfc4122())
->setParameter('sourceId', $query->sourceId->toRfc4122())
;
$qb = $this->applyArticleFilters($qb, $query->filters);
@@ -49,8 +49,10 @@ final readonly class GetSourceDetailsDbalHandler implements GetSourceDetailsHand
$qb->from('source', 's')
->leftJoin('s', 'article', 'a', 'a.source_id = s.id')
->where('s.id = :sourceId')
->setParameter('sourceId', $query->sourceId->toBinary(), ParameterType::BINARY)
->setParameter('userId', $query->userId->toBinary(), ParameterType::BINARY);
->setParameter('sourceId', $query->sourceId->toRfc4122())
->setParameter('userId', $query->userId->toRfc4122());
// Aggregate columns are selected; include non-aggregated columns in GROUP BY for PostgreSQL
$qb->groupBy('s.id, s.name, s.description, s.url, s.updated_at, s.display_name, s.bias, s.reliability, s.transparency');
try {
$data = $qb->executeQuery()->fetchAssociative();
@@ -79,10 +81,10 @@ final readonly class GetSourceDetailsDbalHandler implements GetSourceDetailsHand
->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where(' s.id = :sourceId')
->andWhere('a.published_at BETWEEN FROM_UNIXTIME(:start) AND FROM_UNIXTIME(:end)')
->andWhere('a.published_at BETWEEN to_timestamp(:start) AND to_timestamp(:end)')
->groupBy('day')
->orderBy('day', 'ASC')
->setParameter('sourceId', $query->sourceId->toBinary(), ParameterType::BINARY)
->setParameter('sourceId', $query->sourceId->toRfc4122())
->setParameter('start', $dateRange->start, ParameterType::INTEGER)
->setParameter('end', $dateRange->end, ParameterType::INTEGER)
->enableResultCache(new QueryCacheProfile(SourceCacheAttributes::CACHE_TTL, $cacheKey));
@@ -120,11 +122,11 @@ final readonly class GetSourceDetailsDbalHandler implements GetSourceDetailsHand
{
$cacheKey = SourceCacheAttributes::CATEGORIES->withId($query->sourceId->toString());
$qb = $this->connection->createQueryBuilder()
->select('a.categories')
->select("array_to_string(a.categories, ',') AS categories")
->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where('s.id = :sourceId')
->setParameter('sourceId', $query->sourceId->toBinary(), ParameterType::BINARY)
->setParameter('sourceId', $query->sourceId->toRfc4122())
->enableResultCache(new QueryCacheProfile(SourceCacheAttributes::CACHE_TTL, $cacheKey));
try {
@@ -12,7 +12,6 @@ use Basango\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Features\PaginationQuery;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
/**
* Class GetSourceOverviewListDbalHandler.
@@ -37,8 +36,7 @@ final readonly class GetSourceOverviewListDbalHandler implements GetSourceOvervi
$qb = $this->addFollowedSourceExistsQuery($qb);
$qb->from('source', 's')
->groupBy('s.name')
->setParameter('userId', $query->userId->toBinary(), ParameterType::BINARY)
->setParameter('userId', $query->userId->toRfc4122())
;
$qb = $this->applyCursorPagination($qb, $query->page, new PaginatorKeyset('s.id', 's.created_at'));
@@ -22,7 +22,7 @@ trait ArticleQuery
'a.id as article_id',
'a.title as article_title',
'a.link as article_link',
'a.categories as article_categories',
"array_to_string(a.categories, ',') as article_categories",
'a.excerpt as article_excerpt',
'a.published_at as article_published_at',
'a.image as article_image',
@@ -36,7 +36,7 @@ trait ArticleQuery
'a.id as article_id',
'a.title as article_title',
'a.link as article_link',
'a.categories as article_categories',
"array_to_string(a.categories, ',') as article_categories",
'a.body as article_body',
'a.hash as article_hash',
'a.published_at as article_published_at',
@@ -62,17 +62,19 @@ trait ArticleQuery
private function applyArticleFilters(QueryBuilder $qb, ArticleFilters $filters): QueryBuilder
{
if ($filters->category !== null) {
$qb->andWhere('a.categories LIKE :category')
->setParameter('category', sprintf('%%%s%%', $filters->category));
// PostgreSQL array containment for single value
$qb->andWhere(':category = ANY(a.categories)')
->setParameter('category', $filters->category);
}
if ($filters->search !== null) {
$qb->andWhere('a.title LIKE :search')
// Case-insensitive search in PostgreSQL
$qb->andWhere('a.title ILIKE :search')
->setParameter('search', sprintf('%%%s%%', $filters->search));
}
if ($filters->dateRange instanceof DateRange) {
$qb->andWhere('a.published_at BETWEEN FROM_UNIXTIME(:start) AND FROM_UNIXTIME(:end)')
$qb->andWhere('a.published_at BETWEEN to_timestamp(:start) AND to_timestamp(:end)')
->setParameter('start', $filters->dateRange->start, ParameterType::INTEGER)
->setParameter('end', $filters->dateRange->end, ParameterType::INTEGER);
}
@@ -40,7 +40,7 @@ trait SourceQuery
"CONCAT('https://devscast.org/images/sources/', s.name, '.png') as source_image",
'COUNT(a.hash) AS articles_count',
'MAX(a.crawled_at) AS source_crawled_at',
'COUNT(CASE WHEN a.metadata IS NOT NULL THEN 1 ELSE NULL END) AS articles_metadata_available',
'COUNT(*) FILTER (WHERE a.metadata IS NOT NULL) AS articles_metadata_available',
);
}
@@ -9,7 +9,6 @@ use Basango\FeedManagement\Domain\Model\Entity\FollowedSource;
use Basango\FeedManagement\Domain\Model\Repository\FollowedSourceRepository;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ParameterType;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -41,10 +40,10 @@ final class FollowedSourceOrmRepository extends ServiceEntityRepository implemen
public function getByUserId(UserId $userId, SourceId $sourceId): ?FollowedSource
{
return $this->createQueryBuilder('fs')
->andWhere('fs.follower = :userId')
->andWhere('fs.source = :sourceId')
->setParameter('sourceId', $sourceId->toBinary(), ParameterType::BINARY)
->setParameter('userId', $userId->toBinary(), ParameterType::BINARY)
->andWhere('IDENTITY(fs.follower) = :userId')
->andWhere('IDENTITY(fs.source) = :sourceId')
->setParameter('sourceId', $sourceId->toRfc4122())
->setParameter('userId', $userId->toRfc4122())
->getQuery()
->getOneOrNullResult();
}
@@ -33,7 +33,7 @@ class User
public readonly UserId $id;
private function __construct(
public function __construct(
private(set) string $name,
private(set) EmailAddress $email,
private(set) Roles $roles,
@@ -9,7 +9,6 @@ use Basango\IdentityAndAccess\Application\UseCase\Query\GetUserProfile;
use Basango\IdentityAndAccess\Application\UseCase\QueryHandler\GetUserProfileHandler;
use Basango\IdentityAndAccess\Domain\Exception\UserNotFound;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
/**
* Class GetUserProfileDbalHandler.
@@ -35,7 +34,7 @@ final readonly class GetUserProfileDbalHandler implements GetUserProfileHandler
)
->from('user', 'u')
->where('u.id = :userId')
->setParameter('userId', $query->userId->toBinary(), ParameterType::BINARY);
->setParameter('userId', $query->userId->toRfc4122());
/** @var array<string, mixed>|false $data */
$data = $qb->executeQuery()->fetchAssociative();
@@ -52,7 +52,7 @@ final class LoginAttemptOrmRepository extends ServiceEntityRepository implements
$this->createQueryBuilder('la')
->delete(LoginAttempt::class, 'la')
->where('la.user = :user')
->setParameter('user', $user->id->toBinary())
->setParameter('user', $user)
->getQuery()
->execute();
}
@@ -8,7 +8,6 @@ use Basango\SharedKernel\Domain\Model\Pagination\Page;
use Basango\SharedKernel\Domain\Model\Pagination\PaginationCursor;
use Basango\SharedKernel\Domain\Model\Pagination\PaginationInfo;
use Basango\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Query\QueryBuilder;
/**
@@ -41,12 +40,12 @@ trait PaginationQuery
if ($keyset->date === null) {
$qb
->andWhere(sprintf('%s <= :cursorLastId', $keyset->id))
->setParameter('cursorLastId', $cursor->id->toString(), ParameterType::BINARY);
->setParameter('cursorLastId', $cursor->id->toRfc4122());
} else {
$qb
->andWhere(sprintf('(%s, %s) <= (:cursorLastDate, :cursorLastId)', $keyset->date, $keyset->id))
->setParameter('cursorLastDate', $cursor->id->toBinary(), ParameterType::BINARY)
->setParameter('cursorLastId', $cursor->date->format('Y-m-d H:i:s'));
->setParameter('cursorLastDate', $cursor->date->format('Y-m-d H:i:s'))
->setParameter('cursorLastId', $cursor->id->toRfc4122());
}
return $qb->setMaxResults($page->limit + 1);
@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace Basango\SharedKernel\Infrastructure\Persistence\Doctrine\Importer;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Statement;
use Doctrine\ORM\EntityManagerInterface;
use Generator;
use PDO;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Uid\Uuid;
use Throwable;
/**
* ImportEngine: unified, naming-accurate API for migrating data
* from a source database (old MariaDB over PDO) to a target database
* (new PostgreSQL via Doctrine DBAL/ORM).
*
* - Source: MariaDB/MySQL via PDO (unbuffered)
* - Target: PostgreSQL via Doctrine DBAL/ORM
*
* Memory tactics:
* - Reuse a fixed-size params array for inserts (no per-row allocations)
* - Stream source rows unbuffered; close cursor in finally
* - Batch transactions; commit regularly
* - Disable DBAL middlewares/loggers; disable PDO emulate prepares
* - Periodic gc_collect_cycles() on long runs
*/
final readonly class ImportEngine
{
/**
* Columns to ignore per target table.
* Key = normalized table name (lowercase, unquoted),
* Value = list of column names to exclude from insert.
*/
private const array IGNORE_COLUMNS = [
'article' => ['tsv', 'image', 'excerpt'],
];
private Connection $targetConnection;
private PDO $sourceConnection;
public function __construct(
private EntityManagerInterface $em,
#[Autowire(env: 'SOURCE_DATABASE_HOST')] private string $host,
#[Autowire(env: 'SOURCE_DATABASE_USER')] private string $user,
#[Autowire(env: 'SOURCE_DATABASE_PASS')] private string $pass,
#[Autowire(env: 'SOURCE_DATABASE_PORT')] private int $port = 3306,
#[Autowire(env: 'SOURCE_DATABASE_NAME')] private string $name = 'app',
) {
// Target (PostgreSQL via Doctrine DBAL)
$this->targetConnection = $this->em->getConnection();
$this->targetConnection->getConfiguration()->setMiddlewares([]);
// If DBAL exposes a native PDO, harden it for low memory
try {
$native = $this->targetConnection->getNativeConnection();
if ($native instanceof PDO) {
// Use server-side prepares; avoids driver-side buffering
$native->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$native->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, false);
$native->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
} catch (\Throwable) {
// If the platform/driver doesnt expose a PDO, ignore safely
}
// Source (MariaDB/MySQL via PDO), unbuffered
$this->sourceConnection = new PDO(
dsn: sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $this->host, $this->port, $this->name),
username: $this->user,
password: $this->pass,
options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
// Unbuffered cursor (critical for memory)
if (defined('PDO::MYSQL_ATTR_USE_BUFFERED_QUERY')) {
$this->sourceConnection->setAttribute(constant('PDO::MYSQL_ATTR_USE_BUFFERED_QUERY'), false);
}
}
public function import(string $table, int $batchSize = 1000): int
{
$this->reset($table);
$rows = $this->copy($table);
return $this->paste($table, $rows, $batchSize);
}
/**
* Truncate target table safely with replication role toggling.
*/
private function reset(string $tableName): void
{
$platform = $this->targetConnection->getDatabasePlatform();
$this->targetConnection->beginTransaction();
try {
$this->targetConnection->executeStatement("SET session_replication_role = 'replica'");
$sql = $platform->getTruncateTableSQL($tableName, true);
$this->targetConnection->executeStatement($sql);
$this->targetConnection->executeStatement("SET session_replication_role = 'origin'");
$this->targetConnection->commit();
} catch (Throwable $e) {
if ($this->targetConnection->isTransactionActive()) {
$this->targetConnection->rollBack();
}
throw $e;
}
}
/**
* Stream rows from MySQL unbuffered; ensure cursor is always closed.
*/
private function copy(string $table): iterable
{
$sql = sprintf('SELECT * FROM `%s`', str_replace('`', '', $table));
$stmt = $this->sourceConnection->query($sql);
if ($stmt === false) {
// Return an empty iterable on failure
return [];
}
return (function () use ($stmt): Generator {
try {
while (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) {
yield $row;
}
} finally {
// Free server resources ASAP
$stmt->closeCursor();
}
})();
}
/**
* Insert rows into PostgreSQL with minimal allocations.
* - Fixed-size $params array reused per row
* - Batch transactions to limit peak memory
* - Periodic GC for long streams
*/
private function paste(string $table, iterable $rows, int $batchSize = 1000): int
{
if ($batchSize <= 0) {
$batchSize = 1000;
}
$platform = $this->targetConnection->getDatabasePlatform();
$quote = static fn (string|int $id) => $platform->quoteIdentifier((string) $id);
$ignored = $this->ignoredColumnsFor($table);
$ignoredFlip = $ignored !== [] ? array_flip($ignored) : [];
$columns = null;
$statement = null;
$params = null; // fixed-size, reused
$total = 0;
$inBatch = 0;
try {
foreach ($rows as $row) {
// Build statement on first row (after ignoring columns)
if ($columns === null) {
if ($ignoredFlip !== []) {
$row = array_diff_key($row, $ignoredFlip);
}
/** @var list<string> $columns */
$columns = array_map(static fn (int|string $k): string => (string) $k, array_keys($row));
$columnList = implode(', ', array_map($quote, $columns));
$placeholders = implode(', ', array_fill(0, count($columns), '?'));
$sql = sprintf('INSERT INTO %s (%s) VALUES (%s)', $quote($table), $columnList, $placeholders);
$statement = $this->targetConnection->prepare($sql);
// Allocate params array once, with fixed size
$params = array_fill(0, count($columns), null);
// Begin first batch transaction
$this->targetConnection->beginTransaction();
}
// Fill params by index (avoid per-row array allocs)
$i = 0;
foreach ($columns as $col) {
$val = $row[$col] ?? null;
if ($val !== null) {
// Convert BINARY(16) UUIDs to canonical RFC4122
if ($col === 'id' || str_ends_with((string) $col, '_id')) {
$params[$i++] = Uuid::fromBinary($val)->toRfc4122();
continue;
}
// Convert invalid date to now()
if (str_ends_with((string) $col, '_at') && $val === '0000-00-00 00:00:00') {
$val = new \DateTimeImmutable('now')->format('Y-m-d H:i:s');
$params[$i++] = $val;
continue;
}
// Convert categories to PG text[] literal cheaply
if ($col === 'categories') {
if (is_string($val)) {
$val = $this->ensureUtf8String($val);
}
$params[$i++] = sprintf('{%s}', $val);
continue;
}
if (is_string($val)) {
$params[$i++] = $this->ensureUtf8String($val);
continue;
}
}
$params[$i++] = $val;
}
if (! $statement instanceof Statement) {
throw new \LogicException('Insert statement not initialized.');
}
// @phpstan-ignore-next-line
$statement->executeStatement($params);
$total++;
$inBatch++;
if ($inBatch >= $batchSize) {
$this->targetConnection->commit();
$inBatch = 0;
// Start next batch transaction
$this->targetConnection->beginTransaction();
// Help GC on very long imports
if (($total % ($batchSize * 5)) === 0) {
gc_collect_cycles();
}
}
}
// Commit trailing rows if any
if ($inBatch > 0 && $this->targetConnection->isTransactionActive()) {
$this->targetConnection->commit();
}
} catch (Throwable $e) {
if ($this->targetConnection->isTransactionActive()) {
$this->targetConnection->rollBack();
}
// Keep failure payloads small to avoid memory spikes
throw $e;
} finally {
// Release large references promptly
$statement = null;
$columns = null;
$params = null;
gc_collect_cycles();
}
return $total;
}
private function ignoredColumnsFor(string $table): array
{
$normalized = strtolower(trim($table, '`"'));
return self::IGNORE_COLUMNS[$normalized] ?? [];
}
/**
* Keep it cheap: fast-path valid UTF-8; otherwise minimal conversions.
*/
private function ensureUtf8String(string $value): string
{
// Fast path: valid UTF-8
if (@preg_match('//u', $value) === 1) {
return $value;
}
// Try common legacy encodings with transliteration
$converted = @iconv('CP1252', 'UTF-8//TRANSLIT', $value);
if ($converted === false) {
$converted = @iconv('ISO-8859-1', 'UTF-8//TRANSLIT', $value);
}
if ($converted === false) {
// Last resort: drop invalid sequences
$converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value);
}
return $converted !== false ? $converted : $value;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Basango\SharedKernel\Presentation\Console;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\Importer\ImportEngine;
use Override;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:sync-import',
description: 'from mariadb to postgres'
)]
class SyncImport extends Command
{
use AskArgumentFeature;
private SymfonyStyle $io;
public function __construct(
private readonly ImportEngine $importEngine
) {
parent::__construct();
}
#[Override]
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (! $this->io->confirm('Do you want to continue?', false)) {
$this->io->warning('Process aborted');
return Command::FAILURE;
}
$tables = ['user', 'source', 'article'];
foreach ($tables as $table) {
$count = $this->importEngine->import($table);
$this->io->text(sprintf('Imported %d records into %s table.', $count, $table));
}
$this->io->success('Source add successfully');
return Command::SUCCESS;
}
}