> */ 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, $dataset); } $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( $this->indexColumns($index), $this->configuredIndexName($dataset->targetTable(), $index, $position), ); } }); $this->ensureDerivedLookups($dataset, $connection); 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, $dataset): void { foreach ($missing as $column) { $this->addColumn($table, $column, $dataset); } }); } $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, fn (array $index): bool => ! in_array( array_map(fn (string $column): string => strtolower($this->indexBaseColumn($column)), $index), $existingIndexes, true, ), )); if ($missingIndexes !== []) { $schema->table($dataset->targetTable(), function (Blueprint $table) use ($dataset, $missingIndexes): void { foreach ($missingIndexes as $position => $index) { $table->index( $this->indexColumns($index), $this->configuredIndexName($dataset->targetTable(), $index, $position), ); } }); } $this->ensureDerivedLookups($dataset, $connection); return $columns; } private function ensureDerivedLookups(BusinessMirrorDataset $dataset, string $connection): void { if ($dataset->targetTable() === GoodsProfitAfsLookupSchema::TABLE) { app(GoodsProfitAfsLookupSchema::class)->ensure(DB::connection($connection)); } } /** @return list> */ 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(); $selected = app(BusinessMirrorDatasetRegistry::class)->columnSelection($dataset->key()); if ($selected !== null) { $selected = array_map('strtolower', $selected); $rows = array_values(array_filter($rows, fn (array $row): bool => in_array(strtolower($row['column_name']), $selected, true))); if (count($rows) !== count($selected)) throw new RuntimeException('business_mirror_selected_column_missing:' . $dataset->key()); } 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()); } } $fixedFilterColumns = array_keys($dataset->fixedFilters()); foreach ([...$dataset->keyColumns(), $dataset->watermarkColumn(), ...$fixedFilterColumns] 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, BusinessMirrorDataset $dataset): 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)); // 超宽表(例如商品利润 160 多个 nvarchar 列)即使每列只有 50 字符, // 全部按 VARCHAR 建表也会超过 InnoDB 行长度上限;这类数据集把非键列存为 TEXT。 $wideStrings = (bool) app(BusinessMirrorDatasetRegistry::class) ->definitionValue($dataset->key(), 'wide_strings', false); $requiredInline = in_array(strtolower($name), array_map('strtolower', $dataset->keyColumns()), true) || strcasecmp((string) $dataset->watermarkColumn(), $name) === 0 || $this->isConfiguredIndexColumn($dataset, $name); $definition = match ($type) { 'bit', 'boolean' => $table->boolean($name), 'tinyint' => $table->unsignedTinyInteger($name), 'smallint' => $table->smallInteger($name), 'int', 'integer' => $table->integer($name), 'bigint' => $table->bigInteger($name), 'decimal', 'numeric', 'money', 'smallmoney' => $table->decimal($name, $precision, $scale), 'float', 'real', 'double', 'double precision' => $table->double($name), 'date' => $table->date($name), 'datetime', 'datetime2', 'smalldatetime', 'timestamp without time zone', 'timestamp with time zone' => $table->dateTime($name, 6), 'time' => $table->time($name, 6), 'uniqueidentifier', 'uuid' => $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. A declared lookup index // may opt a bounded legacy identifier into VARCHAR; every other // wide string remains TEXT and is stored off-page. 'char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying' => $length > 0 && ! ($wideStrings && ! $requiredInline) && ($length <= 64 || ($length <= 255 && $this->isConfiguredIndexColumn($dataset, $name))) ? $table->string($name, $length) : $table->longText($name), default => $table->longText($name), }; $definition->nullable(); } private function isIndexable(array $columns, string $name, ?BusinessMirrorDataset $dataset = null): 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', 'bytea', 'json', 'jsonb', 'array', 'user-defined'], true) && (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying'], true) || ($length > 0 && ($length <= 64 || ($length <= 255 && $dataset !== null && $this->isConfiguredIndexColumn($dataset, $name))))); } return false; } private function indexName(string $table, string $suffix): string { return substr($table . '_' . $suffix, 0, 64); } /** @return list> */ 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) { $base = $this->indexBaseColumn($column); if (! in_array(strtolower($base), $columnNames, true)) { throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key()); } if ($base !== $column) { continue; } if (! $this->isIndexable($columns, $base, $dataset)) { throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key()); } } } return $dataset->indexDefinitions(); } private function isConfiguredIndexColumn(BusinessMirrorDataset $dataset, string $column): bool { foreach ($dataset->indexDefinitions() as $index) { foreach ($index as $indexedColumn) { if (strcasecmp((string) $indexedColumn, $column) === 0) { return true; } } } return false; } /** @param list $index @return list */ private function indexColumns(array $index): array { return array_map(function (string $column): string|Expression { if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\((\d+)\)$/', $column, $matched) !== 1) { return $column; } return DB::raw(sprintf('`%s`(%d)', $matched[1], (int) $matched[2])); }, $index); } private function indexBaseColumn(string $column): string { return preg_replace('/\(\d+\)$/', '', $column) ?? $column; } private function configuredIndexName(string $table, array $columns, int $position): string { $signature = implode('_', $columns) . '_' . $position; return substr($table . '_lookup_' . hash('sha256', $signature), 0, 64); } }