199 lines
8.1 KiB
PHP
199 lines
8.1 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
|
use RuntimeException;
|
|
|
|
class BusinessMirrorSchemaManager
|
|
{
|
|
/** @return list<array<string,mixed>> */
|
|
public function ensure(BusinessMirrorDataset $dataset): array
|
|
{
|
|
$columns = $this->columns($dataset);
|
|
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
|
$schema = Schema::connection($connection);
|
|
|
|
if (! $schema->hasTable($dataset->targetTable())) {
|
|
$schema->create($dataset->targetTable(), function (Blueprint $table) use ($columns, $dataset): void {
|
|
$table->char('_mirror_key', 64)->primary();
|
|
|
|
foreach ($columns as $column) {
|
|
$this->addColumn($table, $column);
|
|
}
|
|
|
|
$table->uuid('_mirror_generation')->nullable()->index();
|
|
$table->dateTime('_mirror_synced_at', 6)->index();
|
|
|
|
$watermark = $dataset->watermarkColumn();
|
|
if ($watermark !== null && $this->isIndexable($columns, $watermark)) {
|
|
$table->index($watermark, $this->indexName($dataset->targetTable(), 'watermark'));
|
|
}
|
|
|
|
foreach ($this->validatedIndexes($dataset, $columns) as $position => $index) {
|
|
$table->index($index, $this->configuredIndexName($dataset->targetTable(), $index, $position));
|
|
}
|
|
});
|
|
|
|
return $columns;
|
|
}
|
|
|
|
$existing = array_map('strtolower', $schema->getColumnListing($dataset->targetTable()));
|
|
$missing = array_values(array_filter(
|
|
$columns,
|
|
fn (array $column): bool => ! in_array(strtolower((string) $column['column_name']), $existing, true),
|
|
));
|
|
|
|
if ($missing !== []) {
|
|
$schema->table($dataset->targetTable(), function (Blueprint $table) use ($missing): void {
|
|
foreach ($missing as $column) {
|
|
$this->addColumn($table, $column);
|
|
}
|
|
});
|
|
}
|
|
|
|
$configuredIndexes = $this->validatedIndexes($dataset, $columns);
|
|
$existingIndexes = collect($schema->getIndexes($dataset->targetTable()))
|
|
->map(static fn (array $index): array => array_map('strtolower', (array) ($index['columns'] ?? [])))
|
|
->all();
|
|
$missingIndexes = array_values(array_filter(
|
|
$configuredIndexes,
|
|
static fn (array $index): bool => ! in_array(array_map('strtolower', $index), $existingIndexes, true),
|
|
));
|
|
|
|
if ($missingIndexes !== []) {
|
|
$schema->table($dataset->targetTable(), function (Blueprint $table) use ($dataset, $missingIndexes): void {
|
|
foreach ($missingIndexes as $position => $index) {
|
|
$table->index($index, $this->configuredIndexName($dataset->targetTable(), $index, $position));
|
|
}
|
|
});
|
|
}
|
|
|
|
return $columns;
|
|
}
|
|
|
|
/** @return list<array<string,mixed>> */
|
|
public function columns(BusinessMirrorDataset $dataset): array
|
|
{
|
|
$rows = DB::table('upstream_schema_columns')
|
|
->where('source_key', $dataset->sourceKey())
|
|
->whereRaw('LOWER(schema_name) = ?', [strtolower($dataset->sourceSchema())])
|
|
->whereRaw('LOWER(object_name) = ?', [strtolower($dataset->sourceObject())])
|
|
->orderBy('ordinal_position')
|
|
->get()
|
|
->map(fn (object $row): array => (array) $row)
|
|
->all();
|
|
|
|
if ($rows === []) {
|
|
throw new RuntimeException('business_mirror_schema_catalog_missing:' . $dataset->key());
|
|
}
|
|
|
|
$names = array_map(fn (array $row): string => strtolower((string) $row['column_name']), $rows);
|
|
if (count($names) !== count(array_unique($names))) {
|
|
throw new RuntimeException('business_mirror_duplicate_column:' . $dataset->key());
|
|
}
|
|
|
|
foreach ($rows as $row) {
|
|
$name = (string) $row['column_name'];
|
|
if (strlen($name) > 64 || str_starts_with(strtolower($name), '_mirror_')) {
|
|
throw new RuntimeException('business_mirror_unsupported_column:' . $dataset->key());
|
|
}
|
|
}
|
|
|
|
foreach ([...$dataset->keyColumns(), $dataset->watermarkColumn()] as $required) {
|
|
if ($required !== null && ! in_array(strtolower($required), $names, true)) {
|
|
throw new RuntimeException('business_mirror_required_column_missing:' . $dataset->key());
|
|
}
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
private function addColumn(Blueprint $table, array $column): void
|
|
{
|
|
$name = (string) $column['column_name'];
|
|
$type = strtolower((string) $column['data_type']);
|
|
$length = (int) ($column['max_length'] ?? 0);
|
|
$precision = min(max((int) ($column['numeric_precision'] ?? 20), 1), 65);
|
|
$scale = min(max((int) ($column['numeric_scale'] ?? 0), 0), min($precision, 30));
|
|
|
|
$definition = match ($type) {
|
|
'bit' => $table->boolean($name),
|
|
'tinyint' => $table->unsignedTinyInteger($name),
|
|
'smallint' => $table->smallInteger($name),
|
|
'int' => $table->integer($name),
|
|
'bigint' => $table->bigInteger($name),
|
|
'decimal', 'numeric', 'money', 'smallmoney' => $table->decimal($name, $precision, $scale),
|
|
'float', 'real' => $table->double($name),
|
|
'date' => $table->date($name),
|
|
'datetime', 'datetime2', 'smalldatetime' => $table->dateTime($name, 6),
|
|
'time' => $table->time($name, 6),
|
|
'uniqueidentifier' => $table->char($name, 36),
|
|
'binary', 'varbinary', 'image', 'timestamp', 'rowversion' => $table->binary($name),
|
|
// MariaDB counts every inline varchar column towards the 65,535-byte
|
|
// row limit. Wide legacy SQL Server tables can contain dozens of
|
|
// nvarchar columns even though each individual column looks small.
|
|
// Keep only genuinely short values inline; TEXT is stored off-page
|
|
// and lets the mirror preserve the complete source schema.
|
|
'char', 'nchar', 'varchar', 'nvarchar' => $length > 0 && $length <= 64
|
|
? $table->string($name, $length)
|
|
: $table->longText($name),
|
|
default => $table->longText($name),
|
|
};
|
|
|
|
$definition->nullable();
|
|
}
|
|
|
|
private function isIndexable(array $columns, string $name): bool
|
|
{
|
|
foreach ($columns as $column) {
|
|
if (strcasecmp((string) $column['column_name'], $name) !== 0) {
|
|
continue;
|
|
}
|
|
|
|
$type = strtolower((string) $column['data_type']);
|
|
$length = (int) ($column['max_length'] ?? 0);
|
|
|
|
return ! in_array($type, ['text', 'ntext', 'xml', 'image', 'binary', 'varbinary'], true)
|
|
&& (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar'], true) || ($length > 0 && $length <= 64));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function indexName(string $table, string $suffix): string
|
|
{
|
|
return substr($table . '_' . $suffix, 0, 64);
|
|
}
|
|
|
|
/** @return list<list<string>> */
|
|
private function validatedIndexes(BusinessMirrorDataset $dataset, array $columns): array
|
|
{
|
|
$columnNames = array_map(
|
|
static fn (array $column): string => strtolower((string) $column['column_name']),
|
|
$columns,
|
|
);
|
|
|
|
foreach ($dataset->indexDefinitions() as $index) {
|
|
foreach ($index as $column) {
|
|
if (! in_array(strtolower($column), $columnNames, true)
|
|
|| ! $this->isIndexable($columns, $column)) {
|
|
throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key());
|
|
}
|
|
}
|
|
}
|
|
|
|
return $dataset->indexDefinitions();
|
|
}
|
|
|
|
private function configuredIndexName(string $table, array $columns, int $position): string
|
|
{
|
|
$signature = implode('_', $columns) . '_' . $position;
|
|
|
|
return substr($table . '_lookup_' . hash('sha256', $signature), 0, 64);
|
|
}
|
|
}
|