dataset(); $client = Mockery::mock(ClickHouseClient::class); $client->shouldReceive('insertJsonEachRow')->once()->withArgs( static function (string $table, array $columns, array $rows): bool { return $table === 'fact_rows' && $columns === ['_mirror_key', 'Id', 'name', '_mirror_generation', '_mirror_synced_at', '_mirror_version'] && $rows[0]['_mirror_key'] === 'key-1' && $rows[0]['Id'] === 1 && $rows[0]['name'] === '记录一' && $rows[0]['_mirror_generation'] === '11111111-1111-4111-8111-111111111111' && is_int($rows[0]['_mirror_version']); }, ); $repository = new ColumnarBusinessMirrorRepository($client); $repository->storeBackfillBatch($dataset, [ ['column_name' => 'Id', 'data_type' => 'int'], ['column_name' => 'name', 'data_type' => 'nvarchar'], ], [[ '_mirror_key' => 'key-1', 'Id' => 1, 'name' => '记录一', '_mirror_synced_at' => '2026-09-07 12:00:00.000000', ]], '11111111-1111-4111-8111-111111111111'); } public function test_columnar_schema_uses_replacing_merge_tree_and_sql_server_safe_types(): void { $dataset = $this->dataset(); $source = Mockery::mock(BusinessMirrorSchemaManager::class); $source->shouldReceive('columns')->once()->andReturn([ ['column_name' => 'Id', 'data_type' => 'int'], ['column_name' => 'flag', 'data_type' => 'tinyint'], ['column_name' => 'amount', 'data_type' => 'decimal', 'numeric_precision' => 20, 'numeric_scale' => 4], ['column_name' => 'updated_at', 'data_type' => 'datetime2'], ]); $placements = Mockery::mock(BusinessDataPlacement::class); $placements->shouldReceive('targetStore')->once()->with('source.schema.fact')->andReturn('clickhouse'); $client = Mockery::mock(ClickHouseClient::class); $client->shouldReceive('identifier')->times(4)->andReturnUsing(static fn (string $name): string => '`' . $name . '`'); $client->shouldReceive('qualifiedTable')->once()->with('fact_rows')->andReturn('`analytics`.`fact_rows`'); $client->shouldReceive('scalar')->once()->withArgs(static fn (string $sql, array $parameters): bool => str_contains($sql, 'SELECT sorting_key FROM system.tables') && $parameters['database'] === 'caiwu_business_analytics' && $parameters['table'] === 'fact_rows' )->andReturn('_mirror_generation, _mirror_key'); $client->shouldReceive('command')->once()->withArgs(static fn (string $sql): bool => str_contains($sql, '`flag` Nullable(UInt8)') && str_contains($sql, '`amount` Nullable(Decimal(20, 4))') && str_contains($sql, "`updated_at` Nullable(DateTime64(6, 'Asia/Shanghai'))") && str_contains($sql, 'ENGINE = ReplacingMergeTree(_mirror_version)') && str_contains($sql, 'PARTITION BY modulo(cityHash64(_mirror_key), 32)') && str_contains($sql, 'ORDER BY (_mirror_generation, _mirror_key)') ); $columns = (new ClickHouseSchemaManager($source, $client, $placements))->ensure($dataset); $this->assertCount(4, $columns); } public function test_columnar_schema_rejects_a_generation_unsafe_existing_sorting_key(): void { $dataset = $this->dataset(); $source = Mockery::mock(BusinessMirrorSchemaManager::class); $source->shouldReceive('columns')->once()->andReturn([ ['column_name' => 'Id', 'data_type' => 'int'], ]); $placements = Mockery::mock(BusinessDataPlacement::class); $placements->shouldReceive('targetStore')->once()->with('source.schema.fact')->andReturn('clickhouse'); $client = Mockery::mock(ClickHouseClient::class); $client->shouldReceive('identifier')->once()->with('Id')->andReturn('`Id`'); $client->shouldReceive('qualifiedTable')->once()->with('fact_rows')->andReturn('`analytics`.`fact_rows`'); $client->shouldReceive('command')->once(); $client->shouldReceive('scalar')->once()->andReturn('_mirror_key'); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('business_data_columnar_generation_key_mismatch'); (new ClickHouseSchemaManager($source, $client, $placements))->ensure($dataset); } private function dataset(): BusinessMirrorDataset { return new class implements BusinessMirrorDataset { public function key(): string { return 'source.schema.fact'; } public function sourceKey(): string { return 'source'; } public function sourceSchema(): string { return 'schema'; } public function sourceObject(): string { return 'fact'; } public function targetTable(): string { return 'fact_rows'; } public function keyColumns(): array { return ['Id']; } public function watermarkColumn(): ?string { return 'updated_at'; } public function fixedFilters(): array { return []; } public function indexDefinitions(): array { return []; } }; } }