datajunction-server 0.0.2.dev2__py3-none-any.whl → 0.0.2.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,4 +2,4 @@
2
2
  Version for Hatch
3
3
  """
4
4
 
5
- __version__ = "0.0.2.dev2"
5
+ __version__ = "0.0.2.dev4"
@@ -1,8 +1,7 @@
1
1
  """
2
2
  Add cascade delete on tag-node and column-attr foreign keys
3
-
4
3
  Revision ID: b55add7e1ebc
5
- Revises: b6398ba852b3
4
+ Revises: 759c4d50cb8d
6
5
  Create Date: 2025-09-16 15:13:39.963297+00:00
7
6
  """
8
7
  # pylint: disable=no-member, invalid-name, missing-function-docstring, unused-import, no-name-in-module
@@ -11,7 +10,7 @@ from alembic import op
11
10
 
12
11
  # revision identifiers, used by Alembic.
13
12
  revision = "b55add7e1ebc"
14
- down_revision = "b6398ba852b3"
13
+ down_revision = "759c4d50cb8d"
15
14
  branch_labels = None
16
15
  depends_on = None
17
16
 
@@ -2,7 +2,7 @@
2
2
  Deployments
3
3
 
4
4
  Revision ID: b6398ba852b3
5
- Revises: 5b00137c69f9
5
+ Revises: b55add7e1ebc
6
6
  Create Date: 2025-09-12 00:07:30.531304+00:00
7
7
  """
8
8
  # pylint: disable=no-member, invalid-name, missing-function-docstring, unused-import, no-name-in-module
@@ -13,7 +13,7 @@ import sqlalchemy_utils
13
13
 
14
14
  # revision identifiers, used by Alembic.
15
15
  revision = "b6398ba852b3"
16
- down_revision = "759c4d50cb8d"
16
+ down_revision = "b55add7e1ebc"
17
17
  branch_labels = None
18
18
  depends_on = None
19
19
 
@@ -403,8 +403,7 @@ async def export_namespace_spec(
403
403
  session: AsyncSession = Depends(get_session),
404
404
  ) -> DeploymentSpec:
405
405
  """
406
- Generates a zip of YAML files for the contents of the given namespace
407
- as well as a project definition file.
406
+ Generates a deployment spec for a namespace
408
407
  """
409
408
  nodes = await NodeNamespace.list_all_nodes(
410
409
  session,
@@ -438,6 +438,15 @@ class Node(Base):
438
438
  node_spec_cls = node_spec_class_map[self.type]
439
439
  return node_spec_cls(**base_kwargs, **extra_kwargs)
440
440
 
441
+ @classmethod
442
+ def default_load_options(cls) -> List[ExecutableOption]:
443
+ return [
444
+ joinedload(Node.current).options(*NodeRevision.default_load_options()),
445
+ selectinload(Node.tags),
446
+ selectinload(Node.created_by),
447
+ selectinload(Node.owners),
448
+ ]
449
+
441
450
  @classmethod
442
451
  def cube_load_options(cls) -> List[ExecutableOption]:
443
452
  return [
@@ -10,7 +10,10 @@ from datajunction_server.database import Node
10
10
  from datajunction_server.models import access
11
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
12
  from datajunction_server.api.tags import get_tags_by_name
13
+ from datajunction_server.models.base import labelize
13
14
 
15
+ from datajunction_server.database.partition import Partition
16
+ from datajunction_server.database.namespace import NodeNamespace
14
17
  from datajunction_server.models.attribute import AttributeTypeIdentifier
15
18
  from datajunction_server.models.deployment import (
16
19
  CubeSpec,
@@ -139,9 +142,38 @@ async def deploy(
139
142
  deployed_results: list[DeploymentResult] = []
140
143
 
141
144
  async with session_context(request) as session:
142
- existing = await find_existing_nodes(session, deployment.nodes)
143
- to_deploy, to_skip = filter_nodes_to_deploy(deployment.nodes, existing)
144
- deployed_results.extend(to_skip)
145
+ current_user = cast(User, await User.get_by_username(session, current_username))
146
+ await create_deployment_namespaces(
147
+ deployment,
148
+ session,
149
+ current_user,
150
+ save_history,
151
+ )
152
+
153
+ # async with session_context(request) as session:
154
+ all_nodes = await NodeNamespace.list_all_nodes(
155
+ session,
156
+ deployment.namespace,
157
+ options=Node.cube_load_options(),
158
+ )
159
+ existing = {node.name: await node.to_spec(session) for node in all_nodes}
160
+ to_deploy, to_skip, to_delete = filter_nodes_to_deploy(
161
+ deployment.nodes,
162
+ existing,
163
+ )
164
+
165
+ deployed_results.extend(
166
+ [
167
+ DeploymentResult(
168
+ name=node_spec.rendered_name,
169
+ deploy_type=DeploymentResult.Type.NODE,
170
+ status=DeploymentResult.Status.SKIPPED,
171
+ operation=DeploymentResult.Operation.NOOP,
172
+ message=f"Node {node_spec.rendered_name} is unchanged.",
173
+ )
174
+ for node_spec in to_skip
175
+ ],
176
+ )
145
177
  if not to_deploy:
146
178
  logger.info(
147
179
  "No changes detected, skipping deployment. Total elapsed: %.3fs",
@@ -155,15 +187,6 @@ async def deploy(
155
187
  len(to_skip),
156
188
  )
157
189
 
158
- async with session_context(request) as session:
159
- current_user = cast(User, await User.get_by_username(session, current_username))
160
- await create_deployment_namespaces(
161
- deployment,
162
- session,
163
- current_user,
164
- save_history,
165
- )
166
-
167
190
  node_graph = extract_node_graph(
168
191
  [node for node in to_deploy if not isinstance(node, CubeSpec)],
169
192
  )
@@ -232,6 +255,9 @@ async def deploy(
232
255
  DeploymentStatus.RUNNING,
233
256
  deployed_results,
234
257
  )
258
+ logger.info("Starting deletion of %d nodes", len(to_delete))
259
+ # for node in to_delete:
260
+ # await deactivate_node(session, node.name, current_user, save_history)
235
261
  logger.info("Finished deploying namespace %s", deployment.namespace)
236
262
  return deployed_results
237
263
 
@@ -248,7 +274,15 @@ async def create_deployment_namespaces(
248
274
  if SEPARATOR in node.rendered_name
249
275
  ]
250
276
  namespace_set = set(namespaces)
251
- for nspace in namespace_set:
277
+ pruned = {
278
+ ns
279
+ for ns in namespace_set
280
+ if not any(
281
+ other != ns and other.startswith(f"{ns}{SEPARATOR}")
282
+ for other in namespace_set
283
+ )
284
+ }
285
+ for nspace in pruned:
252
286
  await create_namespace(
253
287
  session=session,
254
288
  namespace=nspace,
@@ -351,7 +385,7 @@ def filter_nodes_to_deploy(
351
385
  ):
352
386
  to_create: list[NodeSpec] = []
353
387
  to_update: list[NodeSpec] = []
354
- to_skip: list[DeploymentResult] = []
388
+ to_skip: list[NodeSpec] = []
355
389
  for node_spec in node_specs:
356
390
  existing_spec = existing_nodes_map.get(node_spec.rendered_name)
357
391
  if not existing_spec:
@@ -359,15 +393,14 @@ def filter_nodes_to_deploy(
359
393
  elif node_spec != existing_spec:
360
394
  to_update.append(node_spec)
361
395
  else:
362
- to_skip.append(
363
- DeploymentResult(
364
- name=node_spec.rendered_name,
365
- deploy_type=DeploymentResult.Type.NODE,
366
- status=DeploymentResult.Status.SKIPPED,
367
- operation=DeploymentResult.Operation.NOOP,
368
- message=f"Node {node_spec.rendered_name} is unchanged.",
369
- ),
370
- )
396
+ to_skip.append(node_spec)
397
+
398
+ desired_node_names = {n.rendered_name for n in node_specs}
399
+ to_delete = [
400
+ existing
401
+ for name, existing in existing_nodes_map.items()
402
+ if name not in desired_node_names
403
+ ]
371
404
 
372
405
  logger.info(
373
406
  "Creating %d new nodes: %s",
@@ -382,9 +415,9 @@ def filter_nodes_to_deploy(
382
415
  logger.info(
383
416
  "Skipping %d nodes as they are unchanged: %s",
384
417
  len(to_skip),
385
- [result.name for result in to_skip],
418
+ [result.rendered_name for result in to_skip],
386
419
  )
387
- return to_create + to_update, to_skip
420
+ return to_create + to_update, to_skip, to_delete
388
421
 
389
422
 
390
423
  async def check_external_deps(
@@ -475,8 +508,7 @@ async def deploy_nodes_in_levels(
475
508
  background_tasks=background_tasks,
476
509
  save_history=save_history,
477
510
  cache=cache,
478
- existing=existing_nodes_map.get(node_spec.rendered_name)
479
- is not None,
511
+ existing=existing_nodes_map.get(node_spec.rendered_name),
480
512
  ),
481
513
  )
482
514
 
@@ -505,16 +537,17 @@ async def deploy_links_for_node(
505
537
  existing_nodes_map.get(node_spec.rendered_name),
506
538
  )
507
539
  existing_node_links = {
508
- link.rendered_dimension_node: link
540
+ (link.rendered_dimension_node, link.role): link
509
541
  for link in (existing_node_spec.dimension_links if existing_node_spec else [])
510
542
  }
511
543
  desired_node_links = {
512
- link.rendered_dimension_node: link for link in node_spec.dimension_links
544
+ (link.rendered_dimension_node, link.role): link
545
+ for link in node_spec.dimension_links
513
546
  }
514
547
  to_delete = {
515
- existing_node_links[dim]
516
- for dim in existing_node_links
517
- if dim not in desired_node_links
548
+ existing_node_links[(dim, role)]
549
+ for (dim, role) in existing_node_links
550
+ if (dim, role) not in desired_node_links
518
551
  }
519
552
  async with session_context(request) as session:
520
553
  for link in to_delete:
@@ -591,7 +624,7 @@ async def deploy_cubes(
591
624
  background_tasks=background_tasks,
592
625
  save_history=save_history,
593
626
  cache=cache,
594
- existing=existing_nodes_map.get(cube_spec.rendered_name) is not None,
627
+ existing=existing_nodes_map.get(cube_spec.rendered_name),
595
628
  ),
596
629
  )
597
630
  return await run_tasks_with_semaphore(
@@ -644,21 +677,61 @@ async def deploy_column_attributes(
644
677
  node_spec: NodeSpec,
645
678
  current_username: str,
646
679
  save_history: Callable,
647
- ):
680
+ ) -> set[str]:
681
+ changed_columns = set()
648
682
  async with session_context() as session:
649
683
  node = await Node.get_by_name(session=session, name=node_name)
650
684
  current_user = cast(User, await User.get_by_username(session, current_username))
651
- for col in node_spec.columns or []:
652
- await set_node_column_attributes(
653
- session=session,
654
- node=node, # type: ignore
655
- column_name=col.name,
656
- attributes=[
657
- AttributeTypeIdentifier(name=attr) for attr in col.attributes
658
- ],
659
- current_user=current_user,
660
- save_history=save_history,
661
- )
685
+ desired_column_state = {col.name: col for col in node_spec.columns or []}
686
+ for col in node.current.columns: # type: ignore
687
+ if desired_col := desired_column_state.get(col.name):
688
+ # If the column is explicitly defined, update its properties to match
689
+ if col.display_name != desired_col.display_name:
690
+ col.display_name = desired_col.display_name
691
+ changed_columns.add(col.name)
692
+ if col.description != desired_col.description:
693
+ col.description = desired_col.description
694
+ changed_columns.add(col.name)
695
+ if desired_col.partition != (
696
+ col.partition.to_spec() if col.partition else None
697
+ ):
698
+ partition = (
699
+ Partition(
700
+ column_id=col.id,
701
+ type_=desired_col.partition.type,
702
+ format=desired_col.partition.format,
703
+ granularity=desired_col.partition.granularity,
704
+ )
705
+ if desired_col.partition
706
+ else None
707
+ )
708
+ if partition:
709
+ session.add(partition)
710
+ col.partition = partition
711
+ changed_columns.add(col.name)
712
+ if set(desired_col.attributes) != set(col.attribute_names()):
713
+ await set_node_column_attributes(
714
+ session=session,
715
+ node=node, # type: ignore
716
+ column_name=col.name,
717
+ attributes=[
718
+ AttributeTypeIdentifier(name=attr)
719
+ for attr in desired_col.attributes
720
+ ],
721
+ current_user=current_user,
722
+ save_history=save_history,
723
+ )
724
+ changed_columns.add(col.name)
725
+ else:
726
+ # If the column is not explicitly defined, reset it to default
727
+ col.display_name = labelize(col.name)
728
+ col.description = ""
729
+ col.partition = None
730
+ col.attributes = []
731
+
732
+ session.add(col)
733
+ await session.commit()
734
+ return changed_columns
662
735
 
663
736
 
664
737
  async def deploy_node_from_spec(
@@ -671,7 +744,7 @@ async def deploy_node_from_spec(
671
744
  *,
672
745
  save_history: Callable,
673
746
  cache: Cache,
674
- existing: bool = False,
747
+ existing: NodeSpec | None = None,
675
748
  ) -> DeploymentResult:
676
749
  """
677
750
  Deploy a node from its specification.
@@ -690,6 +763,7 @@ async def deploy_node_from_spec(
690
763
  if not existing
691
764
  else DeploymentResult.Operation.UPDATE
692
765
  )
766
+ changelog = []
693
767
  if not deploy_fn: # pragma: no cover
694
768
  raise DJInvalidDeploymentConfig(f"Unknown node type: {node_spec.node_type}")
695
769
  try:
@@ -704,20 +778,40 @@ async def deploy_node_from_spec(
704
778
  cache=cache,
705
779
  existing=existing,
706
780
  )
707
- await deploy_node_tags(node_name=node.name, node_spec=node_spec)
708
- if node.type in (NodeType.SOURCE, NodeType.TRANSFORM, NodeType.DIMENSION):
709
- await deploy_column_attributes(
781
+ changed_fields = existing.diff(node_spec) if existing else []
782
+ changelog.append(
783
+ f"{operation.capitalize()}d {node_spec.node_type} ({node.current_version})",
784
+ )
785
+ changelog.append(
786
+ ("└─ Updated " + ", ".join(changed_fields)),
787
+ ) if changed_fields else ""
788
+
789
+ if set(node_spec.tags) != set([tag.name for tag in node.tags]):
790
+ await deploy_node_tags(node_name=node.name, node_spec=node_spec)
791
+ tags_list = ", ".join([f"`{tag}`" for tag in node_spec.tags])
792
+ changelog.append(f"└─ Set tags to {tags_list}.")
793
+ if node.type in (
794
+ NodeType.SOURCE,
795
+ NodeType.TRANSFORM,
796
+ NodeType.DIMENSION,
797
+ NodeType.CUBE,
798
+ ):
799
+ changed_columns = await deploy_column_attributes(
710
800
  node_name=node.name,
711
801
  node_spec=node_spec,
712
802
  current_username=current_username,
713
803
  save_history=save_history,
714
804
  )
805
+ if changed_columns and operation == DeploymentResult.Operation.UPDATE:
806
+ changelog.append(
807
+ f"└─ Set properties for {len(changed_columns)} columns",
808
+ )
715
809
  except DJException as exc:
716
810
  return DeploymentResult(
717
811
  deploy_type=DeploymentResult.Type.NODE,
718
812
  name=node_spec.rendered_name,
719
813
  status=DeploymentResult.Status.FAILED,
720
- message=str(exc),
814
+ message="\n".join(changelog + [str(exc)]),
721
815
  operation=operation,
722
816
  )
723
817
 
@@ -728,6 +822,7 @@ async def deploy_node_from_spec(
728
822
  if isinstance(node, Node)
729
823
  else DeploymentResult.Status.FAILED,
730
824
  operation=operation,
825
+ message="\n".join(changelog),
731
826
  )
732
827
 
733
828
 
@@ -810,7 +905,7 @@ async def deploy_transform_dimension_node_from_spec(
810
905
  *,
811
906
  save_history: Callable,
812
907
  cache: Cache,
813
- existing: bool = False,
908
+ existing: NodeSpec | None = None,
814
909
  ) -> Node:
815
910
  """
816
911
  Deploy a transform or dimension node from its spec.
@@ -886,20 +981,12 @@ async def deploy_metric_node_from_spec(
886
981
  """
887
982
  Deploy a metric node from its spec.
888
983
  """
889
- metric_metadata_input = (
890
- MetricMetadataInput(
891
- direction=node_spec.direction,
892
- unit=node_spec.unit,
893
- significant_digits=node_spec.significant_digits,
894
- min_decimal_exponent=node_spec.min_decimal_exponent,
895
- max_decimal_exponent=node_spec.max_decimal_exponent,
896
- )
897
- if node_spec.direction
898
- or node_spec.unit
899
- or node_spec.significant_digits
900
- or node_spec.min_decimal_exponent
901
- or node_spec.max_decimal_exponent
902
- else None
984
+ metric_metadata_input = MetricMetadataInput(
985
+ direction=node_spec.direction,
986
+ unit=node_spec.unit,
987
+ significant_digits=node_spec.significant_digits,
988
+ min_decimal_exponent=node_spec.min_decimal_exponent,
989
+ max_decimal_exponent=node_spec.max_decimal_exponent,
903
990
  )
904
991
  async with session_context(request) as session:
905
992
  current_user = cast(User, await User.get_by_username(session, current_username))
@@ -911,10 +998,10 @@ async def deploy_metric_node_from_spec(
911
998
  display_name=node_spec.display_name,
912
999
  description=node_spec.description,
913
1000
  mode=node_spec.mode,
914
- custom_metadata=node_spec.custom_metadata,
1001
+ custom_metadata=node_spec.custom_metadata or {},
915
1002
  owners=node_spec.owners,
916
1003
  query=node_spec.rendered_query,
917
- required_dimensions=node_spec.required_dimensions,
1004
+ required_dimensions=node_spec.required_dimensions or [],
918
1005
  metric_metadata=metric_metadata_input,
919
1006
  ),
920
1007
  session=session,
@@ -1002,32 +1089,34 @@ async def deploy_cube_node_from_spec(
1002
1089
  refresh_materialization=True,
1003
1090
  cache=cache,
1004
1091
  )
1005
- return await Node.get_by_name( # type: ignore
1006
- session,
1007
- node_spec.rendered_name,
1008
- options=NodeOutput.load_options(),
1009
- raise_if_not_exists=True,
1092
+ else:
1093
+ logger.info("Creating cube node %s", node_spec.rendered_name)
1094
+ await create_a_cube(
1095
+ data=CreateCubeNode(
1096
+ name=node_spec.rendered_name,
1097
+ display_name=node_spec.display_name,
1098
+ description=node_spec.description,
1099
+ mode=node_spec.mode,
1100
+ custom_metadata=node_spec.custom_metadata,
1101
+ owners=node_spec.owners,
1102
+ metrics=node_spec.rendered_metrics,
1103
+ dimensions=node_spec.rendered_dimensions,
1104
+ filters=node_spec.rendered_filters,
1105
+ ),
1106
+ request=request,
1107
+ session=session,
1108
+ current_user=current_user,
1109
+ query_service_client=query_service_client,
1110
+ background_tasks=background_tasks,
1111
+ validate_access=validate_access,
1112
+ save_history=save_history,
1010
1113
  )
1011
- logger.info("Creating cube node %s", node_spec.rendered_name)
1012
- return await create_a_cube(
1013
- data=CreateCubeNode(
1014
- name=node_spec.rendered_name,
1015
- display_name=node_spec.display_name,
1016
- description=node_spec.description,
1017
- mode=node_spec.mode,
1018
- custom_metadata=node_spec.custom_metadata,
1019
- owners=node_spec.owners,
1020
- metrics=node_spec.rendered_metrics,
1021
- dimensions=node_spec.rendered_dimensions,
1022
- filters=node_spec.rendered_filters,
1023
- ),
1024
- request=request,
1025
- session=session,
1026
- current_user=current_user,
1027
- query_service_client=query_service_client,
1028
- background_tasks=background_tasks,
1029
- validate_access=validate_access,
1030
- save_history=save_history,
1114
+
1115
+ return await Node.get_by_name( # type: ignore
1116
+ session,
1117
+ node_spec.rendered_name,
1118
+ options=NodeOutput.load_options(),
1119
+ raise_if_not_exists=True,
1031
1120
  )
1032
1121
 
1033
1122
 
@@ -1037,7 +1126,10 @@ async def deploy_dimension_link_from_spec(
1037
1126
  request: Request,
1038
1127
  current_username: str,
1039
1128
  save_history: Callable,
1040
- existing_node_links: dict[str, DimensionJoinLinkSpec | DimensionReferenceLinkSpec],
1129
+ existing_node_links: dict[
1130
+ tuple[str, str | None],
1131
+ DimensionJoinLinkSpec | DimensionReferenceLinkSpec,
1132
+ ],
1041
1133
  ) -> DeploymentResult:
1042
1134
  try:
1043
1135
  link_name = f"{node_spec.rendered_name} -> {link_spec.rendered_dimension_node}"
@@ -1052,36 +1144,46 @@ async def deploy_dimension_link_from_spec(
1052
1144
  await User.get_by_username(session, current_username),
1053
1145
  )
1054
1146
  if link_spec.type == LinkType.JOIN:
1147
+ existing = existing_node_links.get(link_spec.rendered_dimension_node)
1055
1148
  join_link = cast(DimensionJoinLinkSpec, link_spec)
1056
- if join_link.node_column:
1057
- await upsert_simple_dimension_link( # pragma: no cover
1058
- session,
1059
- node_spec.rendered_name,
1060
- join_link.rendered_dimension_node,
1061
- join_link.node_column,
1062
- None,
1063
- current_user,
1064
- save_history,
1149
+ if existing != join_link:
1150
+ if join_link.node_column:
1151
+ await upsert_simple_dimension_link( # pragma: no cover
1152
+ session,
1153
+ node_spec.rendered_name,
1154
+ join_link.rendered_dimension_node,
1155
+ join_link.node_column,
1156
+ None,
1157
+ current_user,
1158
+ save_history,
1159
+ )
1160
+ else:
1161
+ link_input = JoinLinkInput(
1162
+ dimension_node=join_link.rendered_dimension_node,
1163
+ join_type=join_link.join_type,
1164
+ join_on=join_link.rendered_join_on,
1165
+ role=join_link.role,
1166
+ )
1167
+ await upsert_complex_dimension_link(
1168
+ session,
1169
+ node_spec.rendered_name,
1170
+ link_input,
1171
+ current_user,
1172
+ save_history,
1173
+ )
1174
+ return DeploymentResult(
1175
+ deploy_type=DeploymentResult.Type.LINK,
1176
+ operation=operation,
1177
+ name=link_name,
1178
+ status=DeploymentResult.Status.SUCCESS,
1179
+ message="Join link successfully deployed",
1065
1180
  )
1066
- link_input = JoinLinkInput(
1067
- dimension_node=join_link.rendered_dimension_node,
1068
- join_type=join_link.join_type,
1069
- join_on=join_link.rendered_join_on,
1070
- role=join_link.role,
1071
- )
1072
- await upsert_complex_dimension_link(
1073
- session,
1074
- node_spec.rendered_name,
1075
- link_input,
1076
- current_user,
1077
- save_history,
1078
- )
1079
1181
  return DeploymentResult(
1080
1182
  deploy_type=DeploymentResult.Type.LINK,
1081
1183
  operation=operation,
1082
1184
  name=link_name,
1083
- status=DeploymentResult.Status.SUCCESS,
1084
- message="Join link successfully deployed",
1185
+ status=DeploymentResult.Status.SKIPPED,
1186
+ message="No change to dimension link",
1085
1187
  )
1086
1188
  else:
1087
1189
  reference_link = cast(DimensionReferenceLinkSpec, link_spec)
@@ -1137,7 +1137,12 @@ def has_minor_changes(
1137
1137
  Whether the node has minor changes
1138
1138
  """
1139
1139
  return (
1140
- (data and data.description and old_revision.description != data.description)
1140
+ (
1141
+ data
1142
+ and data.description
1143
+ and old_revision.description
1144
+ and (old_revision.description != data.description)
1145
+ )
1141
1146
  or (data and data.mode and old_revision.mode != data.mode)
1142
1147
  or (
1143
1148
  data
@@ -1595,7 +1600,7 @@ async def create_new_revision_from_existing(
1595
1600
  )
1596
1601
  or (
1597
1602
  data
1598
- and data.custom_metadata
1603
+ and data.custom_metadata is not None
1599
1604
  and old_revision.custom_metadata != data.custom_metadata
1600
1605
  )
1601
1606
  )
@@ -1621,7 +1626,7 @@ async def create_new_revision_from_existing(
1621
1626
  )
1622
1627
  required_dim_changes = (
1623
1628
  data
1624
- and data.required_dimensions
1629
+ and isinstance(data.required_dimensions, list)
1625
1630
  and {col.name for col in old_revision.required_dimensions}
1626
1631
  != set(data.required_dimensions)
1627
1632
  )
@@ -1692,10 +1697,10 @@ async def create_new_revision_from_existing(
1692
1697
  created_by_id=current_user.id,
1693
1698
  custom_metadata=old_revision.custom_metadata,
1694
1699
  )
1695
- if data and data.required_dimensions: # type: ignore
1700
+ if data and data.required_dimensions is not None: # type: ignore
1696
1701
  new_revision.required_dimensions = data.required_dimensions # type: ignore
1697
1702
 
1698
- if data and data.custom_metadata: # type: ignore
1703
+ if data and data.custom_metadata is not None: # type: ignore
1699
1704
  new_revision.custom_metadata = data.custom_metadata # type: ignore
1700
1705
 
1701
1706
  # Link the new revision to its parents if a new revision was created and update its status
@@ -58,14 +58,13 @@ class ColumnSpec(BaseModel):
58
58
  def __eq__(self, other: Any) -> bool:
59
59
  if not isinstance(other, ColumnSpec):
60
60
  return False
61
-
62
61
  return (
63
62
  self.name == other.name
64
63
  and self.type == other.type
65
64
  and (self.display_name == other.display_name or self.display_name is None)
66
- and (self.description == other.description or self.description is None)
65
+ and self.description == other.description
67
66
  and set(self.attributes) == set(other.attributes)
68
- and (self.partition == other.partition) or (self.partition is None and other.partition is None)
67
+ and self.partition == other.partition
69
68
  )
70
69
 
71
70
 
@@ -126,15 +125,14 @@ class DimensionJoinLinkSpec(DimensionLinkSpec):
126
125
  )
127
126
 
128
127
  def __eq__(self, other: Any) -> bool:
128
+ if not isinstance(other, DimensionJoinLinkSpec):
129
+ return False
129
130
  return (
130
131
  super().__eq__(other)
131
132
  and self.rendered_dimension_node == other.rendered_dimension_node
132
133
  and self.join_type == other.join_type
133
134
  and self.rendered_join_on == other.rendered_join_on
134
- and (
135
- self.node_column == other.node_column
136
- or (self.node_column is None and other.node_column is None)
137
- )
135
+ and self.node_column == other.node_column
138
136
  )
139
137
 
140
138
 
@@ -233,7 +231,7 @@ class NodeSpec(BaseModel):
233
231
 
234
232
  def __eq__(self, other: Any) -> bool:
235
233
  if not isinstance(other, NodeSpec):
236
- return False
234
+ return False # pragma: no cover
237
235
  return (
238
236
  self.rendered_name == other.rendered_name
239
237
  and self.node_type == other.node_type
@@ -242,11 +240,17 @@ class NodeSpec(BaseModel):
242
240
  and set(self.owners) == set(other.owners)
243
241
  and set(self.tags) == set(other.tags)
244
242
  and self.mode == other.mode
245
- and (
246
- self.custom_metadata == other.custom_metadata
247
- or self.custom_metadata is None
248
- and other.custom_metadata == {}
249
- )
243
+ and eq_or_fallback(self.custom_metadata, other.custom_metadata, {})
244
+ )
245
+
246
+ def diff(self, other: "NodeSpec") -> list[str]:
247
+ """
248
+ Return a list of fields that differ between this and another NodeSpec.
249
+ """
250
+ return diff(
251
+ self,
252
+ other,
253
+ ignore_fields=["name", "namespace", "query", "columns"],
250
254
  )
251
255
 
252
256
 
@@ -278,27 +282,24 @@ class LinkableNodeSpec(NodeSpec):
278
282
  return value
279
283
 
280
284
  def __eq__(self, other: Any) -> bool:
285
+ if not isinstance(other, LinkableNodeSpec):
286
+ return False
287
+ dimension_links_equal = sorted(
288
+ self.dimension_links or [],
289
+ key=lambda link: link.rendered_dimension_node,
290
+ ) == sorted(
291
+ other.dimension_links or [],
292
+ key=lambda link: link.rendered_dimension_node,
293
+ )
294
+ print(
295
+ "Comparing LinkableNodeSpec",
296
+ self.rendered_name,
297
+ eq_columns(self.columns, other.columns),
298
+ )
281
299
  return (
282
300
  super().__eq__(other)
283
- and (
284
- (self.columns or []) == (other.columns or [])
285
- or (
286
- self.columns is None
287
- and not any(
288
- [attr for attr in col.attributes if attr != "primary_key"]
289
- for col in other.columns
290
- )
291
- and not any(col.partition for col in other.columns)
292
- )
293
- )
294
- and sorted(
295
- self.dimension_links or [],
296
- key=lambda link: link.rendered_dimension_node,
297
- )
298
- == sorted(
299
- other.dimension_links or [],
300
- key=lambda link: link.rendered_dimension_node,
301
- )
301
+ and eq_columns(self.columns, other.columns)
302
+ and dimension_links_equal
302
303
  )
303
304
 
304
305
 
@@ -368,6 +369,18 @@ class MetricSpec(NodeSpec):
368
369
  min_decimal_exponent: int | None
369
370
  max_decimal_exponent: int | None
370
371
 
372
+ def __init__(self, **data: Any):
373
+ unit = data.pop("unit", None)
374
+ if unit:
375
+ try:
376
+ if isinstance(unit, MetricUnit):
377
+ data["unit_enum"] = unit
378
+ else:
379
+ data["unit_enum"] = MetricUnit[unit.strip().upper()]
380
+ except KeyError: # pragma: no cover
381
+ raise DJInvalidInputException(f"Invalid metric unit: {unit}")
382
+ super().__init__(**data)
383
+
371
384
  @property
372
385
  def unit(self) -> str | None:
373
386
  """Return lowercased unit name for JSON serialization."""
@@ -387,11 +400,11 @@ class MetricSpec(NodeSpec):
387
400
  super().__eq__(other)
388
401
  and self.query_ast.compare(other.query_ast)
389
402
  and (self.required_dimensions or []) == (other.required_dimensions or [])
390
- and (self.direction == other.direction) or (self.direction is None and other.direction is None)
391
- and (self.unit == other.unit) or (self.unit is None and other.unit is None)
392
- and (self.significant_digits == other.significant_digits) or (self.significant_digits is None and other.significant_digits is None)
393
- and (self.min_decimal_exponent == other.min_decimal_exponent) or (self.min_decimal_exponent is None and other.min_decimal_exponent is None)
394
- and (self.max_decimal_exponent == other.max_decimal_exponent) or (self.max_decimal_exponent is None and other.max_decimal_exponent is None)
403
+ and eq_or_fallback(self.direction, other.direction, MetricDirection.NEUTRAL)
404
+ and eq_or_fallback(self.unit, other.unit, MetricUnit.UNKNOWN.value.name)
405
+ and self.significant_digits == other.significant_digits
406
+ and self.min_decimal_exponent == other.min_decimal_exponent
407
+ and self.max_decimal_exponent == other.max_decimal_exponent
395
408
  )
396
409
 
397
410
 
@@ -421,32 +434,14 @@ class CubeSpec(NodeSpec):
421
434
  ]
422
435
 
423
436
  def __eq__(self, other: Any) -> bool:
424
- print("!!!Comparing cubes", self.rendered_name, other.rendered_name,
425
- "super:", super().__eq__(other),
426
- "columns:", (self.columns or []) == (other.columns or []),
427
- "othercol", ([
428
- [attr for attr in col.attributes if attr != "primary_key"]
429
- for col in other.columns
430
- ],
431
- not any(col.partition for col in other.columns)
432
- ),
433
- "metrics:", set(self.rendered_metrics) == set(other.rendered_metrics),
434
- "dimensions:", set(self.rendered_dimensions) == set(other.rendered_dimensions),
435
- "filters:", (self.rendered_filters or []) == (other.rendered_filters or [])
437
+ print(
438
+ "Comparing CubeSpec",
439
+ self.rendered_name,
440
+ eq_columns(self.columns, other.columns),
436
441
  )
437
442
  return (
438
443
  super().__eq__(other)
439
- and (
440
- (self.columns or []) == (other.columns or [])
441
- or (
442
- not self.columns
443
- and not any(
444
- [attr for attr in col.attributes if attr != "primary_key"]
445
- for col in other.columns
446
- )
447
- and not any(col.partition for col in other.columns)
448
- )
449
- )
444
+ and eq_columns(self.columns, other.columns)
450
445
  and set(self.rendered_metrics) == set(other.rendered_metrics)
451
446
  and set(self.rendered_dimensions) == set(other.rendered_dimensions)
452
447
  and (self.rendered_filters or []) == (other.rendered_filters or [])
@@ -462,6 +457,30 @@ NodeUnion = Union[
462
457
  ]
463
458
 
464
459
 
460
+ def diff(one: BaseModel, two: BaseModel, ignore_fields: list[str] = None) -> list[str]:
461
+ """
462
+ Compare two Pydantic models and return a list of fields that have changed.
463
+ """
464
+ changed_fields = [
465
+ field
466
+ for field in one.__fields__.keys()
467
+ if field not in (ignore_fields or [])
468
+ and hasattr(one, field)
469
+ and hasattr(two, field)
470
+ and (
471
+ (
472
+ isinstance(getattr(one, field), (list, dict))
473
+ and set(getattr(one, field) or []) != set(getattr(two, field) or [])
474
+ )
475
+ or (
476
+ not isinstance(getattr(one, field), (list, dict))
477
+ and getattr(one, field) != getattr(two, field)
478
+ )
479
+ )
480
+ ]
481
+ return changed_fields
482
+
483
+
465
484
  class DeploymentSpec(BaseModel):
466
485
  """
467
486
  Specification of a full deployment (namespace, nodes, tags, and add'l metadata).
@@ -541,3 +560,40 @@ class DeploymentInfo(BaseModel):
541
560
  namespace: str
542
561
  status: DeploymentStatus
543
562
  results: list[DeploymentResult] = Field(default_factory=list)
563
+
564
+
565
+ def eq_or_fallback(a, b, fallback):
566
+ """
567
+ Helper to compare two values that may be None, with a fallback value
568
+ """
569
+ return a == b or (a is None and b == fallback)
570
+
571
+
572
+ def eq_columns(a: list[ColumnSpec] | None, b: list[ColumnSpec] | None) -> bool:
573
+ """
574
+ Compare two lists of ColumnSpec objects (or None) with special rules:
575
+ - None or [] is considered equivalent to a list where every column only has 'primary_key'
576
+ in attributes and partition is None.
577
+ """
578
+ a_list = a or []
579
+ b_list = b or []
580
+
581
+ a_map = {col.name: col for col in a_list}
582
+ b_map = {col.name: col for col in b_list}
583
+ for col_name, col_a in a_map.items():
584
+ col_b = b_map.get(col_name)
585
+ if (set(col_a.attributes if col_a else []) - {"primary_key"}) != ( # type: ignore
586
+ set(col_b.attributes if col_b else []) - {"primary_key"} # type: ignore
587
+ ) or (col_a.partition if col_a else None) != (
588
+ col_b.partition if col_b else None
589
+ ): # type: ignore
590
+ return False
591
+ for col_name, col_b in b_map.items():
592
+ col_a = a_map.get(col_name) # type: ignore
593
+ if (set(col_b.attributes if col_b else []) - {"primary_key"}) != ( # type: ignore
594
+ set(col_a.attributes if col_a else []) - {"primary_key"} # type: ignore
595
+ ) or (col_b.partition if col_b else None) != (
596
+ col_a.partition if col_a else None
597
+ ): # type: ignore
598
+ return False
599
+ return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: datajunction-server
3
- Version: 0.0.2.dev2
3
+ Version: 0.0.2.dev4
4
4
  Summary: DataJunction server library for running to a DataJunction server
5
5
  Project-URL: Homepage, https://datajunction.io
6
6
  Project-URL: Repository, https://github.com/DataJunction/dj
@@ -1,4 +1,4 @@
1
- datajunction_server/__about__.py,sha256=kVl_34fkA7KY3Y1J_D8v00tuvI2dTjfSCc1549SHP7g,54
1
+ datajunction_server/__about__.py,sha256=7_8nP1iwEx_vfBpO-HcgSpLMpKEmeBk1LgGxMXjgACM,54
2
2
  datajunction_server/__init__.py,sha256=nN5-uJoSVEwuc8n-wMygqeF0Xhxi_zqqbCgutZvAt3E,384
3
3
  datajunction_server/alembic.ini,sha256=mclJ_xx8pHfRyZ69SA9ZPqUwZaaQCTyxZ6wBmbrf1bo,3024
4
4
  datajunction_server/config.py,sha256=L1zkaiF82S-ciR-wVeILx7CWKSOPPJ90a9zooXVNHEc,5641
@@ -44,8 +44,8 @@ datajunction_server/alembic/versions/2025_07_06_0746-634fdac051c3_unique_contrai
44
44
  datajunction_server/alembic/versions/2025_08_02_1543-8eab64955a49_add_concrete_measures_for_node_revisions.py,sha256=wN7UtCLjrXsMyDIb1DKJv7p0QULBSQGA0gILgSn-CXw,2394
45
45
  datajunction_server/alembic/versions/2025_09_02_0102-5b00137c69f9_add_service_accounts.py,sha256=rv9j0VDoi3bQNTjcPFphwAsvQMOmL2j35PlcYiGKYJM,2055
46
46
  datajunction_server/alembic/versions/2025_09_12_0555-759c4d50cb8d_add_cascade_delete.py,sha256=hULra07uoqeUVLKmeOXiznx7FKPBowvOn8e3EWMb8Xo,7236
47
- datajunction_server/alembic/versions/2025_09_12_2107-b6398ba852b3_deployments.py,sha256=KEH9L0VPs-x4E579MF4CDhBoyB7FKYrFvr_W_7oVE84,1421
48
- datajunction_server/alembic/versions/2025_09_16_1513-b55add7e1ebc_add_cascade_delete_on_tag_node_and_.py,sha256=nRFhu3VMptDjdPqMei5RzfncNeu9foFPyb7hVZxo6Hc,3317
47
+ datajunction_server/alembic/versions/2025_09_16_1513-b55add7e1ebc_add_cascade_delete_on_tag_node_and_.py,sha256=06vQjjaskVQI1EaztJ_jNUObseRWWuLzWqQymZiEKRA,3316
48
+ datajunction_server/alembic/versions/2025_09_17_2107-b6398ba852b3_deployments.py,sha256=NfPEHgvrSsxfCotC_fVKv-A-g5PH5QaLtAEhszqvFVk,1421
49
49
  datajunction_server/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
50
  datajunction_server/api/attributes.py,sha256=zSTHqdf1el7pWX76Eu-sPrt6uzWFwGN0mXiNJFz9vBE,4553
51
51
  datajunction_server/api/catalogs.py,sha256=D-avfvhUdgTAnOvMSxMN2I8dXebDuovEZr-dQSB-nk8,4454
@@ -65,7 +65,7 @@ datajunction_server/api/main.py,sha256=s1gWNs5wGXzCtlqf4faeq-rE6uf_RLn4O7WJnauWr
65
65
  datajunction_server/api/materializations.py,sha256=buhYWTUGBbRI0fWvREaK1D1F809y5eSxHDWqTQtBewc,21796
66
66
  datajunction_server/api/measures.py,sha256=MdNpB4iNqEcU5-hWno5X4QnMTbijc2ISrprS8nyY2WQ,5729
67
67
  datajunction_server/api/metrics.py,sha256=X5By7oNWa9b0l65Qf8Bcz--aClngc30y4G3PMdJhTCs,4917
68
- datajunction_server/api/namespaces.py,sha256=S-j5JNWzwKh52SgML6pTbgF-dbFzPUvn1r9x3oPWIhw,14849
68
+ datajunction_server/api/namespaces.py,sha256=220PNifdq_DShw9I3OH-VNDd4zJtP54OYRXUTdzSUfA,14781
69
69
  datajunction_server/api/nodes.py,sha256=0nOPEKD0OQ8Tq5FLElKI2ODnXRZS25jM9yUywURKfsc,43728
70
70
  datajunction_server/api/notifications.py,sha256=0jHnWD6leBxazVgZtfTlL1e8-sZ3Y2PmU8FI-TWepkU,5403
71
71
  datajunction_server/api/setup_logging.py,sha256=_1LlR2KMqjjBff1gqJ8Kmyvza1dAYjzGGK6kak1GdcU,174
@@ -128,7 +128,7 @@ datajunction_server/database/materialization.py,sha256=7_w5ay2RXukTaRS03UILsMFrN
128
128
  datajunction_server/database/measure.py,sha256=iwZQIhM-_OlZ4v2a3BmZrhpC4ej-MGh6rLqGB70XGYo,6387
129
129
  datajunction_server/database/metricmetadata.py,sha256=jKh58lOf5_MoFyDTVtpWhK9-Ox22ryWK2XtBKjPApc4,2084
130
130
  datajunction_server/database/namespace.py,sha256=T5GF4eDLgw51xdjWC0T5G_mcAgL4E7OrYuk6x552dhM,5685
131
- datajunction_server/database/node.py,sha256=QkGn5_HMMnotgKETLMZlCqyyHn6TXc_67jXgQb8pq_w,44642
131
+ datajunction_server/database/node.py,sha256=4BJPUaoYQGz-CtBHPcSB8-I1S4yzkd8-Q2NOvRAtYpk,44951
132
132
  datajunction_server/database/nodeowner.py,sha256=YydIO1F-S-J6qyNW8ui71H-l6aENbW7RhdX5koIBq8E,1196
133
133
  datajunction_server/database/notification_preference.py,sha256=uLy60fWLWLJE940Qj7bY0Rq5b5flEkgcq2uxx4pRvPE,1892
134
134
  datajunction_server/database/partition.py,sha256=312KyT0IbaKk-lXxMRvAphJDfA83Wm1RsGp7-dT-U0g,5242
@@ -138,12 +138,12 @@ datajunction_server/database/user.py,sha256=-zW5ssf2IWgSjvZbOljOzVoqynBtNp3gQxHm
138
138
  datajunction_server/internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
139
139
  datajunction_server/internal/client.py,sha256=hpManY1PTGQxAzx4S-1aUAoMvlWfPPU1wgL9iLlAg7o,11442
140
140
  datajunction_server/internal/cube_materializations.py,sha256=SJYIZfjAQr8n-pgECKoroGfOhoR_J0vnMmpCQ0mbLlY,10695
141
- datajunction_server/internal/deployment.py,sha256=rjGFpGJeNHQYSGt97BVUYWU9B9ucOU7ecOfltXDYLgU,40664
141
+ datajunction_server/internal/deployment.py,sha256=N9p5kVllXC61yLX6JXiXRV2P_mjeIt-4gO8fWXm_cVE,45154
142
142
  datajunction_server/internal/engines.py,sha256=RKIYIYJQMjRIFS7YXIW-q6vLUxilc3sd3eWHwTmDDu0,858
143
143
  datajunction_server/internal/history.py,sha256=MAj2ZTUCGgSLCaS1uB5dWtTqvb2SE_5HHLo0RReCRAc,816
144
144
  datajunction_server/internal/materializations.py,sha256=HVc1RPiarOBksvzyLP8Mt5GFvyEPlOqgIe151I_ZZMY,18184
145
145
  datajunction_server/internal/namespaces.py,sha256=Ff020CvpzWeypqReU5sPl3kNbHj1aQpw_6MI2DJhFSw,20713
146
- datajunction_server/internal/nodes.py,sha256=dgK54ybgLkr5ZBNLi_jztrdOSYAhUvsj-SNi-lYVU_w,103572
146
+ datajunction_server/internal/nodes.py,sha256=BbXzMg5G2Tk-xXcBD73UhWR7MQh3B_UeZ9r83LkK8tg,103715
147
147
  datajunction_server/internal/notifications.py,sha256=8aP8KBs8k4kKXnQXIUPWyHa4uKsyV0oERipyP0LR0bs,1724
148
148
  datajunction_server/internal/seed.py,sha256=mc2TQP4dMwAyWTfGKj5b6wK6pvVyU6azHfxSozPElY0,2363
149
149
  datajunction_server/internal/sql.py,sha256=bboANhvtKufVYOOI6RF719GXQFWv-toVXIQQtKIFCtA,18475
@@ -185,7 +185,7 @@ datajunction_server/models/column.py,sha256=3TCa9dDAb9Q2WEAzpcdqAjKSX3PutbwlS8qG
185
185
  datajunction_server/models/cube.py,sha256=p6KmqoOVGlziH5k0wXs6qBvgWWQs984Ho6SGplDbtAQ,4459
186
186
  datajunction_server/models/cube_materialization.py,sha256=ydMRepDM95b4BBVay_nOYlRtK7OFG3pCDDTRdHe2awU,15787
187
187
  datajunction_server/models/database.py,sha256=xhCllbq5ikFNnrPvzuchxQUC9RWogzh73Eqz7Jj0i38,499
188
- datajunction_server/models/deployment.py,sha256=AgVd2o1dSYtWzpqR3R61-Hja0OVWk_sExh408Gx4iOo,16631
188
+ datajunction_server/models/deployment.py,sha256=ZCRgGVy_FLHeoLc98-zRz_n79IZAPsr073Y8k4SOhqA,18051
189
189
  datajunction_server/models/dialect.py,sha256=uifriawtKR4vi-GG2rdp4eeQkAkD6iLNuarCLQanTxY,3825
190
190
  datajunction_server/models/dimensionlink.py,sha256=WaCarxhawOiQqtH1EVS0RQRu9D8Bx6GFonLsdSVgXSs,1551
191
191
  datajunction_server/models/engine.py,sha256=Ebuy4HLkURr7mj0pxTj_Y4fEmF0oDen393rnj9fBnR0,466
@@ -226,7 +226,7 @@ datajunction_server/sql/parsing/backends/grammar/generated/SqlBaseParser.py,sha2
226
226
  datajunction_server/sql/parsing/backends/grammar/generated/SqlBaseParser.tokens,sha256=JDrzbaKDwIaimAZPYIUzCgzkOEgq0X5-a6_lz78lqgs,8131
227
227
  datajunction_server/sql/parsing/backends/grammar/generated/SqlBaseParserListener.py,sha256=vp8wduYkB-T5Xr6HZCSdzAxTHHPrDI5UJZXRJSVhAGA,102464
228
228
  datajunction_server/sql/parsing/backends/grammar/generated/SqlBaseParserVisitor.py,sha256=w3V03LgPIHCiqojNyuekBDYqskjOKlKrd0sczQAB_WQ,60290
229
- datajunction_server-0.0.2.dev2.dist-info/METADATA,sha256=1nsSX_VKvu5SkHpIuXNFyBudwMZRwU_1mF6AuDaTFhc,3769
230
- datajunction_server-0.0.2.dev2.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
231
- datajunction_server-0.0.2.dev2.dist-info/entry_points.txt,sha256=MOInJGdcQ10bDEl-XW4UMokEgx-ypINqBhObeDI8KiQ,74
232
- datajunction_server-0.0.2.dev2.dist-info/RECORD,,
229
+ datajunction_server-0.0.2.dev4.dist-info/METADATA,sha256=h0bsPkDT6voeOPRcczUx6yrmvivISRd09p51Kq40Qq4,3769
230
+ datajunction_server-0.0.2.dev4.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
231
+ datajunction_server-0.0.2.dev4.dist-info/entry_points.txt,sha256=MOInJGdcQ10bDEl-XW4UMokEgx-ypINqBhObeDI8KiQ,74
232
+ datajunction_server-0.0.2.dev4.dist-info/RECORD,,