Skip to content

Commit 1bf575d

Browse files
committed
feat: add new granularities to gooddata-dbt and gooddata-pipelines
JIRA: CQ-2783 risk: low
1 parent acfcc1a commit 1bf575d

11 files changed

Lines changed: 152 additions & 16 deletions

File tree

docs/content/en/latest/pipelines/ldm_extension/_index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ ldm_extension_manager = LdmExtensionManager.create(host=host, token=token)
2727

2828
To extend the LDM, you need to define the custom datasets and the fields they should contain. The script also checks the validity of analytical objects before and after the update. Updates introducing new invalid relations are automatically rolled back. You can opt out of this behavior by setting the `check_relations` parameter to False.
2929

30+
To create date datasets with the second-based granularities (`SECOND`, `SECOND_OF_MINUTE`, `SECOND_OF_DAY`, `MINUTE_OF_DAY`), set the `enable_second_granularities` parameter to True when creating the LdmExtensionManager.
31+
3032
### Custom Dataset Definitions
3133

3234
The custom dataset represents a new dataset appended to the child LDM. It is defined by the following parameters:

packages/gooddata-dbt/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ The plugin provides the following use cases:
5050
- Reads dbt models and profiles
5151
- Scans data source (connection props from dbt profiles) through GoodData to get column data types (optional in dbt)
5252
- Generates GoodData LDM(Logical Data Model) from dbt models. Can utilize custom gooddata-specific metadata, more below
53+
- With `--gooddata-enable-second-granularities`, date datasets are created with second-based granularities.
5354
- upload_notification
5455
- Invalidates caches for data source
5556
- deploy_analytics

packages/gooddata-dbt/src/gooddata_dbt/args.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ def set_gooddata_upper_case_args(parser: argparse.ArgumentParser) -> None:
6767
)
6868

6969

70+
def set_gooddata_enable_second_granularities_args(parser: argparse.ArgumentParser) -> None:
71+
parser.add_argument(
72+
"--gooddata-enable-second-granularities",
73+
help="Create date datasets with second-based granularities.",
74+
action="store_true",
75+
default=False,
76+
)
77+
78+
7079
def set_gooddata_workspace_title_args(parser: argparse.ArgumentParser) -> None:
7180
parser.add_argument(
7281
"-gwt", "--gooddata-workspace-title", help="Workspace title", default=os.getenv("GOODDATA_WORKSPACE_TITLE")
@@ -169,6 +178,7 @@ def parse_arguments(description: str) -> argparse.Namespace:
169178
set_dbt_args(deploy_ldm)
170179
set_environment_id_arg(deploy_ldm)
171180
set_gooddata_upper_case_args(deploy_ldm)
181+
set_gooddata_enable_second_granularities_args(deploy_ldm)
172182
deploy_ldm.set_defaults(method="deploy_ldm")
173183

174184
upload_notification = subparsers.add_parser("upload_notification")

packages/gooddata-dbt/src/gooddata_dbt/dbt/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ class GoodDataSortDirection(Enum):
5252
"MINUTE_OF_HOUR",
5353
"HOUR_OF_DAY",
5454
]
55+
# newly added granularities gated behind `enableSecondGranularities` feature flag
56+
SECOND_TIMESTAMP_GRANULARITIES = [
57+
"SECOND",
58+
"SECOND_OF_MINUTE",
59+
"SECOND_OF_DAY",
60+
"MINUTE_OF_DAY",
61+
]
5562
T = TypeVar("T", bound="Base")
5663

5764
DBT_TARGET_DIR = Path("target")

packages/gooddata-dbt/src/gooddata_dbt/dbt/tables.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
DBT_PATH_TO_MANIFEST,
1919
DBT_TARGET_DIR,
2020
NUMERIC_DATA_TYPES,
21+
SECOND_TIMESTAMP_GRANULARITIES,
2122
TIMESTAMP_DATA_TYPES,
2223
TIMESTAMP_GRANULARITIES,
2324
Base,
@@ -202,9 +203,12 @@ class DbtModelTables:
202203
* column_type – Optional if missing call scan
203204
"""
204205

205-
def __init__(self, tables: list[DbtModelTable], upper_case: bool) -> None:
206+
def __init__(
207+
self, tables: list[DbtModelTable], upper_case: bool, enable_second_granularities: bool = False
208+
) -> None:
206209
self.upper_case = upper_case
207210
self.tables = tables
211+
self._enable_second_granularities = enable_second_granularities
208212

209213
@classmethod
210214
def from_cloud(
@@ -214,22 +218,27 @@ def from_cloud(
214218
upper_case: bool,
215219
all_model_ids: list[str],
216220
path: Union[str, Path] = DBT_TARGET_DIR,
221+
enable_second_granularities: bool = False,
217222
) -> "DbtModelTables":
218223
path = path if isinstance(path, Path) else Path(path)
219224
dbt_conn.download_manifest(run_id=run_id, path=path)
220225
with open(path / "manifest.json") as fp:
221226
dbt_catalog = json.load(fp)
222227
tables = cls.read_dbt_models(dbt_catalog, upper_case, all_model_ids)
223-
return cls(tables, upper_case)
228+
return cls(tables, upper_case, enable_second_granularities)
224229

225230
@classmethod
226231
def from_local(
227-
cls, upper_case: bool, all_model_ids: list[str], manifest_path: Union[str, Path] = DBT_PATH_TO_MANIFEST
232+
cls,
233+
upper_case: bool,
234+
all_model_ids: list[str],
235+
manifest_path: Union[str, Path] = DBT_PATH_TO_MANIFEST,
236+
enable_second_granularities: bool = False,
228237
) -> "DbtModelTables":
229238
with open(manifest_path) as fp:
230239
dbt_catalog = json.load(fp)
231240
tables = cls.read_dbt_models(dbt_catalog, upper_case, all_model_ids)
232-
return cls(tables, upper_case)
241+
return cls(tables, upper_case, enable_second_granularities)
233242

234243
@staticmethod
235244
def read_dbt_models(dbt_catalog: dict, upper_case: bool, all_model_ids: list[str]) -> list[DbtModelTable]:
@@ -437,14 +446,17 @@ def make_attributes(self, table: DbtModelTable) -> list[dict]:
437446
)
438447
return attributes
439448

440-
@staticmethod
441-
def make_date_datasets(table: DbtModelTable, existing_date_datasets: list[dict]) -> list[dict]:
449+
def make_date_datasets(self, table: DbtModelTable, existing_date_datasets: list[dict]) -> list[dict]:
442450
date_datasets = []
443451
for column in table.columns.values():
444452
existing_dataset_ids = [d["id"] for d in existing_date_datasets]
445453
if column.is_date() and column.gooddata_ldm_id not in existing_dataset_ids:
446454
if column.data_type in TIMESTAMP_DATA_TYPES:
447-
granularities = DATE_GRANULARITIES + TIMESTAMP_GRANULARITIES
455+
granularities = (
456+
DATE_GRANULARITIES + TIMESTAMP_GRANULARITIES + SECOND_TIMESTAMP_GRANULARITIES
457+
if self._enable_second_granularities
458+
else DATE_GRANULARITIES + TIMESTAMP_GRANULARITIES
459+
)
448460
else:
449461
granularities = DATE_GRANULARITIES
450462
date_datasets.append(

packages/gooddata-dbt/src/gooddata_dbt/dbt_plugin.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ def deploy_ldm(
7373
logger.info("Generate and put LDM")
7474
dbt_profiles = DbtProfiles(args)
7575
data_source_id = dbt_profiles.data_source_id
76-
dbt_tables = DbtModelTables.from_local(args.gooddata_upper_case, all_model_ids)
76+
dbt_tables = DbtModelTables.from_local(
77+
args.gooddata_upper_case,
78+
all_model_ids,
79+
enable_second_granularities=args.gooddata_enable_second_granularities,
80+
)
7781
generate_and_put_ldm(logger, sdk_wrapper, data_source_id, workspace_id, dbt_tables, model_ids)
7882
workspace_url = f"{sdk_wrapper.get_host_from_sdk()}/modeler/#/{workspace_id}"
7983
logger.info(f"LDM successfully loaded, verify here: {workspace_url}")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# (C) 2026 GoodData Corporation
2+
import sys
3+
4+
from gooddata_dbt.args import parse_arguments
5+
6+
7+
def test_parse_arguments_deploy_ldm_second_granularities(monkeypatch):
8+
monkeypatch.setattr(sys, "argv", ["gooddata-dbt", "deploy_ldm"])
9+
assert parse_arguments("test").gooddata_enable_second_granularities is False
10+
11+
monkeypatch.setattr(sys, "argv", ["gooddata-dbt", "deploy_ldm", "--gooddata-enable-second-granularities"])
12+
assert parse_arguments("test").gooddata_enable_second_granularities is True

packages/gooddata-dbt/tests/test_tables.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
from pathlib import Path
44
from typing import Union
55

6-
from gooddata_dbt.dbt.tables import DbtModelTables
6+
from gooddata_dbt.dbt.base import (
7+
DATE_GRANULARITIES,
8+
SECOND_TIMESTAMP_GRANULARITIES,
9+
TIMESTAMP_GRANULARITIES,
10+
)
11+
from gooddata_dbt.dbt.tables import DbtModelColumn, DbtModelTable, DbtModelTables
712
from gooddata_sdk import CatalogDeclarativeModel, CatalogDeclarativeTables
813

914
_CURR_DIR = Path(__file__).parent
@@ -51,6 +56,36 @@ def test_make_ldm():
5156
assert len(ldm.ldm.date_instances) == 4
5257

5358

59+
def _table_with_date_columns() -> DbtModelTable:
60+
return DbtModelTable(
61+
name="events",
62+
description="",
63+
tags=[],
64+
schema="public",
65+
columns={
66+
"created_at": DbtModelColumn(name="created_at", description="", tags=[], data_type="TIMESTAMP"),
67+
"created_on": DbtModelColumn(name="created_on", description="", tags=[], data_type="DATE"),
68+
},
69+
)
70+
71+
72+
def test_make_date_datasets_without_second_granularities():
73+
tables = DbtModelTables([], upper_case=False)
74+
date_datasets = {d["id"]: d for d in tables.make_date_datasets(_table_with_date_columns(), [])}
75+
assert date_datasets["created_at"]["granularities"] == DATE_GRANULARITIES + TIMESTAMP_GRANULARITIES
76+
assert date_datasets["created_on"]["granularities"] == DATE_GRANULARITIES
77+
78+
79+
def test_make_date_datasets_with_second_granularities():
80+
tables = DbtModelTables([], upper_case=False, enable_second_granularities=True)
81+
date_datasets = {d["id"]: d for d in tables.make_date_datasets(_table_with_date_columns(), [])}
82+
assert (
83+
date_datasets["created_at"]["granularities"]
84+
== DATE_GRANULARITIES + TIMESTAMP_GRANULARITIES + SECOND_TIMESTAMP_GRANULARITIES
85+
)
86+
assert date_datasets["created_on"]["granularities"] == DATE_GRANULARITIES
87+
88+
5489
FAA_MODEL_ID = "faa"
5590

5691

packages/gooddata-pipelines/src/gooddata_pipelines/ldm_extension/input_processor.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ class LdmExtensionDataProcessor:
8282
"FISCAL_YEAR",
8383
]
8484

85+
# newly added granularities gated behind `enableSecondGranularities` feature flag
86+
_SECOND_DATE_GRANULARITIES: list[str] = [
87+
"SECOND",
88+
"SECOND_OF_MINUTE",
89+
"SECOND_OF_DAY",
90+
"MINUTE_OF_DAY",
91+
]
92+
93+
def __init__(self, enable_second_granularities: bool = False):
94+
self._date_granularities = (
95+
self.DATE_GRANULARITIES + self._SECOND_DATE_GRANULARITIES
96+
if enable_second_granularities
97+
else self.DATE_GRANULARITIES
98+
)
99+
85100
@staticmethod
86101
def _attribute_from_field(
87102
dataset_name: str,
@@ -127,7 +142,7 @@ def _date_from_field(
127142
title_base="",
128143
title_pattern="%titleBase - %granularityTitle",
129144
),
130-
granularities=self.DATE_GRANULARITIES,
145+
granularities=self._date_granularities,
131146
description=custom_field.description,
132147
tags=_effective_field_tags(dataset_name, custom_field),
133148
)

packages/gooddata-pipelines/src/gooddata_pipelines/ldm_extension/ldm_extension_manager.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,47 @@
3333

3434

3535
class LdmExtensionManager:
36-
"""Manager for creating custom datasets and fields in GoodData workspaces."""
36+
"""Manager for creating custom datasets and fields in GoodData workspaces.
37+
38+
Args:
39+
enable_second_granularities (bool): Whether to create date datasets
40+
with second-based granularities.
41+
"""
3742

3843
INDENT = " " * 2
3944

4045
@classmethod
41-
def create(cls, host: str, token: str) -> "LdmExtensionManager":
42-
return cls(host=host, token=token)
46+
def create(
47+
cls, host: str, token: str, enable_second_granularities: bool = False
48+
) -> "LdmExtensionManager":
49+
return cls(
50+
host=host,
51+
token=token,
52+
enable_second_granularities=enable_second_granularities,
53+
)
4354

4455
@classmethod
4556
def create_from_profile(
4657
cls,
4758
profile: str = "default",
4859
profiles_path: Path = PROFILES_FILE_PATH,
60+
enable_second_granularities: bool = False,
4961
) -> "LdmExtensionManager":
5062
"""Creates a provisioner instance using a GoodData profile file."""
5163
content = profile_content(profile, profiles_path)
52-
return cls(host=content["host"], token=content["token"])
64+
return cls(
65+
host=content["host"],
66+
token=content["token"],
67+
enable_second_granularities=enable_second_granularities,
68+
)
5369

54-
def __init__(self, host: str, token: str):
70+
def __init__(
71+
self, host: str, token: str, enable_second_granularities: bool = False
72+
):
5573
self._validator = LdmExtensionDataValidator()
56-
self._processor = LdmExtensionDataProcessor()
74+
self._processor = LdmExtensionDataProcessor(
75+
enable_second_granularities=enable_second_granularities
76+
)
5777
self._sdk = GoodDataSdk.create(host_=host, token_=token)
5878
self._api = GoodDataApi(host=host, token=token)
5979
self.logger = LogObserver()

0 commit comments

Comments
 (0)