2626 Callable ,
2727 Dict ,
2828 List ,
29+ Optional ,
2930 Union ,
3031)
3132
3233from loguru import logger
3334import pydantic
34- from pydantic import BaseModel
35+ from pydantic import BaseModel , model_validator
3536import requests
3637
3738from datacustomcode .cmd import cmd_output
@@ -366,23 +367,86 @@ class BaseConfig(BaseModel):
366367 entryPoint : str
367368
368369
370+ class DataObjectField (BaseModel ):
371+ name : str
372+ label : str
373+ dataType : str
374+ isPrimaryKey : bool = False
375+ keyQualifierFieldName : Optional [str ] = None
376+
377+
378+ class DataObject (BaseModel ):
379+ name : str
380+ label : str
381+ type : str
382+ category : str
383+ fields : list [DataObjectField ]
384+
385+
369386class DataTransformConfig (BaseConfig ):
370387 sdkVersion : str
371388 dataspace : str
372389 permissions : Permissions
390+ dataObjects : Optional [list [DataObject ]] = None
373391
374392
375393class FunctionConfig (BaseConfig ):
376394 pass
377395
378396
397+ class DloPermission (BaseModel ):
398+ dlo : list [str ]
399+
400+
401+ class DmoPermission (BaseModel ):
402+ dmo : list [str ]
403+
404+
379405class Permissions (BaseModel ):
380- read : Union [DloPermission ]
381- write : Union [DloPermission ]
406+ read : Union [DloPermission , DmoPermission ]
407+ write : Union [DloPermission , DmoPermission ]
408+
409+ @model_validator (mode = "after" )
410+ def _no_mixed_layers (self ) -> "Permissions" :
411+ read_is_dlo = isinstance (self .read , DloPermission )
412+ write_is_dlo = isinstance (self .write , DloPermission )
413+ if read_is_dlo != write_is_dlo :
414+ raise ValueError (
415+ "permissions.read and permissions.write must both reference "
416+ "DLOs or both reference DMOs (got "
417+ f"read={ type (self .read ).__name__ } , "
418+ f"write={ type (self .write ).__name__ } )"
419+ )
420+ return self
382421
383422
384- class DloPermission (BaseModel ):
385- dlo : list [str ]
423+ def _permission_entries (perm : Union [DloPermission , DmoPermission ]) -> list [str ]:
424+ """Return the list of object names regardless of layer (DLO or DMO)."""
425+ if isinstance (perm , DloPermission ):
426+ return perm .dlo
427+ return perm .dmo
428+
429+
430+ def _data_object_to_output (obj : DataObject ) -> dict [str , Any ]:
431+ """Convert a config.json DataObject into an outputDataObjects entry."""
432+ fields : list [dict [str , Any ]] = []
433+ for field in obj .fields :
434+ entry : dict [str , Any ] = {
435+ "isPrimaryKey" : field .isPrimaryKey ,
436+ "label" : field .label ,
437+ "name" : field .name ,
438+ "type" : field .dataType ,
439+ }
440+ if field .keyQualifierFieldName is not None :
441+ entry ["keyQualifierField" ] = field .keyQualifierFieldName
442+ fields .append (entry )
443+ return {
444+ "category" : obj .category ,
445+ "fields" : fields ,
446+ "label" : obj .label ,
447+ "name" : obj .name ,
448+ "type" : obj .type ,
449+ }
386450
387451
388452def get_config (directory : str ) -> BaseConfig :
@@ -404,10 +468,17 @@ def get_config(directory: str) -> BaseConfig:
404468 except json .JSONDecodeError as err :
405469 raise ValueError (f"config.json at { config_path } is not valid JSON" ) from err
406470 except pydantic .ValidationError as err :
407- missing_fields = [str (err ["loc" ][0 ]) for err in err .errors ()]
471+ errors = err .errors ()
472+ missing = [e for e in errors if e .get ("type" ) == "missing" ]
473+ if missing and len (missing ) == len (errors ):
474+ missing_fields = [str (e ["loc" ][0 ]) for e in missing ]
475+ raise ValueError (
476+ f"config.json at { config_path } is missing required "
477+ f"fields: { ', ' .join (missing_fields )} "
478+ ) from err
479+ messages = [str (e .get ("msg" , "" )) for e in errors ]
408480 raise ValueError (
409- f"config.json at { config_path } is missing required "
410- f"fields: { ', ' .join (missing_fields )} "
481+ f"config.json at { config_path } is invalid: { '; ' .join (messages )} "
411482 ) from err
412483
413484
@@ -421,26 +492,45 @@ def create_data_transform(
421492 script_name = metadata .name
422493 request_hydrated = DATA_TRANSFORM_REQUEST_TEMPLATE .copy ()
423494
424- # Add nodes for each write DLO
425- for i , dlo in enumerate (data_transform_config .permissions .write .dlo , 1 ):
495+ # Add nodes for each write entry (DLO or DMO)
496+ for i , name in enumerate (
497+ _permission_entries (data_transform_config .permissions .write ), 1
498+ ):
426499 request_hydrated ["nodes" ][f"node{ i } " ] = {
427- "relation_name" : dlo ,
500+ "relation_name" : name ,
428501 "config" : {"materialized" : "table" },
429502 "compiled_code" : "" ,
430503 }
431504
432- # Add sources for each read DLO
433- for i , dlo in enumerate (data_transform_config .permissions .read .dlo , 1 ):
434- request_hydrated ["sources" ][f"source{ i } " ] = {"relation_name" : dlo }
505+ # Add sources for each read entry (DLO or DMO)
506+ for i , name in enumerate (
507+ _permission_entries (data_transform_config .permissions .read ), 1
508+ ):
509+ request_hydrated ["sources" ][f"source{ i } " ] = {"relation_name" : name }
435510
436511 request_hydrated ["macros" ]["macro.byoc" ]["arguments" ][0 ]["name" ] = script_name
437512
513+ definition : dict [str , Any ] = {
514+ "type" : "DCSQL" ,
515+ "manifest" : request_hydrated ,
516+ "version" : "56.0" ,
517+ }
518+
519+ # outputDataObjects is only set for DMO-backed transforms. The server requires
520+ # the schema of any DMO created/updated by the transform; DLO transforms use
521+ # an existing materialized table and must not include this field.
522+ if isinstance (data_transform_config .permissions .write , DmoPermission ):
523+ if not data_transform_config .dataObjects :
524+ raise ValueError (
525+ "DMO transforms require 'dataObjects' in config.json describing "
526+ "the schema of each output DMO."
527+ )
528+ definition ["outputDataObjects" ] = [
529+ _data_object_to_output (obj ) for obj in data_transform_config .dataObjects
530+ ]
531+
438532 body = {
439- "definition" : {
440- "type" : "DCSQL" ,
441- "manifest" : request_hydrated ,
442- "version" : "56.0" ,
443- },
533+ "definition" : definition ,
444534 "label" : f"{ metadata .name } " ,
445535 "name" : f"{ metadata .name } " ,
446536 "type" : "BATCH" ,
0 commit comments