Skip to content

Commit d22abfd

Browse files
authored
Merge pull request #192 from dbt-labs/fix/optional-schedule-for-merge-ci
feat: make schedule optional for ci and merge jobs
2 parents 9691894 + 3fe552d commit d22abfd

4 files changed

Lines changed: 159 additions & 4 deletions

File tree

src/dbt_jobs_as_code/schemas/job.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
)
2121
from dbt_jobs_as_code.schemas.custom_environment_variable import CustomEnvironmentVariable
2222

23+
JOB_TYPES_WITHOUT_SCHEDULE = ["ci", "merge"]
24+
2325

2426
@dataclass
2527
class IdentifierInfo:
@@ -57,10 +59,22 @@ def filter_jobs_by_import_filter(
5759
]
5860

5961

62+
def _job_definition_json_schema_extra(schema: dict) -> None:
63+
"""Make 'schedule' conditionally required based on job_type."""
64+
schema["if"] = {
65+
"properties": {"job_type": {"enum": JOB_TYPES_WITHOUT_SCHEDULE}},
66+
"required": ["job_type"],
67+
}
68+
schema["then"] = {}
69+
schema["else"] = {"required": ["schedule"]}
70+
71+
6072
# Main model for loader
6173
class JobDefinition(BaseModel):
6274
"""A definition for a dbt Cloud job."""
6375

76+
model_config = ConfigDict(json_schema_extra=_job_definition_json_schema_extra)
77+
6478
linked_id: Optional[int] = Field(
6579
default=None,
6680
description="The ID of the job in dbt Cloud that we want to link. Only used for the 'link' command.",
@@ -85,7 +99,7 @@ class JobDefinition(BaseModel):
8599
errors_on_lint_failure: Optional[bool] = True
86100
execute_steps: List[str]
87101
generate_docs: bool
88-
schedule: Schedule
102+
schedule: Optional[Schedule] = None
89103
triggers: Triggers
90104
description: str = ""
91105
state: int = 1
@@ -217,6 +231,16 @@ def to_url(self, account_url: str) -> str:
217231
"""Generate a URL for the job in dbt Cloud."""
218232
return f"{account_url}/deploy/{self.account_id}/projects/{self.project_id}/jobs/{self.id}"
219233

234+
@model_validator(mode="after")
235+
def default_schedule_for_ci_merge(self):
236+
"""Default schedule for CI and merge jobs, require it for other job types."""
237+
if self.schedule is None:
238+
if self.job_type in JOB_TYPES_WITHOUT_SCHEDULE:
239+
self.schedule = Schedule(cron="0 0 1 1 *")
240+
else:
241+
raise ValueError(f"'schedule' is required for '{self.job_type}' jobs")
242+
return self
243+
220244
@model_validator(mode="after")
221245
def validate_cron_expression(self):
222246
"""Validate the cron expression and include job ID in error message if invalid."""

src/dbt_jobs_as_code/schemas/load_job_schema.json

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@
100100
},
101101
"JobDefinition": {
102102
"description": "A definition for a dbt Cloud job.",
103+
"else": {
104+
"required": [
105+
"schedule"
106+
]
107+
},
108+
"if": {
109+
"properties": {
110+
"job_type": {
111+
"enum": [
112+
"ci",
113+
"merge"
114+
]
115+
}
116+
},
117+
"required": [
118+
"job_type"
119+
]
120+
},
103121
"properties": {
104122
"linked_id": {
105123
"anyOf": [
@@ -252,7 +270,7 @@
252270
"type": "null"
253271
}
254272
],
255-
"default": false,
273+
"default": true,
256274
"title": "Errors On Lint Failure"
257275
},
258276
"execute_steps": {
@@ -267,7 +285,15 @@
267285
"type": "boolean"
268286
},
269287
"schedule": {
270-
"$ref": "#/$defs/Schedule"
288+
"anyOf": [
289+
{
290+
"$ref": "#/$defs/Schedule"
291+
},
292+
{
293+
"type": "null"
294+
}
295+
],
296+
"default": null
271297
},
272298
"triggers": {
273299
"$ref": "#/$defs/Triggers"
@@ -354,9 +380,9 @@
354380
"run_generate_sources",
355381
"execute_steps",
356382
"generate_docs",
357-
"schedule",
358383
"triggers"
359384
],
385+
"then": {},
360386
"title": "JobDefinition",
361387
"type": "object"
362388
},

tests/schemas/test_job.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import json
2+
13
import pytest
4+
from jsonschema import ValidationError as JsonSchemaValidationError
5+
from jsonschema import validate
6+
from pydantic import ValidationError
27

8+
from dbt_jobs_as_code.schemas.config import generate_config_schema
39
from dbt_jobs_as_code.schemas.job import (
410
IdentifierInfo,
511
JobDefinition,
@@ -153,3 +159,83 @@ def test_empty_filter(self, test_job_factory):
153159
assert len(result) == 1
154160
assert jobs[0] in result
155161
assert jobs[1] not in result
162+
163+
164+
BASE_JOB_DATA = {
165+
"name": "Test Job",
166+
"account_id": 1,
167+
"project_id": 1,
168+
"environment_id": 1,
169+
"settings": {},
170+
"triggers": {},
171+
"execute_steps": ["dbt build"],
172+
"run_generate_sources": False,
173+
"generate_docs": False,
174+
}
175+
176+
177+
class TestScheduleConditionalRequirement:
178+
"""Tests for schedule being optional on ci/merge jobs."""
179+
180+
# -- Pydantic model tests --
181+
182+
@pytest.mark.parametrize("job_type", ["ci", "merge"])
183+
def test_pydantic_schedule_optional_for_ci_merge(self, job_type):
184+
job = JobDefinition(**{**BASE_JOB_DATA, "job_type": job_type})
185+
assert job.schedule is not None # defaults to Schedule()
186+
187+
@pytest.mark.parametrize("job_type", ["scheduled", "other"])
188+
def test_pydantic_schedule_required_for_scheduled_other(self, job_type):
189+
with pytest.raises(ValidationError, match="schedule"):
190+
JobDefinition(**{**BASE_JOB_DATA, "job_type": job_type})
191+
192+
def test_pydantic_schedule_required_when_job_type_absent(self):
193+
with pytest.raises(ValidationError, match="schedule"):
194+
JobDefinition(**BASE_JOB_DATA) # job_type defaults to "scheduled"
195+
196+
# -- JSON schema tests --
197+
198+
@pytest.fixture
199+
def json_schema(self):
200+
return json.loads(generate_config_schema())
201+
202+
def _config_instance(self, job_type=None, include_schedule=False):
203+
"""Build a minimal config dict for JSON schema validation."""
204+
job = {
205+
"name": "Test Job",
206+
"account_id": 1,
207+
"project_id": 1,
208+
"environment_id": 1,
209+
"settings": {},
210+
"triggers": {},
211+
"execute_steps": ["dbt build"],
212+
"run_generate_sources": False,
213+
"generate_docs": False,
214+
}
215+
if job_type is not None:
216+
job["job_type"] = job_type
217+
if include_schedule:
218+
job["schedule"] = {"cron": "0 0 * * *"}
219+
return {"jobs": {"test_job": job}}
220+
221+
@pytest.mark.parametrize("job_type", ["ci", "merge"])
222+
def test_json_schema_schedule_optional_for_ci_merge(self, json_schema, job_type):
223+
instance = self._config_instance(job_type=job_type, include_schedule=False)
224+
validate(instance=instance, schema=json_schema)
225+
226+
@pytest.mark.parametrize("job_type", ["scheduled", "other"])
227+
def test_json_schema_schedule_required_for_scheduled_other(self, json_schema, job_type):
228+
instance = self._config_instance(job_type=job_type, include_schedule=False)
229+
with pytest.raises(JsonSchemaValidationError, match="schedule"):
230+
validate(instance=instance, schema=json_schema)
231+
232+
def test_json_schema_schedule_required_when_job_type_absent(self, json_schema):
233+
instance = self._config_instance(include_schedule=False)
234+
with pytest.raises(JsonSchemaValidationError, match="schedule"):
235+
validate(instance=instance, schema=json_schema)
236+
237+
@pytest.mark.parametrize("job_type", ["scheduled", "ci", "merge", "other"])
238+
def test_json_schema_schedule_accepted_for_all_types(self, json_schema, job_type):
239+
"""Providing schedule is always valid regardless of job_type."""
240+
instance = self._config_instance(job_type=job_type, include_schedule=True)
241+
validate(instance=instance, schema=json_schema)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import json
2+
from pathlib import Path
3+
4+
from dbt_jobs_as_code.schemas.config import generate_config_schema
5+
6+
SCHEMA_PATH = Path("src/dbt_jobs_as_code/schemas/load_job_schema.json")
7+
8+
9+
def test_json_schema_is_up_to_date():
10+
"""Ensure the committed JSON schema matches what the models generate.
11+
12+
If this fails, run: `dbt-jobs-as-code update-json-schema`
13+
"""
14+
committed = json.loads(SCHEMA_PATH.read_text())
15+
generated = json.loads(generate_config_schema())
16+
assert committed == generated, (
17+
"The committed JSON schema is out of date. "
18+
"Run `dbt-jobs-as-code update-json-schema` to regenerate it."
19+
)

0 commit comments

Comments
 (0)