Skip to content

[GoogleSheet] Add a new Google Sheet loader #1609

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
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
2 changes: 1 addition & 1 deletion bin/docs.php
Original file line number Diff line number Diff line change
@@ -41,7 +41,7 @@ public function execute(InputInterface $input, OutputInterface $output) : int
__DIR__ . '/../src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/functions.php',
__DIR__ . '/../src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/functions.php',
__DIR__ . '/../src/adapter/etl-adapter-elasticsearch/src/Flow/ETL/Adapter/Elasticsearch/functions.php',
__DIR__ . '/../src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/functions.php',
__DIR__ . '/../src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php',
__DIR__ . '/../src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/functions.php',
__DIR__ . '/../src/adapter/etl-adapter-meilisearch/src/Flow/ETL/Adapter/Meilisearch/functions.php',
__DIR__ . '/../src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/functions.php',
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -139,7 +139,7 @@
"src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/functions.php",
"src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/functions.php",
"src/adapter/etl-adapter-elasticsearch/src/Flow/ETL/Adapter/Elasticsearch/functions.php",
"src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/functions.php",
"src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php",
"src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/functions.php",
"src/adapter/etl-adapter-meilisearch/src/Flow/ETL/Adapter/Meilisearch/functions.php",
"src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/functions.php",
@@ -208,6 +208,7 @@
},
"extra": {
"google/apiclient-services": [
"Drive",
"Sheets"
]
},
60 changes: 53 additions & 7 deletions documentation/components/adapters/google-sheet.md
Original file line number Diff line number Diff line change
@@ -27,23 +27,69 @@ composer require flow-php/etl-adapter-google-sheet:~--FLOW_PHP_VERSION--
```php
<?php

use function Flow\ETL\Adapter\GoogleSheet\{from_google_sheet};
use Flow\ETL\Adapter\GoogleSheet\GoogleSheetRange;
use Flow\ETL\DSL\GoogleSheet;
use Flow\ETL\Flow;

$rows = (new Flow())
->read(GoogleSheet::from($auth_config, $spreadsheet_document_id, $sheet_name)))
->read(from_google_sheet($auth_config, $spreadsheet_document_id, $sheet_name)))
->fetch();
```

## Needed parameters
## Required parameters

- `$auth_config`

- Create project: [console.cloud.google.com](https://console.cloud.google.com/projectcreate) choosing the right organization.
- Enable google sheet API for created project on [api sheets.googleapis.com](https://console.cloud.google.com/apis/library/sheets.googleapis.com)
- To work with google sheet enable it on [serviceaccounts](https://console.cloud.google.com/iam-admin/serviceaccounts/create) this will generate email for example `serviceaccounts@project.iam.gserviceaccount.com`
- Generate json (auth config) for created serviceaccounts on `Keys` tab.
- Create a project: [console.cloud.google.com](https://console.cloud.google.com/projectcreate) choosing the right organization,
- Enable Google Sheet API for a created project on [API sheets.googleapis.com](https://console.cloud.google.com/apis/library/sheets.googleapis.com),
- To work with Google Sheet enable it on a [serviceaccounts](https://console.cloud.google.com/iam-admin/serviceaccounts/create) this will generate email for example `serviceaccounts@project.iam.gserviceaccount.com`,
- Generate JSON (auth config), for created "serviceaccounts" on `Keys` tab.

- `$spreadsheet_document_id` ID needs to be readded from the document we want to use, example URL `https://docs.google.com/spreadsheets/d/xyzID-for-documentxyz/edit` ID is `xyzID-for-documentxyz`
- `$sheet_name` - Name of sheet from document you want to read.
- `$sheet_name` - Name of the sheet from the document you want to read.

## Loader

```php
<?php

use function Flow\ETL\Adapter\GoogleSheet\{google_create_spreadsheet,
google_sheets,
to_google_sheet};
use function Flow\ETL\DSL\{config, flow_context};
use Flow\ETL\Flow;
use Flow\ETL\Row;
use Flow\ETL\Rows;
use Google\Service\Sheets;

// Create a Google spreadsheet
$service = google_sheets(
$auth_config,
// Scope required to creating & updating spreadsheets
Sheets::SPREADSHEETS
);

$sheetName = 'Flow test sheet';
$spreadsheet = google_create_spreadsheet(
$service,
'Flow test spreadsheet',
$sheetName,
'random@gmail.com'
);

(new Flow())
->process(
new Rows(
...\array_map(
fn (int $i) : Row => Row::create(
new Row\Entry\IntegerEntry('id', $i),
new Row\Entry\StringEntry('name', 'name_' . $i)
),
\range(0, 10)
)
)
)
->write(to_google_sheet($service, $spreadsheet->spreadsheetId, $sheetName))
->run();
```
4 changes: 4 additions & 0 deletions documentation/upgrading.md
Original file line number Diff line number Diff line change
@@ -58,6 +58,10 @@ Selected transformers were deprecated in favor of using `DataFrame::renameEach()
- `RenameAllCaseTransformer` -> `RenameCaseTransformer`,
- `RenameStrReplaceAllEntriesTransformer` -> `RenameReplaceStrategy`,

### 3) Moved `src/Flow/ETL/Adapter/GoogleSheet/functions.php` file

The file was moved to subfolder and now is located at: `src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php`

---

## Upgrading from 0.14.x to 0.15.x
3 changes: 3 additions & 0 deletions examples/topics/data_reading/google_sheet/.env.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GOOGLE_SPREADSHEET_NAME='Flow test spreadsheet'
GOOGLE_SHEET_NAME='Flow test sheet'
GOOGLE_SHEET_EMAIL='random@gmail.com'
3 changes: 3 additions & 0 deletions examples/topics/data_reading/google_sheet/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.env
auth.json
vendor
11 changes: 11 additions & 0 deletions examples/topics/data_reading/google_sheet/auth.json.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"type": "service_account",
"project_id": "flow-test",
"client_email": "flow-test@flow-test.iam.gserviceaccount.com",
"client_id": "1234567890",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/flow-test%flow-test.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
54 changes: 54 additions & 0 deletions examples/topics/data_reading/google_sheet/code.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

use function Flow\ETL\Adapter\GoogleSheet\{from_google_sheet, google_create_spreadsheet, google_sheets, to_google_sheet};
use function Flow\ETL\DSL\{data_frame, from_array, to_stream};
use Google\Service\Sheets;
use Symfony\Component\Dotenv\Dotenv;

require __DIR__ . '/vendor/autoload.php';

if (!\file_exists(__DIR__ . '/auth.json')) {
print 'Example skipped. Please create .env file with Google Auth credentials.' . PHP_EOL;

return;
}

if (!\file_exists(__DIR__ . '/.env')) {
print 'Example skipped. Please create .env file with Google Sheet details.' . PHP_EOL;

return;
}

$dotenv = new Dotenv();
$dotenv->load(__DIR__ . '/.env');

$service = google_sheets(
\json_decode((string) \file_get_contents(__DIR__ . '/auth.json'), true, 512, JSON_THROW_ON_ERROR),
Sheets::SPREADSHEETS
);

$spreadsheet = google_create_spreadsheet(
$service,
$_ENV['GOOGLE_SPREADSHEET_NAME'],
$sheetName = $_ENV['GOOGLE_SHEET_EMAIL'],
$_ENV['GOOGLE_SHEET_EMAIL']
);

data_frame()
->read(from_array([
['id' => 1, 'text' => 'lorem ipsum'],
['id' => 2, 'text' => 'lorem ipsum'],
['id' => 3, 'text' => 'lorem ipsum'],
['id' => 4, 'text' => 'lorem ipsum'],
['id' => 5, 'text' => 'lorem ipsum'],
['id' => 6, 'text' => 'lorem ipsum'],
]))
->write(to_google_sheet($service, $spreadsheet->spreadsheetId, $sheetName))
->run();

data_frame()
->read(from_google_sheet($service, $spreadsheet->spreadsheetId, $sheetName))
->write(to_stream(__DIR__ . '/output.txt', truncate: false))
->run();
23 changes: 23 additions & 0 deletions examples/topics/data_reading/google_sheet/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "flow-php/examples",
"description": "Flow PHP - Examples",
"license": "MIT",
"type": "library",
"require": {
"flow-php/etl": "1.x-dev",
"flow-php/etl-adapter-google-sheet": "1.x-dev",
"symfony/dotenv": "^7.2"
},
"config": {
"allow-plugins": {
"php-http/discovery": false
}
},
"archive": {
"exclude": [
".env",
"auth.json.dist",
"vendor"
]
}
}
2,155 changes: 2,155 additions & 0 deletions examples/topics/data_reading/google_sheet/composer.lock

Large diffs are not rendered by default.

Binary file not shown.
36 changes: 36 additions & 0 deletions examples/topics/data_reading/google_sheet/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
+----+-------------+
| id | text |
+----+-------------+
| 1 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 2 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 3 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 4 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 5 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 6 | lorem ipsum |
+----+-------------+
1 rows
2 changes: 1 addition & 1 deletion examples/topics/data_writing/elasticsearch/code.php
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
require __DIR__ . '/vendor/autoload.php';

if (!\file_exists(__DIR__ . '/.env')) {
print 'Example skipped. Please create .env file with Azure Storage Account credentials.' . PHP_EOL;
print 'Example skipped. Please create .env file with Elasticsearch credentials.' . PHP_EOL;

return;
}
3 changes: 3 additions & 0 deletions examples/topics/data_writing/google_sheet/.env.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GOOGLE_SPREADSHEET_NAME='Flow test spreadsheet'
GOOGLE_SHEET_NAME='Flow test sheet'
GOOGLE_SHEET_EMAIL='random@gmail.com'
3 changes: 3 additions & 0 deletions examples/topics/data_writing/google_sheet/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.env
auth.json
vendor
11 changes: 11 additions & 0 deletions examples/topics/data_writing/google_sheet/auth.json.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"type": "service_account",
"project_id": "flow-test",
"client_email": "flow-test@flow-test.iam.gserviceaccount.com",
"client_id": "1234567890",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/flow-test%flow-test.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
54 changes: 54 additions & 0 deletions examples/topics/data_writing/google_sheet/code.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

use function Flow\ETL\Adapter\GoogleSheet\{from_google_sheet, google_create_spreadsheet, google_sheets, to_google_sheet};
use function Flow\ETL\DSL\{data_frame, from_array, to_stream};
use Google\Service\Sheets;
use Symfony\Component\Dotenv\Dotenv;

require __DIR__ . '/vendor/autoload.php';

if (!\file_exists(__DIR__ . '/auth.json')) {
print 'Example skipped. Please create .env file with Google Auth credentials.' . PHP_EOL;

return;
}

if (!\file_exists(__DIR__ . '/.env')) {
print 'Example skipped. Please create .env file with Google Sheet details.' . PHP_EOL;

return;
}

$dotenv = new Dotenv();
$dotenv->load(__DIR__ . '/.env');

$service = google_sheets(
\json_decode((string) \file_get_contents(__DIR__ . '/auth.json'), true, 512, JSON_THROW_ON_ERROR),
Sheets::SPREADSHEETS
);

$spreadsheet = google_create_spreadsheet(
$service,
$_ENV['GOOGLE_SPREADSHEET_NAME'],
$sheetName = $_ENV['GOOGLE_SHEET_EMAIL'],
$_ENV['GOOGLE_SHEET_EMAIL']
);

data_frame()
->read(from_array([
['id' => 1, 'text' => 'lorem ipsum'],
['id' => 2, 'text' => 'lorem ipsum'],
['id' => 3, 'text' => 'lorem ipsum'],
['id' => 4, 'text' => 'lorem ipsum'],
['id' => 5, 'text' => 'lorem ipsum'],
['id' => 6, 'text' => 'lorem ipsum'],
]))
->write(to_google_sheet($service, $spreadsheet->spreadsheetId, $sheetName))
->run();

data_frame()
->read(from_google_sheet($service, $spreadsheet->spreadsheetId, $sheetName))
->write(to_stream(__DIR__ . '/output.txt', truncate: false))
->run();
23 changes: 23 additions & 0 deletions examples/topics/data_writing/google_sheet/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "flow-php/examples",
"description": "Flow PHP - Examples",
"license": "MIT",
"type": "library",
"require": {
"flow-php/etl": "1.x-dev",
"flow-php/etl-adapter-google-sheet": "1.x-dev",
"symfony/dotenv": "^7.2"
},
"config": {
"allow-plugins": {
"php-http/discovery": false
}
},
"archive": {
"exclude": [
".env",
"auth.json.dist",
"vendor"
]
}
}
2,229 changes: 2,229 additions & 0 deletions examples/topics/data_writing/google_sheet/composer.lock

Large diffs are not rendered by default.

Binary file not shown.
36 changes: 36 additions & 0 deletions examples/topics/data_writing/google_sheet/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
+----+-------------+
| id | text |
+----+-------------+
| 1 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 2 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 3 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 4 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 5 | lorem ipsum |
+----+-------------+
1 rows
+----+-------------+
| id | text |
+----+-------------+
| 6 | lorem ipsum |
+----+-------------+
1 rows
3 changes: 2 additions & 1 deletion src/adapter/etl-adapter-google-sheet/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
vendor
*.cache
var
var
auth.json.dist
3 changes: 2 additions & 1 deletion src/adapter/etl-adapter-google-sheet/composer.json
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@
]
},
"files": [
"src/Flow/ETL/Adapter/GoogleSheet/functions.php"
"src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php"
]
},
"autoload-dev": {
@@ -38,6 +38,7 @@
},
"extra": {
"google/apiclient-services": [
"Drive",
"Sheets"
]
},
Original file line number Diff line number Diff line change
@@ -6,7 +6,9 @@

use Flow\ETL\Attribute\{DocumentationDSL, Module, Type};
use Google\Client;
use Google\Service\Sheets;
use Google\Service\Drive\Permission;
use Google\Service\{Drive, Sheets};
use Google\Service\Sheets\Spreadsheet;

/**
* @param array{type: string, project_id: string, private_key_id: string, private_key: string, client_email: string, client_id: string, auth_uri: string, token_uri: string, auth_provider_x509_cert_url: string, client_x509_cert_url: string}|Sheets $auth_config
@@ -28,10 +30,7 @@
if ($auth_config instanceof Sheets) {
$sheets = $auth_config;
} else {
$client = new Client();
$client->setScopes(Sheets::SPREADSHEETS_READONLY);
$client->setAuthConfig($auth_config);
$sheets = new Sheets($client);
$sheets = google_sheets($auth_config, Sheets::SPREADSHEETS_READONLY);

Check warning on line 33 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L33

Added line #L33 was not covered by tests
}

return (new GoogleSheetExtractor(
@@ -67,10 +66,7 @@
if ($auth_config instanceof Sheets) {
$sheets = $auth_config;
} else {
$client = new Client();
$client->setScopes(Sheets::SPREADSHEETS_READONLY);
$client->setAuthConfig($auth_config);
$sheets = new Sheets($client);
$sheets = google_sheets($auth_config, Sheets::SPREADSHEETS_READONLY);

Check warning on line 69 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L69

Added line #L69 was not covered by tests
}

return (new GoogleSheetExtractor(
@@ -81,3 +77,89 @@
->withRowsPerPage($rows_per_page)
->withOptions($options);
}

#[DocumentationDSL(module: Module::GOOGLE_SHEET, type: Type::LOADER)]
function to_google_sheet(
array|Sheets $auth_config,
string $spreadsheet_id,
string $sheet_name,
bool $with_header = true,
ValueInputOption $value_input_option = ValueInputOption::USER_ENTERED,
) : GoogleSheetLoader {
if ($auth_config instanceof Sheets) {
$sheets = $auth_config;
} else {
$sheets = google_sheets($auth_config, Sheets::SPREADSHEETS);

Check warning on line 92 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L92

Added line #L92 was not covered by tests
}

return (new GoogleSheetLoader($sheets, $spreadsheet_id, $sheet_name))
->withHeader($with_header)
->withInputOption($value_input_option);
}

/**
* @param array<mixed>|string $auth_config
* @param array<string>|string $scopes
*/
#[DocumentationDSL(module: Module::GOOGLE_SHEET, type: Type::HELPER)]
function google_client(string|array $auth_config, string|array $scopes) : Client
{
$client = new Client();
$client->setScopes($scopes);
$client->setAuthConfig($auth_config);

Check warning on line 109 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L107-L109

Added lines #L107 - L109 were not covered by tests

return $client;

Check warning on line 111 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L111

Added line #L111 was not covered by tests
}

#[DocumentationDSL(module: Module::GOOGLE_SHEET, type: Type::HELPER)]
function google_sheets(string|array $auth_config, string|array $scopes) : Sheets
{
return new Sheets(google_client($auth_config, $scopes));

Check warning on line 117 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L117

Added line #L117 was not covered by tests
}

#[DocumentationDSL(module: Module::GOOGLE_SHEET, type: Type::HELPER)]
function google_create_spreadsheet(
array|Sheets $auth_config,
string $spreadsheet_name,
string $sheet_name,
string $shareWithEmail,
) : Spreadsheet {
if ($auth_config instanceof Sheets) {
$sheets = $auth_config;

Check warning on line 128 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L127-L128

Added lines #L127 - L128 were not covered by tests
} else {
$sheets = google_sheets($auth_config, Sheets::SPREADSHEETS);

Check warning on line 130 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L130

Added line #L130 was not covered by tests
}

$spreadsheet = $sheets->spreadsheets->create(
new Spreadsheet([
'properties' => [
'title' => $spreadsheet_name,
],
'sheets' => [
[
'properties' => [
'title' => $sheet_name,
],
],
],
])
);

Check warning on line 146 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L133-L146

Added lines #L133 - L146 were not covered by tests

// Ensure required drive scope is applied
$sheets->getClient()->addScope(Drive::DRIVE);

Check warning on line 149 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L149

Added line #L149 was not covered by tests

$drive = new Drive($sheets->getClient());
$drive->permissions->create(
$spreadsheet->spreadsheetId,
new Permission(
[
'type' => 'user',
'role' => 'reader',
'emailAddress' => $shareWithEmail,
]
),
['fields' => 'id']
);

Check warning on line 162 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L151-L162

Added lines #L151 - L162 were not covered by tests

return $spreadsheet;

Check warning on line 164 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/DSL/functions.php#L164

Added line #L164 was not covered by tests
}
Original file line number Diff line number Diff line change
@@ -38,7 +38,6 @@ public function extract(FlowContext $context) : \Generator
$cellsRange = new SheetRange($this->columnRange, 1, $this->rowsPerPage);
$headers = [];

/** @var Sheets\ValueRange $response */
$response = $this->service->spreadsheets_values->get(
$this->spreadsheetId,
$cellsRange->toString(),
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet;

use Flow\ETL\{Adapter\GoogleSheet\RowsNormalizer\EntryNormalizer,
Adapter\GoogleSheet\Spreadsheet\SpreadsheetManager,
FlowContext,
Loader,
Row\Entry,
Rows};
use Flow\ETL\Loader\Closure;
use Google\Service\Sheets;
use Google\Service\Sheets\{ValueRange};

final class GoogleSheetLoader implements Closure, Loader
{
private string $dateFormat = 'Y-m-d';

private string $dateTimeFormat = 'Y-m-d H:i:s';

private ValueInputOption $inputOption = ValueInputOption::USER_ENTERED;

private int $loadedRows = 0;

private int $sheetDynamicAllocationSize = 1_000;

private bool $withHeader = true;

public function __construct(
private readonly Sheets $service,
private readonly string $spreadsheetId,
private readonly string $sheetName,
) {
}

public function closure(FlowContext $context) : void

Check warning on line 38 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php#L38

Added line #L38 was not covered by tests
{
$this->loadedRows = 0;

Check warning on line 40 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php#L40

Added line #L40 was not covered by tests
}

public function load(Rows $rows, FlowContext $context) : void
{
if (!$rows->count()) {
return;

Check warning on line 46 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php#L46

Added line #L46 was not covered by tests
}

$manager = new SpreadsheetManager($this->service, $this->spreadsheetId);
$spreadsheetProperties = $manager->getSpreadsheetProperties($this->sheetName);

$columnsCount = $rows->first()->entries()->count();
$currentRow = $this->calculateCurrentRow($rows);
$sheetRange = $this->calculateSheetAddress($columnsCount, $currentRow);

// Calculate if spreadsheet size fits current load
if ($rows->count() >= $spreadsheetProperties->rowCount) {
$manager->increaseSheetSize($currentRow, $columnsCount, $spreadsheetProperties->sheetId, $this->sheetDynamicAllocationSize);

Check warning on line 58 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php#L58

Added line #L58 was not covered by tests
}

$normalizer = new RowsNormalizer(
new EntryNormalizer($this->dateTimeFormat, $this->dateFormat)
);

$this->service->spreadsheets_values->update(
$this->spreadsheetId,
$sheetRange->toString(),
new ValueRange(
[
'values' => $this->values($rows, $normalizer),
'majorDimension' => 'ROWS',
]
),
$this->inputOption->toArray()
);

$this->loadedRows = $currentRow;
}

public function withDateFormat(string $dateFormat) : self
{
$this->dateFormat = $dateFormat;

return $this;
}

public function withDateTimeFormat(string $dateTimeFormat) : self
{
$this->dateTimeFormat = $dateTimeFormat;

return $this;
}

public function withHeader(bool $withHeader) : self
{
$this->withHeader = $withHeader;

return $this;
}

public function withInputOption(ValueInputOption $inputOption) : self
{
$this->inputOption = $inputOption;

return $this;
}

public function withSheetDynamicAllocationSize(int $bySize) : self

Check warning on line 108 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php#L108

Added line #L108 was not covered by tests
{
$this->sheetDynamicAllocationSize = $bySize;

Check warning on line 110 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php#L110

Added line #L110 was not covered by tests

return $this;

Check warning on line 112 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetLoader.php#L112

Added line #L112 was not covered by tests
}

private function calculateCurrentRow(Rows $rows) : int
{
return $rows->count() + (0 === $this->loadedRows ? ($this->withHeader ? 1 : 0) : $this->loadedRows);
}

private function calculateSheetAddress(int $columnsCount, int $currentRow) : SheetAddress
{
return SheetAddress::calculate(
$columnsCount,
$currentRow,
new SheetAddress(
startCell: 0 === $this->loadedRows ? 'A1' : 'A' . $this->loadedRows + 1,
sheetName: $this->sheetName
)
);
}

private function values(Rows $rows, RowsNormalizer $normalizer) : array
{
$values = [];

if ($this->withHeader && 0 === $this->loadedRows) {
$values[] = $rows->first()->entries()->map(fn (Entry $entry) => $entry->name());
}

foreach ($normalizer->normalize($rows) as $normalizedRow) {
$values[] = $normalizedRow;
}

return $values;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet;

use Flow\ETL\Adapter\GoogleSheet\RowsNormalizer\EntryNormalizer;
use Flow\ETL\Rows;

final readonly class RowsNormalizer
{
public function __construct(private EntryNormalizer $entryNormalizer)
{
}

/**
* @return \Generator<array<null|bool|float|int|string>>
*/
public function normalize(Rows $rows) : \Generator
{
foreach ($rows as $row) {
$normalizedRow = [];

foreach ($row->entries() as $entry) {
$normalizedRow[] = $this->entryNormalizer->normalize($entry);
}

yield $normalizedRow;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\RowsNormalizer;

use function Flow\Types\DSL\type_json;
use Flow\ETL\Row\Entry;
use Flow\ETL\Row\Entry\{DateEntry, DateTimeEntry, EnumEntry, JsonEntry, ListEntry, MapEntry, StructureEntry, TimeEntry, UuidEntry, XMLElementEntry, XMLEntry};

final readonly class EntryNormalizer
{
public function __construct(
private string $dateTimeFormat,
private string $dateFormat,
) {
}

/**
* @param Entry<mixed, mixed> $entry
*/
public function normalize(Entry $entry) : string|float|int|bool|null
{
return match ($entry::class) {
UuidEntry::class,
XMLElementEntry::class,
XMLEntry::class => $entry->toString(),
DateTimeEntry::class => $entry->value() === null ? '' : $entry->value()->format($this->dateTimeFormat),
DateEntry::class => $entry->value() === null ? '' : $entry->value()->format($this->dateFormat),
TimeEntry::class => $entry->toString(),
EnumEntry::class => $entry->value()?->name,
ListEntry::class,
MapEntry::class,
StructureEntry::class,
JsonEntry::class => type_json()->cast($entry->value()),
default => $entry->value() ?? '',
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet;

final readonly class SheetAddress
{
private ?string $endCell;

private string $startCell;

public function __construct(
string $startCell = 'A1',
?string $endCell = null,
private ?string $sheetName = null,
) {
$this->startCell = \strtoupper($startCell);
$this->endCell = $endCell ? \strtoupper($endCell) : null;
}

public static function calculate(int $columns, int $rows, ?self $startAddress = null) : self
{
$startAddress ??= new self('A1');
$startColumn = $startAddress->getStartCellColumn();
$startRow = $startAddress->getStartCellRow();

$startColumnNumber = 0;

for ($i = 0, $length = \strlen($startColumn); $i < $length; $i++) {
$startColumnNumber = $startColumnNumber * 26 + (ord($startColumn[$i]) - 64);
}

$endColumnNumber = $startColumnNumber + $columns - 1;
$endColumn = '';

while ($endColumnNumber > 0) {
$remainder = ($endColumnNumber - 1) % 26;
$endColumn = chr(65 + $remainder) . $endColumn;
$endColumnNumber = floor(($endColumnNumber - $remainder) / 26);
}

return new self(
$startColumn . $startRow,
$endColumn . ($startRow + $rows - 1),
$startAddress->getSheetName()
);
}

public function getEndCell() : ?string
{
return $this->endCell;
}

public function getEndCellColumn() : string
{
return (string) preg_replace('/[0-9]/', '', (string) $this->endCell);
}

public function getEndCellRow() : int
{
return (int) preg_replace('/[A-Z]/', '', (string) $this->endCell);
}

public function getSheetName() : ?string
{
return $this->sheetName;
}

public function getStartCell() : string
{
return $this->startCell;
}

public function getStartCellColumn() : string
{
return (string) preg_replace('/[0-9]/', '', $this->startCell);
}

public function getStartCellRow() : int
{
return (int) preg_replace('/[A-Z]/', '', $this->startCell);
}

public function toString() : string
{
return ($this->sheetName ? ("'" . $this->sheetName . "'!") : '') . $this->startCell . ($this->endCell ? ':' . $this->endCell : '');
}
}
Original file line number Diff line number Diff line change
@@ -42,7 +42,7 @@ public function nextRows(int $count) : self
public function toString() : string
{
return \sprintf(
'%s!%s%d:%s%d',
"'%s'!%s%d:%s%d",
$this->columnRange->sheetName,
$this->columnRange->startColumn,
$this->startRow,
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Spreadsheet;

use Flow\ETL\Exception\InvalidArgumentException;
use Google\Service\Sheets;
use Google\Service\Sheets\{BatchUpdateSpreadsheetRequest, Spreadsheet};

final class SpreadsheetManager
{
private ?Spreadsheet $spreadsheet = null;

public function __construct(
private readonly Sheets $service,
private readonly string $spreadsheetId,
) {
}

public function getSpreadsheetProperties(string $sheetName) : SpreadsheetProperties
{
$rowCount = 0;
$sheetId = null;

foreach ($this->spreadsheet()->getSheets() as $sheet) {
if ($sheetName === $sheet->getProperties()->getTitle()) {
$sheetId = $sheet->getProperties()->getSheetId();
$gridProperties = $sheet->getProperties()->getGridProperties();
$rowCount += $gridProperties->getRowCount();

break;
}
}

if (null === $sheetId) {
throw new InvalidArgumentException("Sheet '{$sheetName}' not found in spreadsheet '{$this->spreadsheetId}'");
}

return new SpreadsheetProperties($sheetId, $rowCount);
}

public function increaseSheetSize(int $currentRow, int $columnsCount, int $sheetId, int $sheetDynamicAllocationSize) : void

Check warning on line 43 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/Spreadsheet/SpreadsheetManager.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/Spreadsheet/SpreadsheetManager.php#L43

Added line #L43 was not covered by tests
{
$this->service->spreadsheets->batchUpdate(
$this->spreadsheet()->spreadsheetId,
new BatchUpdateSpreadsheetRequest(
[
'requests' => [
[
'updateSheetProperties' => [
'properties' => [
'sheetId' => $sheetId,
'gridProperties' => [
'rowCount' => $currentRow + $sheetDynamicAllocationSize,

Check warning on line 55 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/Spreadsheet/SpreadsheetManager.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/Spreadsheet/SpreadsheetManager.php#L45-L55

Added lines #L45 - L55 were not covered by tests
// Ensure we update the column size as well
'columnCount' => $columnsCount,
],
],
'fields' => 'gridProperties(rowCount,columnCount)',
],
],
],
]
)
);

Check warning on line 66 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/Spreadsheet/SpreadsheetManager.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/Spreadsheet/SpreadsheetManager.php#L57-L66

Added lines #L57 - L66 were not covered by tests
}

private function spreadsheet() : Spreadsheet
{
if ($this->spreadsheet !== null) {
return $this->spreadsheet;

Check warning on line 72 in src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/Spreadsheet/SpreadsheetManager.php

Codecov / codecov/patch

src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/Spreadsheet/SpreadsheetManager.php#L72

Added line #L72 was not covered by tests
}

return $this->spreadsheet = $this->service->spreadsheets->get($this->spreadsheetId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Spreadsheet;

final class SpreadsheetProperties
{
public function __construct(
public int $sheetId,
public int $rowCount,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet;

enum ValueInputOption : string
{
case RAW = 'RAW';
case USER_ENTERED = 'USER_ENTERED';

public function toArray() : array
{
return ['valueInputOption' => $this->value];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"spreadsheetId": "1234567890",
"properties": {
"title": "Test Spreadsheet",
"locale": "en_US",
"timeZone": "America/Los_Angeles"
},
"sheets": [
{
"properties": {
"sheetId": 0,
"title": "Sheet",
"index": 0,
"sheetType": "GRID",
"gridProperties": {
"rowCount": 1000,
"columnCount": 26
}
}
}
],
"spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1234567890/edit"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"spreadsheetId": "1234567890",
"updatedRange": "Sheet!A1:D4",
"updatedRows": 4,
"updatedColumns": 2,
"updatedCells": 8,
"updatedData": {
"range": "Sheet!A1:C4",
"majorDimension": "ROWS",
"values": [
["id", "name"],
[12345, "Norbert"],
[54321, "Joseph"],
[666, "Dominik"]
]
}
}
Original file line number Diff line number Diff line change
@@ -6,42 +6,20 @@

use Google\Client as GoogleClient;
use Google\Service\Sheets;
use GuzzleHttp\{Client as HttpClient, HandlerStack};
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;

final readonly class GoogleSheetsContext
{
private GoogleClient $client;

public function __construct()
public function __construct(private HttpClientContext $httpClientContext = new HttpClientContext())
{
$this->client = new GoogleClient();
}

public function sheets(string $fixtureFile) : Sheets
public function sheets() : Sheets
{
$this->client->setHttpClient($this->createHttpClient($fixtureFile));
$this->client->setHttpClient($this->httpClientContext->createHttpClient());

return new Sheets($this->client);
}

private function createHttpClient(string $fixtureFile) : HttpClient
{
return new HttpClient(
[
'handler' => HandlerStack::create(
new MockHandler(
[
new Response(
200,
['Content-Type' => 'application/json'],
file_get_contents($fixtureFile) ?: throw new \RuntimeException('Failed to read file: ' . $fixtureFile)
),
]
)
),
]
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Tests;

use GuzzleHttp\{Client as HttpClient, Handler\MockHandler, Psr7\Request};
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Assert;

final class HttpClientContext
{
public function __construct(private array $queue = [])
{
}

public function add(
HttpRequestContext $requestContext,
string $fixtureFile,
) : self {
$this->queue[] = function (Request $request) use ($requestContext, $fixtureFile) : Response {
Assert::assertSame($requestContext->method, $request->getMethod());
Assert::assertSame($requestContext->url, (string) $request->getUri());

if (null !== $requestContext->body) {
Assert::assertSame($requestContext->body, (string) $request->getBody());
}

return new Response(
headers: ['Content-Type' => 'application/json'],
body: file_get_contents($fixtureFile) ?: throw new \RuntimeException('Failed to read file: ' . $fixtureFile)
);
};

return $this;
}

public function createHttpClient() : HttpClient
{
return new HttpClient(
[
'handler' => MockHandler::createWithMiddleware($this->queue),
]
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Tests;

final class HttpRequestContext
{
public function __construct(
public string $method,
public string $url,
public ?string $body = null,
) {
}
}
Original file line number Diff line number Diff line change
@@ -4,8 +4,12 @@

namespace Flow\ETL\Adapter\GoogleSheet\Tests\Integration;

use function Flow\ETL\Adapter\GoogleSheet\from_google_sheet;
use function Flow\ETL\DSL\{config, flow_context};
use Flow\ETL\Adapter\GoogleSheet\{Columns, GoogleSheetExtractor, Tests\GoogleSheetsContext};
use Flow\ETL\Adapter\GoogleSheet\{
Tests\GoogleSheetsContext,
Tests\HttpClientContext,
Tests\HttpRequestContext};
use Flow\ETL\Exception\InvalidArgumentException;
use Flow\ETL\Tests\FlowTestCase;

@@ -15,15 +19,24 @@ final class GoogleSheetExtractorTest extends FlowTestCase

protected function setUp() : void
{
$this->context = new GoogleSheetsContext();
$this->context = new GoogleSheetsContext(
(new HttpClientContext())
->add(
new HttpRequestContext(
'GET',
'https://sheets.googleapis.com/v4/spreadsheets/1234567890/values/%27Sheet%27%21A1%3AZ1000',
),
__DIR__ . '/../Fixtures/extra-columns.json'
)
);
}

public function test_extract_with_cut_extra_columns() : void
{
$extractor = new GoogleSheetExtractor(
$this->context->sheets(__DIR__ . '/../Fixtures/extra-columns.json'),
$extractor = from_google_sheet(
$this->context->sheets(),
'1234567890',
new Columns('Sheet', 'A', 'Z'),
'Sheet',
);

$rows = $extractor->extract(flow_context(config()));
@@ -35,10 +48,10 @@ public function test_extract_with_cut_extra_columns() : void

public function test_extract_without_cut_extra_columns() : void
{
$extractor = new GoogleSheetExtractor(
$this->context->sheets(__DIR__ . '/../Fixtures/extra-columns.json'),
$extractor = from_google_sheet(
$this->context->sheets(),
'1234567890',
new Columns('Sheet', 'A', 'Z'),
'Sheet',
);
$extractor->withDropExtraColumns(false);

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Tests\Integration;

use function Flow\ETL\Adapter\GoogleSheet\to_google_sheet;
use function Flow\ETL\DSL\{config, flow_context, int_entry, row, rows, string_entry};
use Flow\ETL\Adapter\GoogleSheet\{
Tests\GoogleSheetsContext,
Tests\HttpClientContext,
Tests\HttpRequestContext};
use Flow\ETL\Tests\FlowTestCase;

final class GoogleSheetLoaderTest extends FlowTestCase
{
public function test_load() : void
{
$httpContext = (new HttpClientContext())
->add(
new HttpRequestContext(
'GET',
'https://sheets.googleapis.com/v4/spreadsheets/1234567890',
),
__DIR__ . '/../Fixtures/get-spreadsheet-response.json'
)
->add(
new HttpRequestContext(
'PUT',
'https://sheets.googleapis.com/v4/spreadsheets/1234567890/values/%27Sheet%27%21A1%3AB4?valueInputOption=USER_ENTERED',
'{"majorDimension":"ROWS","values":[["id","name"],[12345,"Norbert"],[54321,"Joseph"],[666,"Dominik"]]}',
),
__DIR__ . '/../Fixtures/update-spreadsheet-response.json'
);

$loader = to_google_sheet(
(new GoogleSheetsContext($httpContext))
->sheets(),
'1234567890',
'Sheet',
);

$loader->load(
rows(
row(int_entry('id', 12345), string_entry('name', 'Norbert')),
row(int_entry('id', 54321), string_entry('name', 'Joseph')),
row(int_entry('id', 666), string_entry('name', 'Dominik'))
),
flow_context(config())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Tests\Integration;

use function Flow\ETL\Adapter\GoogleSheet\{from_google_sheet,
google_create_spreadsheet,
google_sheets,
to_google_sheet};
use function Flow\ETL\DSL\{config, flow_context, int_entry, row, rows, str_entry, string_entry};
use Flow\ETL\Tests\FlowTestCase;
use Google\Service\Sheets;
use Google\Service\Sheets\ClearValuesRequest;

final class GoogleSheetTest extends FlowTestCase
{
private const SHEET_NAME = 'Flow Sheet';

private Sheets $service;

private string $spreadsheetId;

protected function setUp() : void
{
parent::setUp();

$authFilename = __DIR__ . '/../../../../../../../auth.json.dist';

if (!file_exists($authFilename)) {
self::markTestSkipped('auth.json.dist file is missing');
}

try {
$this->service = google_sheets(
json_decode((string) file_get_contents($authFilename), true, 512, JSON_THROW_ON_ERROR),
Sheets::SPREADSHEETS
);
} catch (\JsonException) {
self::markTestSkipped('auth.json.dist file contains not valid JSON');
}

$spreadsheet = google_create_spreadsheet(
$this->service,
'Flow test spreadsheet',
self::SHEET_NAME,
'random@gmail.com'
);

$this->spreadsheetId = $spreadsheet->spreadsheetId;

$this->clearTestSheet();
}

protected function tearDown() : void
{
parent::tearDown();

$this->clearTestSheet();
}

public function test_load_and_extract() : void
{
$loader = to_google_sheet(
$this->service,
$this->spreadsheetId,
self::SHEET_NAME,
);

$loader->load(
rows(
row(int_entry('id', 12345), string_entry('name', 'Norbert')),
row(int_entry('id', 54321), string_entry('name', 'Joseph')),
),
$context = flow_context(config())
);

// Ensure previous rows are not overwritten
$loader->load(
rows(
row(int_entry('id', 666), string_entry('name', 'Dominik'))
),
$context
);

$extractor = from_google_sheet(
$this->service,
$this->spreadsheetId,
self::SHEET_NAME,
);

$rowsArray = \iterator_to_array($extractor->extract(flow_context(config())));
self::assertCount(3, $rowsArray);
self::assertSame(1, $rowsArray[0]->count());
self::assertEquals(row(string_entry('id', '12345'), string_entry('name', 'Norbert')), $rowsArray[0]->first());
self::assertSame(1, $rowsArray[1]->count());
self::assertEquals(row(str_entry('id', '54321'), string_entry('name', 'Joseph')), $rowsArray[1]->first());
self::assertSame(1, $rowsArray[2]->count());
self::assertEquals(row(str_entry('id', '666'), string_entry('name', 'Dominik')), $rowsArray[2]->first());
}

private function clearTestSheet() : void
{
$this->service->spreadsheets_values->clear(
$this->spreadsheetId,
"'" . self::SHEET_NAME . "'!A1:ZZ",
new ClearValuesRequest(),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Tests\Unit;

use function Flow\ETL\DSL\{config,
date_entry,
datetime_entry,
flow_context,
int_entry,
row,
rows,
string_entry,
time_entry};
use Flow\ETL\Adapter\GoogleSheet\{GoogleSheetLoader, ValueInputOption};
use Google\Service\Sheets;
use Google\Service\Sheets\{GridProperties, Sheet, SheetProperties, Spreadsheet, ValueRange};
use Google\Service\Sheets\Resource\{Spreadsheets, SpreadsheetsValues};
use PHPUnit\Framework\TestCase;

final class GoogleSheetLoaderTest extends TestCase
{
public function test_load_with_entity_normalizer_options() : void
{
$sheetsMock = $this->createSheetsStub(
[
['date', 'datetime', 'time'],
['03-05-2025', '2025-05-03 15:20', '01:00:00'],
],
"'Sheet'!A1:C2"
);
$loader = new GoogleSheetLoader(
$sheetsMock,
'1234567890',
'Sheet'
);
$loader->withDateTimeFormat('Y-m-d H:i');
$loader->withDateFormat('d-m-Y');
$loader->withInputOption(ValueInputOption::RAW);

$loader->load(
rows(
row(
date_entry('date', '2025-05-03 15:20:20'),
datetime_entry('datetime', '2025-05-03 15:20:20'),
time_entry('time', 'PT1H')
),
),
flow_context(config())
);
}

public function test_load_with_headers() : void
{
$sheetsMock = $this->createSheetsStub(
[
['id', 'name'],
[12345, 'Norbert'],
[54321, 'Joseph'],
],
"'Sheet'!A1:B3"
);
$loader = new GoogleSheetLoader(
$sheetsMock,
'1234567890',
'Sheet',
);
$loader->withInputOption(ValueInputOption::RAW);

$loader->load(
rows(
row(int_entry('id', 12345), string_entry('name', 'Norbert')),
row(int_entry('id', 54321), string_entry('name', 'Joseph'))
),
flow_context(config())
);
}

public function test_load_without_headers() : void
{
$sheetsMock = $this->createSheetsStub(
[
[12345, 'Norbert'],
[54321, 'Joseph'],
],
"'Sheet'!A1:B2"
);
$loader = new GoogleSheetLoader(
$sheetsMock,
'1234567890',
'Sheet'
);
$loader->withHeader(false);
$loader->withInputOption(ValueInputOption::RAW);

$loader->load(
rows(
row(int_entry('id', 12345), string_entry('name', 'Norbert')),
row(int_entry('id', 54321), string_entry('name', 'Joseph'))
),
flow_context(config())
);
}

private function createSheetsStub(array $values, string $cellsRange) : Sheets
{
$gridProperties = new GridProperties();
$gridProperties->rowCount = 1000;

$sheetProperties = new SheetProperties();
$sheetProperties->title = 'Sheet';
$sheetProperties->sheetId = 666;
$sheetProperties->setGridProperties($gridProperties);

$sheet = new Sheet();
$sheet->setProperties($sheetProperties);

$spreadsheet = new Spreadsheet();
$spreadsheet->setSpreadsheetId('1234567890');
$spreadsheet->setSheets([$sheet]);

$spreadsheetsMock = $this->createMock(Spreadsheets::class);
$spreadsheetsMock->expects(self::once())->method('get')->with('1234567890')->willReturn($spreadsheet);

$sheets = new Sheets();
$sheets->spreadsheets = $spreadsheetsMock;
$sheets->spreadsheets_values = $this->createMock(SpreadsheetsValues::class);
$sheets->spreadsheets_values->expects(self::once())->method('update')->with(
'1234567890',
$cellsRange,
new ValueRange(
[
'values' => $values,
'majorDimension' => 'ROWS',
],
),
['valueInputOption' => 'RAW']
)->willReturn(
[
'spreadsheetId' => '1234567890',
'updatedRange' => "'Sheet'!A1:B2",
'updatedRows' => 2,
'updatedColumns' => 2,
'updatedCells' => 4,
]
);

return $sheets;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Tests\Unit\RowsNormalizer;

use function Flow\ETL\DSL\{date_entry,
enum_entry,
json_entry,
list_entry,
time_entry,
type_list,
type_string,
uuid_entry};
use Flow\ETL\Adapter\GoogleSheet\RowsNormalizer\EntryNormalizer;
use Flow\ETL\Adapter\GoogleSheet\ValueInputOption;
use Flow\ETL\Row\Entry;
use Flow\ETL\Row\Entry\{DateTimeEntry};
use PHPUnit\Framework\TestCase;

final class EntryNormalizerTest extends TestCase
{
private EntryNormalizer $normalizer;

protected function setUp() : void
{
$this->normalizer = new EntryNormalizer(
'Y-m-d H:i:s',
'Y-m-d',
);
}

public function test_normalize_date_entry() : void
{
$date = new \DateTimeImmutable('2024-03-15');
$entry = date_entry('entry', $date);

$result = $this->normalizer->normalize($entry);

self::assertEquals('2024-03-15', $result);
}

public function test_normalize_datetime_entry() : void
{
$dateTime = new \DateTimeImmutable('2024-03-15 14:30:00');
$entry = new DateTimeEntry('entry', $dateTime);

$result = $this->normalizer->normalize($entry);

self::assertEquals($dateTime->format('Y-m-d H:i:s'), $result);
}

public function test_normalize_default_entry() : void
{
$value = 'simple string';
$entry = $this->createMock(Entry::class);
$entry->method('value')->willReturn($value);

$result = $this->normalizer->normalize($entry);

self::assertEquals($value, $result);
}

public function test_normalize_enum_entry() : void
{
$enum = ValueInputOption::RAW;
$entry = enum_entry('entry', $enum);

$result = $this->normalizer->normalize($entry);

self::assertEquals('RAW', $result);
}

public function test_normalize_json_entry() : void
{
$data = ['key' => 'value'];
$entry = json_entry('entry', $data);

$result = $this->normalizer->normalize($entry);

self::assertEquals(json_encode($data), $result);
}

public function test_normalize_list_entry() : void
{
$list = ['item1', 'item2'];
$entry = list_entry('entry', $list, type_list(type_string()));

$result = $this->normalizer->normalize($entry);

self::assertEquals(json_encode($list), $result);
}

public function test_normalize_null_value() : void
{
$entry = new DateTimeEntry('entry', null);

$result = $this->normalizer->normalize($entry);

self::assertSame('', $result);
}

public function test_normalize_time_entry() : void
{
$time = new \DateInterval('PT1H');
$entry = time_entry('entry', $time);

$result = $this->normalizer->normalize($entry);

self::assertEquals('01:00:00', $result);
}

public function test_normalize_uuid_entry() : void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$entry = uuid_entry('entry', $uuid);

$result = $this->normalizer->normalize($entry);

self::assertEquals($uuid, $result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Tests\Unit;

use Flow\ETL\Adapter\GoogleSheet\SheetAddress;
use PHPUnit\Framework\TestCase;

final class SheetAddressTest extends TestCase
{
public function test_calculating() : void
{
$range = SheetAddress::calculate(5, 10);

self::assertEquals('A1', $range->getStartCell());
self::assertEquals('E10', $range->getEndCell());
}

public function test_calculating_the_from_a_range() : void
{
$range = SheetAddress::calculate(2, 10, new SheetAddress('A1', 'B5', 'Sheet1'));

self::assertEquals('A1', $range->getStartCell());
self::assertEquals('B10', $range->getEndCell());
self::assertEquals('A', $range->getStartCellColumn());
self::assertEquals(1, $range->getStartCellRow());
self::assertEquals('B', $range->getEndCellColumn());
self::assertEquals(10, $range->getEndCellRow());
self::assertEquals('Sheet1', $range->getSheetName());
}

public function test_with_multi_words_sheet_name() : void
{
$range = SheetAddress::calculate(2, 10, new SheetAddress('A1', 'B5', 'Sheet 1'));

self::assertEquals('Sheet 1', $range->getSheetName());
self::assertEquals("'Sheet 1'!A1:B10", $range->toString());
}
}
Original file line number Diff line number Diff line change
@@ -15,19 +15,19 @@ public static function example_string_ranges() : \Generator
{
yield 'one cell' => [
new SheetRange(new Columns('Sheet2', 'B', 'B'), 2, 2),
'Sheet2!B2:B2',
"'Sheet2'!B2:B2",
];
yield 'one line range' => [
new SheetRange(new Columns('Sheet1', 'A', 'C'), 1, 1),
'Sheet1!A1:C1',
"'Sheet1'!A1:C1",
];
yield 'multiple line range' => [
new SheetRange(new Columns('Sheet1', 'B', 'D'), 2, 30),
'Sheet1!B2:D30',
"'Sheet1'!B2:D30",
];
yield 'multi letter columns' => [
new SheetRange(new Columns('Sheet1', 'ABC', 'CBA'), 101, 999),
'Sheet1!ABC101:CBA999',
"'Sheet1'!ABC101:CBA999",
];
}

@@ -59,8 +59,8 @@ public function test_assertions(int $startRow, int $endRow, string $expectedExce
public function test_next_rows_range() : void
{
$range = new SheetRange(new Columns('Sheet2', 'A', 'B'), 1, 10);
self::assertSame('Sheet2!A11:B20', $range->nextRows(10)->toString());
self::assertSame('Sheet2!A21:B40', $range->nextRows(10)->nextRows(20)->toString());
self::assertSame("'Sheet2'!A11:B20", $range->nextRows(10)->toString());
self::assertSame("'Sheet2'!A21:B40", $range->nextRows(10)->nextRows(20)->toString());
}

#[DataProvider('example_string_ranges')]
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Flow\ETL\Adapter\GoogleSheet\Tests\Unit\Spreadsheet;

use Flow\ETL\Adapter\GoogleSheet\Spreadsheet\SpreadsheetManager;
use Flow\ETL\Exception\InvalidArgumentException;
use Flow\ETL\Tests\FlowTestCase;
use Google\Service\Sheets;
use Google\Service\Sheets\Resource\{Spreadsheets};
use Google\Service\Sheets\{Spreadsheet};

final class SpreadsheetManagerTest extends FlowTestCase
{
public function test_get_spreadsheet_properties_fails_without_sheet() : void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Sheet 'Sheet' not found in spreadsheet '1234567890'");

$manager = new SpreadsheetManager($this->createSheetsStub(), '1234567890');
$manager->getSpreadsheetProperties('Sheet');
}

private function createSheetsStub() : Sheets
{
$spreadsheet = new Spreadsheet();
$spreadsheet->setSpreadsheetId('1234567890');
$spreadsheet->setSheets([]);

$spreadsheetsMock = $this->createMock(Spreadsheets::class);
$spreadsheetsMock->expects(self::once())->method('get')->with('1234567890')->willReturn($spreadsheet);

$sheets = new Sheets();
$sheets->spreadsheets = $spreadsheetsMock;

return $sheets;
}
}
Original file line number Diff line number Diff line change
@@ -29,13 +29,14 @@ final class BatchSizeOptimization implements Optimization

/**
* We can't use DbalLoader::class here because that would create a circular dependency between ETL and Adapters.
* All adapters requires ETL, but ELT does not require a single adapter to be present.
* All adapters require ETL, but ELT does not require a single adapter to be present.
*
* @var array<class-string<Loader>>
*/
private array $supportedLoaders = [
'Flow\ETL\Adapter\Doctrine\DbalLoader',
'Flow\ETL\Adapter\Elasticsearch\ElasticsearchPHP\ElasticsearchLoader',
'Flow\ETL\Adapter\GoogleSheet\GoogleSheetLoader',
'Flow\ETL\Adapter\Meilisearch\MeilisearchPHP\MeilisearchLoader',
];

@@ -51,7 +52,7 @@ public function __construct(private readonly int $batchSize = 1000, ?array $supp

public function isFor(Loader|Transformer $element, Pipeline $pipeline) : bool
{
// Pipeline is already batching so we don't need to optimize it
// Pipeline is already batching, so we don't need to optimize it
if (\in_array($pipeline::class, $this->batchingPipelines, true)) {
return false;
}
2 changes: 1 addition & 1 deletion web/landing/resources/dsl.json

Large diffs are not rendered by default.