Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions libs/output-mapping/src/LoadTableTaskCreator.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ public function create(
$settings->hasNewNativeTypesFeature(),
$settings->getTreatValuesAsNull(),
);
$defaultBranchTable = $storageSources->getDefaultBranchTable();
$canCreateTypedTable = $defaultBranchTable === null || $defaultBranchTable->isTyped();

// some scenarios are not supported by the SAPI, so we need to take care of them manually here
// - columns in config + headless CSV (SAPI always expect to have a header in CSV)
// - sliced files
if ($settings->hasNativeTypesFeature() &&
if ($canCreateTypedTable &&
$settings->hasNativeTypesFeature() &&
!$storageSources->didTableExistBefore() &&
$source->hasColumns() && $source->hasColumnMetadata()
) {
Expand All @@ -67,7 +70,8 @@ public function create(
);
$this->tableCreator->createTableDefinition($source->getDestination()->getBucketId(), $tableDefinition);
$loadTask = new LoadTableTask($source->getDestination(), $loadOptions, true);
} elseif ($settings->hasNewNativeTypesFeature() &&
} elseif ($canCreateTypedTable &&
$settings->hasNewNativeTypesFeature() &&
!$storageSources->didTableExistBefore() &&
$source->getSchema()
) {
Expand All @@ -78,6 +82,25 @@ public function create(
);
$this->tableCreator->createTableDefinition($source->getDestination()->getBucketId(), $tableDefinition);
$loadTask = new LoadTableTask($source->getDestination(), $loadOptions, true);
} elseif (!$canCreateTypedTable &&
$settings->hasNewNativeTypesFeature() &&
!$storageSources->didTableExistBefore() &&
$source->getSchema()
) {
// Typed-table creation was suppressed because the production (default-branch) table is
// non-typed (AJDA-3014). The columns are still known from the schema, so create a plain
// non-typed table with those columns; otherwise the headless CSV would be imported with
// its first data row taken as the header (via the CreateAndLoadTableTask fallback).
$this->tableCreator->createTable(
$source->getDestination()->getBucketId(),
$source->getDestination()->getTableName(),
array_map(
fn (MappingFromConfigurationSchemaColumn $column) => $column->getName(),
$source->getSchema(),
),
$loadOptions,
);
$loadTask = new LoadTableTask($source->getDestination(), $loadOptions, true);
} elseif (!$storageSources->didTableExistBefore() && $source->hasColumns()) {
// tabulka neexistuje a známe sloupce z manifestu
$this->tableCreator->createTable(
Expand Down
12 changes: 10 additions & 2 deletions libs/output-mapping/src/Mapping/MappingStorageSources.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@

class MappingStorageSources
{
public function __construct(readonly private BucketInfo $bucket, readonly private ?TableInfo $table)
{
public function __construct(
readonly private BucketInfo $bucket,
readonly private ?TableInfo $table,
readonly private ?TableInfo $defaultBranchTable = null,
) {
}

public function getBucket(): BucketInfo
Expand All @@ -27,4 +30,9 @@ public function getTable(): ?TableInfo
{
return $this->table;
}

public function getDefaultBranchTable(): ?TableInfo
{
return $this->defaultBranchTable;
}
}
24 changes: 23 additions & 1 deletion libs/output-mapping/src/Storage/StoragePreparer.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ public function prepareStorageBucketAndTable(
$destinationTableInfo = $this->getDestinationTableInfoIfExists(
$processedSource->getDestination()->getTableId(),
);
$defaultBranchTableInfo = null;
if ($destinationTableInfo === null &&
$this->clientWrapper->getClientOptionsReadOnly()->useBranchStorage() &&
$this->clientWrapper->isDevelopmentBranch()
) {
$defaultBranchTableInfo = $this->getDefaultBranchTableInfoIfExists(
$processedSource->getDestination()->getTableId(),
);
}

if ($destinationTableInfo !== null) {
if ($this->hasNewNativeTypeFeature && $processedSource->getSchema()) {
Expand Down Expand Up @@ -69,7 +78,7 @@ public function prepareStorageBucketAndTable(
);
}

return new MappingStorageSources($destinationBucket, $destinationTableInfo);
return new MappingStorageSources($destinationBucket, $destinationTableInfo, $defaultBranchTableInfo);
}

private function getDestinationTableInfoIfExists(string $tableId): ?TableInfo
Expand All @@ -84,4 +93,17 @@ private function getDestinationTableInfoIfExists(string $tableId): ?TableInfo

return null;
}

private function getDefaultBranchTableInfoIfExists(string $tableId): ?TableInfo
{
try {
return new TableInfo($this->clientWrapper->getClientForDefaultBranch()->getTable($tableId));
} catch (ClientException $e) {
if ($e->getCode() !== 404) {
throw $e;
}
}

return null;
}
}
137 changes: 137 additions & 0 deletions libs/output-mapping/tests/LoadTableTaskCreatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Keboola\OutputMapping\Mapping\MappingFromRawConfigurationAndPhysicalDataWithManifest;
use Keboola\OutputMapping\Mapping\MappingStorageSources;
use Keboola\OutputMapping\Storage\BucketInfo;
use Keboola\OutputMapping\Storage\TableInfo;
use Keboola\OutputMapping\Tests\AbstractTestCase;
use Keboola\OutputMapping\Tests\Needs\NeedsEmptyOutputBucket;
use Keboola\OutputMapping\Tests\Needs\NeedsTestTables;
Expand Down Expand Up @@ -347,6 +348,142 @@ public function testLoadTaskTableNotExistsManifestNotExists(): void
);
}

#[NeedsEmptyOutputBucket]
public function testNativeTypeCreationSuppressedWhenDefaultBranchTableNonTyped(): void
{
// The source has native types enabled and column metadata, so it would normally create a
// typed table (see testNativeTypeLoadTaskTableNotExists). Because the production
// (default-branch) table already exists and is NON-typed, typed creation must be
// suppressed and a plain non-typed table created instead (AJDA-3014).
$settings = self::createMock(OutputMappingSettings::class);
// The typed-creation branches are gated on $canCreateTypedTable first, which is false here
// and short-circuits, so hasNativeTypesFeature() is never queried. hasNewNativeTypesFeature()
// is queried in buildLoadOptions and again in the suppressed-schema branch (returns false
// here since this is the metadata path, so a plain non-typed table is created via columns).
$settings->expects(self::never())->method('hasNativeTypesFeature');
$settings->expects(self::exactly(2))->method('hasNewNativeTypesFeature')->willReturn(false);

$strategy = self::createMock(LocalTableStrategy::class);
$strategy->expects(self::once())
->method('prepareLoadTaskOptions')
->willReturn([]);

$source = self::createMock(MappingFromProcessedConfiguration::class);
$source->expects(self::once())->method('hasColumns')->willReturn(true);
$source->expects(self::exactly(3))->method('getDestination')->willReturn(
new MappingDestination($this->emptyOutputBucketId . '.destinationTable'),
);
$source->expects(self::once())->method('getPrimaryKey')->willReturn([]);
$source->expects(self::exactly(2))->method('getColumns')->willReturn(['Id', 'Name']);

$storageSources = self::createMock(MappingStorageSources::class);
$storageSources->expects(self::exactly(2))->method('didTableExistBefore')->willReturn(false);
$storageSources->expects(self::once())->method('getDefaultBranchTable')->willReturn(
new TableInfo([
'id' => $this->emptyOutputBucketId . '.destinationTable',
'columns' => ['Id', 'Name'],
'primaryKey' => [],
'isTyped' => false,
]),
);

$loadTableTaskCreator = new LoadTableTaskCreator(
$this->clientWrapper,
$this->testLogger,
);
$loadTask = $loadTableTaskCreator->create(
strategy: $strategy,
source: $source,
storageSources: $storageSources,
settings: $settings,
);

self::assertInstanceOf(LoadTableTask::class, $loadTask);
self::assertTrue($loadTask->isUsingFreshlyCreatedTable());
$storageTable = $this->clientWrapper->getTableAndFileStorageClient()->getTable(
$this->emptyOutputBucketId . '.destinationTable',
);

self::assertFalse($storageTable['isTyped']);
self::assertSame(['Id', 'Name'], $storageTable['columns']);
}

#[NeedsEmptyOutputBucket]
public function testNativeTypeCreationAllowedWhenDefaultBranchTableTyped(): void
{
// Same setup as testNativeTypeLoadTaskTableNotExists, but with a TYPED production
// (default-branch) table present. The fix must not over-suppress: typed creation stays
// allowed when the production table is already typed (AJDA-3014).
$settings = self::createMock(OutputMappingSettings::class);
$settings->expects(self::once())->method('hasNativeTypesFeature')->willReturn(true);
$settings->expects(self::once())->method('hasBigqueryNativeTypesFeature')->willReturn(false);

$strategy = self::createMock(LocalTableStrategy::class);
$strategy->expects(self::once())
->method('prepareLoadTaskOptions')
->willReturn([]);

$source = self::createMock(MappingFromProcessedConfiguration::class);
$source->expects(self::once())->method('hasColumns')->willReturn(true);
$source->expects(self::once())->method('hasColumnMetadata')->willReturn(true);
$source->expects(self::once())->method('hasMetadata')->willReturn(false);
$source->expects(self::exactly(3))->method('getDestination')->willReturn(
new MappingDestination($this->emptyOutputBucketId . '.destinationTable'),
);
$source->expects(self::exactly(2))->method('getPrimaryKey')->willReturn([]);
$source->expects(self::once())->method('getColumnMetadata')->willReturn([
new MappingColumnMetadata('col1', [
[
'key' => 'KBC.datatype.basetype',
'value' => 'STRING',
],
]),
new MappingColumnMetadata('col2', [
[
'key' => 'KBC.datatype.basetype',
'value' => 'INTEGER',
],
]),
]);

$storageSources = self::createMock(MappingStorageSources::class);
$storageSources->expects(self::exactly(2))->method('didTableExistBefore')->willReturn(false);
$storageSources->expects(self::once())->method('getDefaultBranchTable')->willReturn(
new TableInfo([
'id' => $this->emptyOutputBucketId . '.destinationTable',
'columns' => ['col1', 'col2'],
'primaryKey' => [],
'isTyped' => true,
]),
);
$storageSources->expects(self::once())->method('getBucket')->willReturn(
new BucketInfo([
'id' => $this->emptyOutputBucketId,
'backend' => 'Snowflake',
'metadata' => [],
]),
);

$loadTableTaskCreator = new LoadTableTaskCreator(
$this->clientWrapper,
$this->testLogger,
);
$loadTask = $loadTableTaskCreator->create(
strategy: $strategy,
source: $source,
storageSources: $storageSources,
settings: $settings,
);

self::assertInstanceOf(LoadTableTask::class, $loadTask);
self::assertTrue($loadTask->isUsingFreshlyCreatedTable());
$storageTable = $this->clientWrapper->getTableAndFileStorageClient()->getTable(
$this->emptyOutputBucketId . '.destinationTable',
);

self::assertTrue($storageTable['isTyped']);
}

/**
* @dataProvider buildLoadOptionsDataProvider
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,10 @@ public function testBasic(): void
self::assertEquals($bucketInfo, $source->getBucket());
self::assertFalse($source->didTableExistBefore());
self::assertNull($source->getTable());
self::assertNull($source->getDefaultBranchTable());

$source = new MappingStorageSources($bucketInfo, null, $table);
self::assertFalse($source->didTableExistBefore());
self::assertEquals($table, $source->getDefaultBranchTable());
}
}
Loading
Loading