Skip to content

Commit c37fe3b

Browse files
committed
#12486 and #49 from alpha-beta-issues fixes
1 parent ec8c8c6 commit c37fe3b

File tree

8 files changed

+244
-125
lines changed

8 files changed

+244
-125
lines changed

airbyte-integrations/connectors/source-google-ads/source_google_ads/custom_query_stream.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def get_json_schema(self) -> Dict[str, Any]:
7272
# Represents protobuf message and could be anything, set custom
7373
# attribute "protobuf_message" to convert it to a string (or
7474
# array of strings) later.
75-
# https://developers.google.com/google-ads/api/reference/rpc/v8/GoogleAdsFieldDataTypeEnum.GoogleAdsFieldDataType?hl=en#message
75+
# https://developers.google.com/google-ads/api/reference/rpc/v9/GoogleAdsFieldDataTypeEnum.GoogleAdsFieldDataType?hl=en#message
7676
if node.is_repeated:
7777
output_type = ["array", "null"]
7878
else:
@@ -95,7 +95,8 @@ def get_json_schema(self) -> Dict[str, Any]:
9595
WHERE_EXPR = re.compile("where.*", flags=RE_FLAGS)
9696
# list of keywords that can come after WHERE clause,
9797
# according to https://developers.google.com/google-ads/api/docs/query/grammar
98-
KEYWORDS_EXPR = re.compile("(order by|limit|parameters)", flags=RE_FLAGS)
98+
# each whitespace matters!
99+
KEYWORDS_EXPR = re.compile("(order by| limit | parameters )", flags=RE_FLAGS)
99100

100101
@staticmethod
101102
def get_query_fields(query: str) -> List[str]:

airbyte-integrations/connectors/source-google-ads/source_google_ads/google_ads.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44

55

66
from enum import Enum
7-
from typing import Any, Iterator, List, Mapping
7+
from typing import Any, Iterator, List, Mapping, MutableMapping
88

99
import pendulum
1010
from google.ads.googleads.client import GoogleAdsClient
11-
from google.ads.googleads.v8.services.types.google_ads_service import GoogleAdsRow, SearchGoogleAdsResponse
11+
from google.ads.googleads.v9.services.types.google_ads_service import GoogleAdsRow, SearchGoogleAdsResponse
1212
from proto.marshal.collections import Repeated, RepeatedComposite
1313

14+
1415
REPORT_MAPPING = {
1516
"accounts": "customer",
17+
"service_accounts": "customer",
1618
"ad_group_ads": "ad_group_ad",
1719
"ad_group_ad_labels": "ad_group_ad_label",
1820
"ad_groups": "ad_group",
@@ -34,13 +36,11 @@
3436
class GoogleAds:
3537
DEFAULT_PAGE_SIZE = 1000
3638

37-
def __init__(self, credentials: Mapping[str, Any], customer_id: str):
39+
def __init__(self, credentials: MutableMapping[str, Any]):
3840
# `google-ads` library version `14.0.0` and higher requires an additional required parameter `use_proto_plus`.
3941
# More details can be found here: https://developers.google.com/google-ads/api/docs/client-libs/python/protobuf-messages
4042
credentials["use_proto_plus"] = True
41-
4243
self.client = GoogleAdsClient.load_from_dict(credentials)
43-
self.customer_ids = customer_id.split(",")
4444
self.ga_service = self.client.get_service("GoogleAdsService")
4545

4646
def send_request(self, query: str, customer_id: str) -> Iterator[SearchGoogleAdsResponse]:
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from dataclasses import dataclass
2+
from typing import Any, Iterable, Mapping, Union
3+
from pendulum import timezone
4+
from pendulum.tz.timezone import Timezone
5+
6+
7+
@dataclass
8+
class Customer:
9+
id: str
10+
time_zone: Union[timezone, str] = "local"
11+
is_manager_account: bool = False
12+
13+
@classmethod
14+
def from_accounts(cls, accounts: Iterable[Iterable[Mapping[str, Any]]]):
15+
data_objects = []
16+
for account_list in accounts:
17+
for account in account_list:
18+
time_zone_name = account.get("customer.time_zone")
19+
tz = Timezone(time_zone_name) if time_zone_name else "local"
20+
21+
data_objects.append(cls(
22+
id=str(account["customer.id"]),
23+
time_zone=tz,
24+
is_manager_account=bool(account.get("customer.manager"))
25+
))
26+
return data_objects
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"type": "object",
4+
"properties": {
5+
"customer.auto_tagging_enabled": {
6+
"type": ["null", "boolean"]
7+
},
8+
"customer.call_reporting_setting.call_conversion_action": {
9+
"type": ["null", "string"]
10+
},
11+
"customer.call_reporting_setting.call_conversion_reporting_enabled": {
12+
"type": ["null", "boolean"]
13+
},
14+
"customer.call_reporting_setting.call_reporting_enabled": {
15+
"type": ["null", "boolean"]
16+
},
17+
"customer.conversion_tracking_setting.conversion_tracking_id": {
18+
"type": ["null", "integer"]
19+
},
20+
"customer.conversion_tracking_setting.cross_account_conversion_tracking_id": {
21+
"type": ["null", "integer"]
22+
},
23+
"customer.currency_code": {
24+
"type": ["null", "string"]
25+
},
26+
"customer.descriptive_name": {
27+
"type": ["null", "string"]
28+
},
29+
"customer.final_url_suffix": {
30+
"type": ["null", "string"]
31+
},
32+
"customer.has_partners_badge": {
33+
"type": ["null", "boolean"]
34+
},
35+
"customer.id": {
36+
"type": ["null", "integer"]
37+
},
38+
"customer.manager": {
39+
"type": ["null", "boolean"]
40+
},
41+
"customer.optimization_score": {
42+
"type": ["null", "number"]
43+
},
44+
"customer.optimization_score_weight": {
45+
"type": ["null", "number"]
46+
},
47+
"customer.pay_per_conversion_eligibility_failure_reasons": {
48+
"type": ["null", "array"],
49+
"items": {
50+
"type": "string"
51+
}
52+
},
53+
"customer.remarketing_setting.google_global_site_tag": {
54+
"type": ["null", "string"]
55+
},
56+
"customer.resource_name": {
57+
"type": ["null", "string"]
58+
},
59+
"customer.test_account": {
60+
"type": ["null", "boolean"]
61+
},
62+
"customer.time_zone": {
63+
"type": ["null", "string"]
64+
},
65+
"customer.tracking_url_template": {
66+
"type": ["null", "string"]
67+
}
68+
}
69+
}

airbyte-integrations/connectors/source-google-ads/source_google_ads/source.py

Lines changed: 36 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,22 @@
44

55

66
import traceback
7-
from typing import Any, List, Mapping, Tuple, Union
7+
from typing import Any, Iterable, List, Mapping, MutableMapping, Tuple
88

99
from airbyte_cdk import AirbyteLogger
1010
from airbyte_cdk.models import SyncMode
1111
from airbyte_cdk.sources import AbstractSource
1212
from airbyte_cdk.sources.streams import Stream
1313
from google.ads.googleads.errors import GoogleAdsException
1414
from pendulum import parse, timezone, today
15-
from pendulum.tz.timezone import Timezone
1615

1716
from .custom_query_stream import CustomQuery
1817
from .google_ads import GoogleAds
18+
from .models import Customer
1919
from .streams import (
2020
AccountPerformanceReport,
2121
Accounts,
22+
ServiceAccounts,
2223
AdGroupAdLabels,
2324
AdGroupAdReport,
2425
AdGroupAds,
@@ -38,7 +39,7 @@
3839

3940
class SourceGoogleAds(AbstractSource):
4041
@staticmethod
41-
def get_credentials(config: Mapping[str, Any]) -> Mapping[str, Any]:
42+
def get_credentials(config: Mapping[str, Any]) -> MutableMapping[str, Any]:
4243
credentials = config["credentials"]
4344
# use_proto_plus is set to True, because setting to False returned wrong value types, which breakes the backward compatibility.
4445
# For more info read the related PR's description: https://github.com/airbytehq/airbyte/pull/9996
@@ -50,36 +51,25 @@ def get_credentials(config: Mapping[str, Any]) -> Mapping[str, Any]:
5051
return credentials
5152

5253
@staticmethod
53-
def get_incremental_stream_config(google_api: GoogleAds, config: Mapping[str, Any], tz: Union[timezone, str] = "local"):
54+
def get_incremental_stream_config(google_api: GoogleAds, config: Mapping[str, Any], customers: List[Customer]):
5455
true_end_date = None
5556
configured_end_date = config.get("end_date")
5657
if configured_end_date is not None:
5758
true_end_date = min(today(), parse(configured_end_date)).to_date_string()
5859
incremental_stream_config = dict(
5960
api=google_api,
61+
customers=customers,
6062
conversion_window_days=config["conversion_window_days"],
6163
start_date=config["start_date"],
62-
time_zone=tz,
6364
end_date=true_end_date,
6465
)
6566
return incremental_stream_config
6667

67-
def get_account_info(self, google_api: GoogleAds, config: Mapping[str, Any]) -> dict:
68-
incremental_stream_config = self.get_incremental_stream_config(google_api, config)
69-
accounts_streams = Accounts(**incremental_stream_config)
70-
for stream_slice in accounts_streams.stream_slices(sync_mode=SyncMode.full_refresh):
71-
return next(accounts_streams.read_records(sync_mode=SyncMode.full_refresh, stream_slice=stream_slice), {})
72-
73-
@staticmethod
74-
def get_time_zone(account: dict) -> Union[timezone, str]:
75-
time_zone_name = account.get("customer.time_zone")
76-
if time_zone_name:
77-
return Timezone(time_zone_name)
78-
return "local"
79-
80-
@staticmethod
81-
def is_manager_account(account: dict) -> bool:
82-
return bool(account.get("customer.manager"))
68+
def get_account_info(self, google_api: GoogleAds, config: Mapping[str, Any]) -> Iterable[Iterable[Mapping[str, Any]]]:
69+
dummy_customers = [Customer(id=_id) for _id in config["customer_id"].split(",")]
70+
accounts_stream = ServiceAccounts(google_api, customers=dummy_customers)
71+
for slice_ in accounts_stream.stream_slices():
72+
yield accounts_stream.read_records(sync_mode=SyncMode.full_refresh, stream_slice=slice_)
8373

8474
@staticmethod
8575
def is_metrics_in_custom_query(query: str) -> bool:
@@ -92,42 +82,42 @@ def is_metrics_in_custom_query(query: str) -> bool:
9282
def check_connection(self, logger: AirbyteLogger, config: Mapping[str, Any]) -> Tuple[bool, any]:
9383
try:
9484
logger.info("Checking the config")
95-
google_api = GoogleAds(credentials=self.get_credentials(config), customer_id=config["customer_id"])
96-
account_info = self.get_account_info(google_api, config)
97-
is_manager_account = self.is_manager_account(account_info)
85+
google_api = GoogleAds(credentials=self.get_credentials(config))
9886

87+
accounts = self.get_account_info(google_api, config)
88+
customers = Customer.from_accounts(accounts)
9989
# Check custom query request validity by sending metric request with non-existant time window
100-
for query in config.get("custom_queries", []):
101-
query = query.get("query")
102-
103-
if is_manager_account and self.is_metrics_in_custom_query(query):
104-
return False, f"Metrics are not available for manager account. Check fields in your custom query: {query}"
105-
if CustomQuery.cursor_field in query:
106-
return False, f"Custom query should not contain {CustomQuery.cursor_field}"
107-
108-
req_q = CustomQuery.insert_segments_date_expr(query, "1980-01-01", "1980-01-01")
109-
for customer_id in google_api.customer_ids:
110-
google_api.send_request(req_q, customer_id=customer_id)
90+
for customer in customers:
91+
for query in config.get("custom_queries", []):
92+
query = query.get("query")
93+
if customer.is_manager_account and self.is_metrics_in_custom_query(query):
94+
return False, f"Metrics are not available for manager account. Check fields in your custom query: {query}"
95+
if CustomQuery.cursor_field in query:
96+
return False, f"Custom query should not contain {CustomQuery.cursor_field}"
97+
req_q = CustomQuery.insert_segments_date_expr(query, "1980-01-01", "1980-01-01")
98+
response = google_api.send_request(req_q, customer_id=customer.id)
99+
# iterate over the response otherwise exceptions will not be raised!
100+
for _ in response:
101+
pass
111102
return True, None
112103
except GoogleAdsException as exception:
113104
error_messages = ", ".join([error.message for error in exception.failure.errors])
114105
logger.error(traceback.format_exc())
115106
return False, f"Unable to connect to Google Ads API with the provided credentials - {error_messages}"
116107

117108
def streams(self, config: Mapping[str, Any]) -> List[Stream]:
118-
google_api = GoogleAds(credentials=self.get_credentials(config), customer_id=config["customer_id"])
119-
account_info = self.get_account_info(google_api, config)
120-
time_zone = self.get_time_zone(account_info)
121-
incremental_stream_config = self.get_incremental_stream_config(google_api, config, tz=time_zone)
122-
109+
google_api = GoogleAds(credentials=self.get_credentials(config))
110+
accounts = self.get_account_info(google_api, config)
111+
customers = Customer.from_accounts(accounts)
112+
incremental_stream_config = self.get_incremental_stream_config(google_api, config, customers)
123113
streams = [
124114
AdGroupAds(**incremental_stream_config),
125-
AdGroupAdLabels(google_api),
115+
AdGroupAdLabels(google_api, customers=customers),
126116
AdGroups(**incremental_stream_config),
127-
AdGroupLabels(google_api),
117+
AdGroupLabels(google_api, customers=customers),
128118
Accounts(**incremental_stream_config),
129119
Campaigns(**incremental_stream_config),
130-
CampaignLabels(google_api),
120+
CampaignLabels(google_api, customers=customers),
131121
ClickView(**incremental_stream_config),
132122
]
133123
custom_query_streams = [
@@ -137,7 +127,9 @@ def streams(self, config: Mapping[str, Any]) -> List[Stream]:
137127
streams.extend(custom_query_streams)
138128

139129
# Metrics streams cannot be requested for a manager account.
140-
if not self.is_manager_account(account_info):
130+
non_manager_accounts = [customer for customer in customers if not customer.is_manager_account]
131+
if non_manager_accounts:
132+
incremental_stream_config["customers"] = non_manager_accounts
141133
streams.extend(
142134
[
143135
UserLocationReport(**incremental_stream_config),

airbyte-integrations/connectors/source-google-ads/source_google_ads/spec.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
"query": {
9191
"type": "string",
9292
"title": "Custom Query",
93-
"description": "A custom defined GAQL query for building the report. Should not contain segments.date expression because it is used by incremental streams. See Google's <a href=\"https://developers.google.com/google-ads/api/fields/v8/overview_query_builder\">query builder</a> for more information.",
93+
"description": "A custom defined GAQL query for building the report. Should not contain segments.date expression because it is used by incremental streams. See Google's <a href=\"https://developers.google.com/google-ads/api/fields/v9/overview_query_builder\">query builder</a> for more information.",
9494
"examples": [
9595
"SELECT segments.ad_destination_type, campaign.advertising_channel_sub_type FROM campaign WHERE campaign.status = 'PAUSED'"
9696
]

0 commit comments

Comments
 (0)