From 4aaba95148318749046f779d707967ecf71d06c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:14:28 +0100 Subject: [PATCH 1/6] Fix star in data path (#4180) * Fix star in data path that were broken --- .../data_providers/data_provider_types.py | 20 ++++-- .../data_providers/data_provider_types.py | 22 ++++-- .../local_baserow/service_types.py | 25 ++----- .../src/baserow/core/services/registries.py | 10 +-- .../test_data_provider_types.py | 67 +++++++++++++++++++ .../modules/builder/dataProviderTypes.js | 51 +++++++++----- web-frontend/modules/builder/elementTypes.js | 29 +++++--- .../modules/builder/workflowActionTypes.js | 8 +-- web-frontend/modules/core/serviceTypes.js | 5 +- .../modules/core/workflowActionTypes.js | 5 +- .../integrations/localBaserow/serviceTypes.js | 12 ++-- 11 files changed, 171 insertions(+), 83 deletions(-) diff --git a/backend/src/baserow/contrib/automation/data_providers/data_provider_types.py b/backend/src/baserow/contrib/automation/data_providers/data_provider_types.py index 15de4d6f46..2793e78715 100644 --- a/backend/src/baserow/contrib/automation/data_providers/data_provider_types.py +++ b/backend/src/baserow/contrib/automation/data_providers/data_provider_types.py @@ -37,17 +37,27 @@ def get_data_chunk( previous_node_results = dispatch_context.previous_nodes_results[ int(previous_node.id) ] - if previous_node.service.get_type().returns_list: - previous_node_results = previous_node_results["results"] except KeyError as exc: message = ( "The previous node id is not present in the dispatch context results" ) raise InvalidFormulaContext(message) from exc + + service = previous_node.service.specific + + if service.get_type().returns_list: + previous_node_results = previous_node_results["results"] + if len(rest) >= 2: + prepared_path = [ + rest[0], + *service.get_type().prepare_value_path(service, rest[1:]), + ] + else: + prepared_path = rest else: - return previous_node.service.get_type().get_value_at_path( - previous_node.service.specific, previous_node_results, rest - ) + prepared_path = service.get_type().prepare_value_path(service, rest) + + return get_value_at_path(previous_node_results, prepared_path) def import_path(self, path, id_mapping, **kwargs): """ diff --git a/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py b/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py index 25722ac04f..c79f419005 100644 --- a/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py +++ b/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py @@ -177,12 +177,21 @@ def get_data_chunk(self, dispatch_context: BuilderDispatchContext, path: List[st data_source, dispatch_context ) - if data_source.service.get_type().returns_list: + service = data_source.service.specific + + if service.get_type().returns_list: dispatch_result = dispatch_result["results"] + if len(rest) >= 2: + prepared_path = [ + rest[0], + *service.get_type().prepare_value_path(service, rest[1:]), + ] + else: + prepared_path = rest + else: + prepared_path = service.get_type().prepare_value_path(service, rest) - return data_source.service.get_type().get_value_at_path( - data_source.service.specific, dispatch_result, rest - ) + return get_value_at_path(dispatch_result, prepared_path) def import_path(self, path, id_mapping, **kwargs): """ @@ -484,9 +493,10 @@ def get_data_chunk(self, dispatch_context: DispatchContext, path: List[str]): cache_key = self.get_dispatch_action_cache_key( dispatch_id, workflow_action.id ) - return workflow_action.service.get_type().get_value_at_path( - workflow_action.service.specific, cache.get(cache_key), rest + prepared_path = workflow_action.service.get_type().prepare_value_path( + workflow_action.service.specific, rest ) + return get_value_at_path(cache.get(cache_key), prepared_path) else: # Frontend actions return get_value_at_path(previous_action_results[previous_action_id], rest) diff --git a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py index 74c58fbb05..5739760cc3 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py @@ -225,21 +225,11 @@ def sanitize_result(self, service, result, allowed_field_names): return super().sanitize_result(service, result, allowed_field_names) - def get_value_at_path(self, service: Service, context: Any, path: List[str]): - """ - Convert the field name to a human name. - """ - - if self.returns_list: - if len(path) < 2: - return super().get_value_at_path(service, context, path) - - row_index, db_column, *rest = path - else: - if len(path) < 1: - return super().get_value_at_path(service, context, path) + def prepare_value_path(self, service: Service, path: List[str]): + if len(path) < 1: + return path - db_column, *rest = path + db_column, *rest = path human_name = db_column @@ -248,12 +238,7 @@ def get_value_at_path(self, service: Service, context: Any, path: List[str]): human_name = field_obj["field"].name break - if self.returns_list: - return super().get_value_at_path( - service, context, [row_index, human_name, *rest] - ) - else: - return super().get_value_at_path(service, context, [human_name, *rest]) + return [human_name, *rest] def build_queryset( self, diff --git a/backend/src/baserow/core/services/registries.py b/backend/src/baserow/core/services/registries.py index d5605a1e9c..abb84cdace 100644 --- a/backend/src/baserow/core/services/registries.py +++ b/backend/src/baserow/core/services/registries.py @@ -33,7 +33,6 @@ ) from baserow.core.services.dispatch_context import DispatchContext from baserow.core.services.types import DispatchResult, FormulaToResolve -from baserow.core.utils import get_value_at_path from .exceptions import ( DispatchException, @@ -312,13 +311,8 @@ def resolve_service_formulas( return resolved_values - def get_value_at_path(self, service: Service, context: Any, path: List[str]): - """ - Offers the opportunity to hook into way data are extracted from the context for - a given path. - """ - - return get_value_at_path(context, path) + def prepare_value_path(self, service: Service, path: List[str]): + return path def dispatch_transform( self, diff --git a/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py b/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py index 20df48281f..49827c23ad 100644 --- a/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py +++ b/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py @@ -197,6 +197,73 @@ def test_data_source_data_provider_get_data_chunk(data_fixture): ) +@pytest.mark.django_db +def test_data_source_data_provider_get_data_chunk_with_list_data_source(data_fixture): + user = data_fixture.create_user() + table, fields, rows = data_fixture.build_table( + user=user, + columns=[ + ("Name", "text"), + ("My Color", "text"), + ], + rows=[ + ["BMW", "Blue"], + ["Audi", "Orange"], + ["Volkswagen", "White"], + ["Volkswagen", "Green"], + ], + ) + builder = data_fixture.create_builder_application(user=user) + integration = data_fixture.create_local_baserow_integration( + user=user, application=builder + ) + page = data_fixture.create_builder_page(user=user, builder=builder) + data_source = data_fixture.create_builder_local_baserow_list_rows_data_source( + user=user, + page=page, + integration=integration, + table=table, + name="Items", + ) + + data_source_provider = DataSourceDataProviderType() + + dispatch_context = BuilderDispatchContext( + HttpRequest(), page, only_expose_public_allowed_properties=False + ) + + assert ( + data_source_provider.get_data_chunk( + dispatch_context, [data_source.id, "0", fields[1].db_column] + ) + == "Blue" + ) + + dispatch_context.reset_call_stack() + + assert ( + data_source_provider.get_data_chunk( + dispatch_context, [data_source.id, "2", fields[1].db_column] + ) + == "White" + ) + + dispatch_context.reset_call_stack() + + assert ( + data_source_provider.get_data_chunk( + dispatch_context, [data_source.id, "0", "id"] + ) + == rows[0].id + ) + + dispatch_context.reset_call_stack() + + assert data_source_provider.get_data_chunk( + dispatch_context, [data_source.id, "*", fields[1].db_column] + ) == ["Blue", "Orange", "White", "Green"] + + @pytest.mark.django_db def test_data_source_data_provider_get_data_chunk_with_formula(data_fixture): user = data_fixture.create_user() diff --git a/web-frontend/modules/builder/dataProviderTypes.js b/web-frontend/modules/builder/dataProviderTypes.js index f4f9b3a927..13341f973a 100644 --- a/web-frontend/modules/builder/dataProviderTypes.js +++ b/web-frontend/modules/builder/dataProviderTypes.js @@ -120,23 +120,32 @@ export class DataSourceDataProviderType extends DataProviderType { 'dataSource/getPagesDataSourceById' ](pages, parseInt(dataSourceId)) - const rawContent = this.getDataSourceContent(applicationContext, dataSource) + const content = this.getDataSourceContent(applicationContext, dataSource) - const serviceType = this.app.$registry.get('service', dataSource.type) + let preparedPath + + if (!content) { + return null + } - let content = rawContent - let path = rest + const serviceType = this.app.$registry.get('service', dataSource.type) if (serviceType.returnsList) { // if it returns a list let's consume the next path token which is the row - const [row, ...afterRow] = rest - content = getValueAtPath(content, row) - path = afterRow + if (rest.length >= 2) { + const [row, ...afterRow] = rest + preparedPath = [ + row, + ...serviceType.prepareValuePath(dataSource, afterRow), + ] + } else { + preparedPath = rest + } + } else { + preparedPath = serviceType.prepareValuePath(dataSource, rest) } - return content - ? serviceType.getValueAtPath(dataSource, content, path) - : null + return getValueAtPath(content, preparedPath) } getDataSourceContent(applicationContext, dataSource) { @@ -451,10 +460,15 @@ export class CurrentRecordDataProviderType extends DataProviderType { } getDataChunk(applicationContext, path) { - const { element } = applicationContext + const { element, recordIndexPath = [0] } = applicationContext const elementType = this.app.$registry.get('element', element.type) + // Special case for the index + if (path.length === 1 && path[0] === this.indexKey) { + return recordIndexPath.at(-1) + } + return elementType.getElementCurrentContent(applicationContext, path) } @@ -831,13 +845,14 @@ export class PreviousActionDataProviderType extends DataProviderType { workflowAction.type ) - return content[workflowActionId] - ? actionType.getValueAtPath( - workflowAction, - content[workflowActionId], - rest - ) - : null + if (!content[workflowActionId]) { + return null + } + + return getValueAtPath( + content[workflowActionId], + actionType.prepareValuePath(workflowAction, rest) + ) } getWorkflowActionSchema(workflowAction) { diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js index 956ccc965e..dabc7121f8 100644 --- a/web-frontend/modules/builder/elementTypes.js +++ b/web-frontend/modules/builder/elementTypes.js @@ -94,6 +94,7 @@ import elementImageTable from '@baserow/modules/builder/assets/icons/element-tab import elementImageText from '@baserow/modules/builder/assets/icons/element-text.svg' import _ from 'lodash' +import { getValueAtPath } from '../core/utils/object' export class ElementType extends Registerable { get name() { @@ -840,6 +841,7 @@ export class ElementType extends Registerable { mainDataSource.type ) + // Create a path joining the record indexes and the schemaProperties const dataPaths = collectionAncestry .map(({ schema_property: schemaProperty }) => schemaProperty || null) .flatMap((x, i) => @@ -852,20 +854,27 @@ export class ElementType extends Registerable { const contentRows = mainElementType.getElementContentInStore(mainElement) if (fullDataPath.length) { + let preparedPath if (mainDataSourceType.returnsList) { - // directly consume the first path item as it's the row index - // and the getValueAtPath is only able to support property level. - return mainDataSourceType.getValueAtPath( + if (fullDataPath.length >= 2) { + // not include the first path item as it's the row index + // and the prepareValuePath is only able to support property level. + const [row, ...rest] = fullDataPath + preparedPath = [ + row, + ...mainDataSourceType.prepareValuePath(mainDataSource, rest), + ] + } else { + preparedPath = fullDataPath + } + } else { + preparedPath = mainDataSourceType.prepareValuePath( mainDataSource, - contentRows[fullDataPath[0]], - fullDataPath.slice(1) + fullDataPath ) } - return mainDataSourceType.getValueAtPath( - mainDataSource, - contentRows, - fullDataPath - ) + + return getValueAtPath(contentRows, preparedPath) } return contentRows diff --git a/web-frontend/modules/builder/workflowActionTypes.js b/web-frontend/modules/builder/workflowActionTypes.js index 541f2eeea4..dd89b6cd0f 100644 --- a/web-frontend/modules/builder/workflowActionTypes.js +++ b/web-frontend/modules/builder/workflowActionTypes.js @@ -306,12 +306,8 @@ export class WorkflowActionServiceType extends WorkflowActionType { return super.getErrorMessage(workflowAction, applicationContext) } - getValueAtPath(workflowAction, content, path) { - return this.serviceType.getValueAtPath( - workflowAction.service, - content, - path - ) + prepareValuePath(workflowAction, path) { + return this.serviceType.prepareValuePath(workflowAction.service, path) } get serviceType() { diff --git a/web-frontend/modules/core/serviceTypes.js b/web-frontend/modules/core/serviceTypes.js index 01dcb669fc..588d31120d 100644 --- a/web-frontend/modules/core/serviceTypes.js +++ b/web-frontend/modules/core/serviceTypes.js @@ -1,5 +1,4 @@ import { Registerable } from '@baserow/modules/core/registry' -import { getValueAtPath } from '@baserow/modules/core/utils/object' export class ServiceType extends Registerable { get name() { @@ -77,8 +76,8 @@ export class ServiceType extends Registerable { /** * Allow to customize way data are accessed from service */ - getValueAtPath(service, content, path) { - return getValueAtPath(content, path.join('.')) + prepareValuePath(service, path) { + return path } getOrder() { diff --git a/web-frontend/modules/core/workflowActionTypes.js b/web-frontend/modules/core/workflowActionTypes.js index 6a85b73a10..b3ee25228a 100644 --- a/web-frontend/modules/core/workflowActionTypes.js +++ b/web-frontend/modules/core/workflowActionTypes.js @@ -1,5 +1,4 @@ import { Registerable } from '@baserow/modules/core/registry' -import { getValueAtPath } from '@baserow/modules/core/utils/object' export class WorkflowActionType extends Registerable { get form() { @@ -35,8 +34,8 @@ export class WorkflowActionType extends Registerable { /** * Allow to customize way data are accessed from workflow action */ - getValueAtPath(workflowAction, content, path) { - return getValueAtPath(content, path.join('.')) + prepareValuePath(workflowAction, path) { + return path } /** diff --git a/web-frontend/modules/integrations/localBaserow/serviceTypes.js b/web-frontend/modules/integrations/localBaserow/serviceTypes.js index cffec47794..703a6217a0 100644 --- a/web-frontend/modules/integrations/localBaserow/serviceTypes.js +++ b/web-frontend/modules/integrations/localBaserow/serviceTypes.js @@ -15,7 +15,6 @@ import LocalBaserowSignalTriggerServiceForm from '@baserow/modules/integrations/ import LocalBaserowGetRowForm from '@baserow/modules/integrations/localBaserow/components/services/LocalBaserowGetRowForm' import LocalBaserowListRowsForm from '@baserow/modules/integrations/localBaserow/components/services/LocalBaserowListRowsForm' import LocalBaserowAggregateRowsForm from '@baserow/modules/integrations/localBaserow/components/services/LocalBaserowAggregateRowsForm' -import { getValueAtPath } from '@baserow/modules/core/utils/object' export class LocalBaserowTableServiceType extends ServiceType { get integrationType() { @@ -80,13 +79,17 @@ export class LocalBaserowTableServiceType extends ServiceType { return description } - getValueAtPath(service, content, path) { + prepareValuePath(service, path) { + if (path.length < 1) { + return path + } + const schema = this.getDataSchema(service) const [field, ...rest] = path let humanName = field - if (schema && field.startsWith('field_')) { + if (schema && field && field.startsWith('field_')) { if (this.returnsList) { if (schema.items?.properties?.[field]?.title) { humanName = schema.items.properties[field].title @@ -95,7 +98,8 @@ export class LocalBaserowTableServiceType extends ServiceType { humanName = schema.properties[field].title } } - return getValueAtPath(content, [humanName, ...rest].join('.')) + + return [humanName, ...rest] } } From eda3db2a2d4ecf301db0bd891ce2913dc605f26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:57:48 +0100 Subject: [PATCH 2/6] Wab pre 2.0 bug fixes (#4167) * Support raw formulas in the backend * Fix import with operator * Fix new edge addition delay * Fix the double undo necessary for local baserow node creation * Show error on simulate dispatch error * Follow up of AI agent node * Datasource without table were breaking the application * Fix filter value mode * Change ai node name --- .../contrib/automation/formula_importer.py | 21 +- .../contrib/automation/nodes/handler.py | 28 ++- .../contrib/automation/nodes/registries.py | 4 +- .../contrib/automation/nodes/service.py | 13 + .../automation/workflows/graph_handler.py | 6 + .../contrib/automation/workflows/handler.py | 34 +-- .../builder/api/elements/serializers.py | 2 +- .../elements/collection_field_types.py | 27 +- .../contrib/builder/elements/element_types.py | 21 +- .../contrib/builder/elements/registries.py | 3 +- .../contrib/builder/formula_importer.py | 21 +- .../builder/formula_property_extractor.py | 3 + backend/src/baserow/contrib/builder/mixins.py | 3 +- .../workflow_actions/workflow_action_types.py | 8 +- .../contrib/integrations/ai/service_types.py | 26 +- .../integrations/core/service_types.py | 7 +- .../local_baserow/api/serializers.py | 8 +- .../integrations/local_baserow/mixins.py | 29 ++- .../local_baserow/service_types.py | 5 +- backend/src/baserow/core/formula/__init__.py | 6 +- .../src/baserow/core/formula/serializers.py | 21 -- backend/src/baserow/core/registry.py | 6 +- .../baserow/core/services/formula_importer.py | 4 +- .../src/baserow/core/services/registries.py | 53 ++-- .../automation/nodes/test_node_actions.py | 10 +- .../automation/nodes/test_node_service.py | 3 +- .../workflows/test_graph_handler.py | 2 +- .../data_sources/test_data_source_views.py | 14 +- .../test_data_provider_types.py | 43 +++- .../test_tags_collection_field_type.py | 7 +- .../contrib/builder/test_formula_importer.py | 19 +- .../test_formula_property_extractor.py | 4 +- .../ai/test_ai_agent_service_type.py | 39 +-- .../form/SimulateDispatchNodeForm.vue | 36 ++- .../components/sidebar/SampleDataModal.vue | 2 +- .../components/workflow/WorkflowNode.vue | 46 ++-- .../modules/automation/locales/en.json | 4 +- .../node/simulate_dispatch_node.scss | 8 + web-frontend/modules/core/formula/index.js | 4 +- web-frontend/modules/core/serviceTypes.js | 5 +- web-frontend/modules/core/utils/object.js | 9 +- .../services/AIAgentServiceForm.vue | 232 +++++++++--------- .../integrations/ai/integrationTypes.js | 11 +- .../services/LocalBaserowServiceForm.vue | 1 - ...ocalBaserowTableServiceConditionalForm.vue | 31 ++- .../modules/integrations/locales/en.json | 8 +- 46 files changed, 501 insertions(+), 396 deletions(-) diff --git a/backend/src/baserow/contrib/automation/formula_importer.py b/backend/src/baserow/contrib/automation/formula_importer.py index 4e741df095..7d4fba1f4c 100644 --- a/backend/src/baserow/contrib/automation/formula_importer.py +++ b/backend/src/baserow/contrib/automation/formula_importer.py @@ -4,6 +4,7 @@ automation_data_provider_type_registry, ) from baserow.core.formula import BaserowFormulaObject, get_parse_tree_for_formula +from baserow.core.formula.types import BASEROW_FORMULA_MODE_RAW from baserow.core.services.formula_importer import BaserowFormulaImporter @@ -20,7 +21,7 @@ def get_data_provider_type_registry(self): def import_formula( formula: Union[str, BaserowFormulaObject], id_mapping: Dict[str, str], **kwargs -) -> str: +) -> BaserowFormulaObject: """ When a formula is used in an automation, it must be migrated when we import it because it could contain IDs referencing other objects. @@ -32,11 +33,17 @@ def import_formula( :return: The updated path. """ - # Figure out what our formula string is. - formula_str = formula if isinstance(formula, str) else formula["formula"] + formula = BaserowFormulaObject.to_formula(formula) - if not formula_str: - return formula_str + if formula["mode"] == BASEROW_FORMULA_MODE_RAW or not formula["formula"]: + return formula - tree = get_parse_tree_for_formula(formula_str) - return AutomationFormulaImporter(id_mapping, **kwargs).visit(tree) + tree = get_parse_tree_for_formula(formula["formula"]) + new_formula = AutomationFormulaImporter(id_mapping, **kwargs).visit(tree) + + if new_formula != formula["formula"]: + # We create a new instance to show it's a different formula + formula = dict(formula) + formula["formula"] = new_formula + + return formula diff --git a/backend/src/baserow/contrib/automation/nodes/handler.py b/backend/src/baserow/contrib/automation/nodes/handler.py index fc14aebf00..931b0f4b05 100644 --- a/backend/src/baserow/contrib/automation/nodes/handler.py +++ b/backend/src/baserow/contrib/automation/nodes/handler.py @@ -7,6 +7,8 @@ from baserow.contrib.automation.automation_dispatch_context import ( AutomationDispatchContext, ) +from baserow.contrib.automation.constants import IMPORT_SERIALIZED_IMPORTING +from baserow.contrib.automation.formula_importer import import_formula from baserow.contrib.automation.models import AutomationWorkflow from baserow.contrib.automation.nodes.exceptions import ( AutomationNodeDoesNotExist, @@ -28,9 +30,7 @@ from baserow.core.services.handler import ServiceHandler from baserow.core.services.models import Service from baserow.core.storage import ExportZipFile -from baserow.core.utils import MirrorDict, extract_allowed - -from .signals import automation_node_updated +from baserow.core.utils import ChildProgressBuilder, MirrorDict, extract_allowed class AutomationNodeHandler: @@ -275,6 +275,7 @@ def import_nodes( serialized_nodes: List[AutomationNodeDict], id_mapping: Dict[str, Dict[int, int]], cache: Optional[Dict] = None, + progress: Optional[ChildProgressBuilder] = None, *args, **kwargs, ): @@ -303,9 +304,22 @@ def import_nodes( *args, **kwargs, ) - imported_nodes.append([node_instance, serialized_node]) + imported_nodes.append(node_instance) + + if progress: + progress.increment(state=IMPORT_SERIALIZED_IMPORTING) + + # We migrate service formulas here to make sure all nodes are imported before + # we migrate them + for imported_node in imported_nodes: + service = imported_node.service.specific + updated_models = service.get_type().import_formulas( + service, id_mapping, import_formula, **kwargs + ) + + [u.save() for u in updated_models] - return [i[0] for i in imported_nodes] + return imported_nodes def import_node_only( self, @@ -362,10 +376,6 @@ def dispatch_node( # Return early if this is a simulated dispatch if until_node := dispatch_context.simulate_until_node: if until_node.id == node.id: - # sample_data was updated as it's a simulation we should tell to - # the frontend - node.service.refresh_from_db(fields=["sample_data"]) - automation_node_updated.send(self, user=None, node=node) return if children := node.get_children(): diff --git a/backend/src/baserow/contrib/automation/nodes/registries.py b/backend/src/baserow/contrib/automation/nodes/registries.py index 8b72abd995..0c741a3950 100644 --- a/backend/src/baserow/contrib/automation/nodes/registries.py +++ b/backend/src/baserow/contrib/automation/nodes/registries.py @@ -5,7 +5,6 @@ from baserow.contrib.automation.automation_dispatch_context import ( AutomationDispatchContext, ) -from baserow.contrib.automation.formula_importer import import_formula from baserow.contrib.automation.nodes.exceptions import AutomationNodeNotReplaceable from baserow.contrib.automation.nodes.models import AutomationNode from baserow.contrib.automation.nodes.types import AutomationNodeDict, NodePositionType @@ -204,7 +203,7 @@ def deserialize_property( storage=storage, cache=cache, files_zip=files_zip, - import_formula=import_formula, + # We don't migrate formulas here but later after the node import import_export_config=kwargs.get("import_export_config"), ) return super().deserialize_property( @@ -259,6 +258,7 @@ def prepare_values( # as part of creating a new node. If this happens, we need # to create a new service. service = ServiceHandler().create_service(service_type) + else: service = instance.service.specific diff --git a/backend/src/baserow/contrib/automation/nodes/service.py b/backend/src/baserow/contrib/automation/nodes/service.py index f870e608f3..de863c83f2 100644 --- a/backend/src/baserow/contrib/automation/nodes/service.py +++ b/backend/src/baserow/contrib/automation/nodes/service.py @@ -36,6 +36,7 @@ ) from baserow.contrib.automation.workflows.signals import automation_workflow_updated from baserow.core.handler import CoreHandler +from baserow.core.integrations.handler import IntegrationHandler from baserow.core.trash.handler import TrashHandler @@ -173,6 +174,18 @@ def create_node( prepared_values = node_type.prepare_values(kwargs, user) + # Preselect first integration if exactly one exists + if node_type.get_service_type().integration_type: + integrations_of_type = [ + i + for i in IntegrationHandler().get_integrations(workflow.automation) + if i.get_type().type == node_type.get_service_type().integration_type + ] + + if len(integrations_of_type) == 1: + prepared_values["service"].integration = integrations_of_type[0] + prepared_values["service"].save() + new_node = self.handler.create_node( node_type, workflow=workflow, diff --git a/backend/src/baserow/contrib/automation/workflows/graph_handler.py b/backend/src/baserow/contrib/automation/workflows/graph_handler.py index b1af973f50..2c15de8152 100644 --- a/backend/src/baserow/contrib/automation/workflows/graph_handler.py +++ b/backend/src/baserow/contrib/automation/workflows/graph_handler.py @@ -340,6 +340,10 @@ def remove(self, node_to_delete: AutomationNode, keep_info=False): node_to_delete.id, next_node_ids, ) + if not graph[node_position_id]["next"][output]: + del graph[node_position_id]["next"][output] + if not graph[node_position_id]["next"]: + del graph[node_position_id]["next"] elif position == "child": next_nodes = self._get_all_next_nodes(node_to_delete) graph[node_position_id]["children"] = _replace( @@ -347,6 +351,8 @@ def remove(self, node_to_delete: AutomationNode, keep_info=False): node_to_delete.id, next_nodes, ) + if not graph[node_position_id]["children"]: + del graph[node_position_id]["children"] if not keep_info: del graph[str(node_to_delete.id)] diff --git a/backend/src/baserow/contrib/automation/workflows/handler.py b/backend/src/baserow/contrib/automation/workflows/handler.py index 02df73dc0f..55aa038ceb 100644 --- a/backend/src/baserow/contrib/automation/workflows/handler.py +++ b/backend/src/baserow/contrib/automation/workflows/handler.py @@ -440,23 +440,16 @@ def import_nodes( imported_nodes = [] - for serialized_node in serialized_nodes: - # check that the node has not already been imported in a - # previous pass or if the parent doesn't exist yet. - imported_node = AutomationNodeHandler().import_node( - workflow, - serialized_node, - id_mapping, - import_export_config=import_export_config, - files_zip=files_zip, - storage=storage, - cache=cache, - ) - - imported_nodes.append(imported_node) - - if progress: - progress.increment(state=IMPORT_SERIALIZED_IMPORTING) + imported_nodes = AutomationNodeHandler().import_nodes( + workflow, + serialized_nodes, + id_mapping, + import_export_config=import_export_config, + files_zip=files_zip, + storage=storage, + cache=cache, + progress=progress, + ) return imported_nodes @@ -957,3 +950,10 @@ def start_workflow( history.message = history_message history.status = history_status history.save() + else: + # sample_data was updated as it's a simulation we should tell to + # the frontend + simulate_until_node.service.specific.refresh_from_db( + fields=["sample_data"] + ) + automation_node_updated.send(self, user=None, node=simulate_until_node) diff --git a/backend/src/baserow/contrib/builder/api/elements/serializers.py b/backend/src/baserow/contrib/builder/api/elements/serializers.py index ca4c9e37fb..727299da98 100644 --- a/backend/src/baserow/contrib/builder/api/elements/serializers.py +++ b/backend/src/baserow/contrib/builder/api/elements/serializers.py @@ -508,7 +508,7 @@ def to_representation(self, value): if not is_formula: # We force the type to raw as it's not a formula # For compat with unmigrated values. - value["mode"] = "raw" + value["mode"] = BASEROW_FORMULA_MODE_RAW return value diff --git a/backend/src/baserow/contrib/builder/elements/collection_field_types.py b/backend/src/baserow/contrib/builder/elements/collection_field_types.py index 1d372c78ee..5e6601fda0 100644 --- a/backend/src/baserow/contrib/builder/elements/collection_field_types.py +++ b/backend/src/baserow/contrib/builder/elements/collection_field_types.py @@ -10,7 +10,7 @@ from baserow.contrib.builder.workflow_actions.models import BuilderWorkflowAction from baserow.core.constants import RatingStyleChoices from baserow.core.formula.serializers import FormulaSerializerField -from baserow.core.formula.types import BaserowFormulaObject +from baserow.core.formula.types import BASEROW_FORMULA_MODE_RAW, BaserowFormulaObject from baserow.core.registry import Instance @@ -186,7 +186,9 @@ def formula_generator( for index, page_parameter in enumerate( collection_field.config.get("page_parameters") or [] ): - new_formula = yield page_parameter.get("value") + new_formula = yield BaserowFormulaObject.to_formula( + page_parameter.get("value") + ) if new_formula is not None: collection_field.config["page_parameters"][index]["value"] = new_formula yield collection_field @@ -194,7 +196,9 @@ def formula_generator( for index, query_parameter in enumerate( collection_field.config.get("query_parameters") or [] ): - new_formula = yield query_parameter.get("value") + new_formula = yield BaserowFormulaObject.to_formula( + query_parameter.get("value") + ) if new_formula is not None: collection_field.config["query_parameters"][index][ "value" @@ -266,11 +270,18 @@ def formula_generator( yield from super().formula_generator(collection_field) - if collection_field.config.get("colors_is_formula"): - new_formula = yield collection_field.config.get("colors", "") - if new_formula is not None: - collection_field.config["colors"] = new_formula - yield collection_field + is_formula = collection_field.config.get("colors_is_formula", False) + colors = BaserowFormulaObject.to_formula( + collection_field.config.get("colors", "") + ) + + if not is_formula: + colors["mode"] = BASEROW_FORMULA_MODE_RAW + + new_formula = yield colors + if new_formula is not None: + collection_field.config["colors"] = new_formula + yield collection_field class ButtonCollectionFieldType(CollectionFieldType): diff --git a/backend/src/baserow/contrib/builder/elements/element_types.py b/backend/src/baserow/contrib/builder/elements/element_types.py index c08e8e9469..e4e8549ec6 100644 --- a/backend/src/baserow/contrib/builder/elements/element_types.py +++ b/backend/src/baserow/contrib/builder/elements/element_types.py @@ -1007,21 +1007,13 @@ def formula_generator( yield from super().formula_generator(element) for index, data in enumerate(element.page_parameters): - new_formula = ( - yield data["value"] - if isinstance(data["value"], str) - else data["value"]["formula"] - ) + new_formula = yield BaserowFormulaObject.to_formula(data["value"]) if new_formula is not None: element.page_parameters[index]["value"] = new_formula yield element for index, data in enumerate(element.query_parameters or []): - new_formula = ( - yield data["value"] - if isinstance(data["value"], str) - else data["value"]["formula"] - ) + new_formula = yield BaserowFormulaObject.to_formula(data["value"]) if new_formula is not None: element.query_parameters[index]["value"] = new_formula yield element @@ -2503,20 +2495,21 @@ def formula_generator( for item in element.menu_items.all(): for index, data in enumerate(item.page_parameters or []): - new_formula = yield data["value"] + new_formula = yield BaserowFormulaObject.to_formula(data["value"]) if new_formula is not None: item.page_parameters[index]["value"] = new_formula yield item for index, data in enumerate(item.query_parameters or []): - new_formula = yield data["value"] + new_formula = yield BaserowFormulaObject.to_formula(data["value"]) if new_formula is not None: item.query_parameters[index]["value"] = new_formula yield item for formula_field in NavigationElementManager.simple_formula_fields: - formula = getattr(item, formula_field, "") - new_formula = yield formula + new_formula = yield BaserowFormulaObject.to_formula( + getattr(item, formula_field, "") + ) if new_formula is not None: setattr(item, formula_field, new_formula) yield item diff --git a/backend/src/baserow/contrib/builder/elements/registries.py b/backend/src/baserow/contrib/builder/elements/registries.py index 93f7d46675..45d1b28ac6 100644 --- a/backend/src/baserow/contrib/builder/elements/registries.py +++ b/backend/src/baserow/contrib/builder/elements/registries.py @@ -23,6 +23,7 @@ from baserow.contrib.builder.formula_importer import import_formula from baserow.contrib.builder.mixins import BuilderInstanceWithFormulaMixin from baserow.contrib.builder.pages.models import Page +from baserow.core.formula.types import BaserowFormulaObject from baserow.core.models import Workspace from baserow.core.registry import ( CustomFieldsInstanceMixin, @@ -586,7 +587,7 @@ def formula_generator( for formula_field in self.simple_formula_fields: formula = collection_field.config.get(formula_field, "") - new_formula = yield formula + new_formula = yield BaserowFormulaObject.to_formula(formula) if new_formula is not None: collection_field.config[formula_field] = new_formula yield collection_field diff --git a/backend/src/baserow/contrib/builder/formula_importer.py b/backend/src/baserow/contrib/builder/formula_importer.py index 50a3ad879f..c411797b79 100644 --- a/backend/src/baserow/contrib/builder/formula_importer.py +++ b/backend/src/baserow/contrib/builder/formula_importer.py @@ -4,6 +4,7 @@ builder_data_provider_type_registry, ) from baserow.core.formula import BaserowFormulaObject, get_parse_tree_for_formula +from baserow.core.formula.types import BASEROW_FORMULA_MODE_RAW from baserow.core.services.formula_importer import BaserowFormulaImporter @@ -20,7 +21,7 @@ def get_data_provider_type_registry(self): def import_formula( formula: Union[str, BaserowFormulaObject], id_mapping: Dict[str, str], **kwargs -) -> str: +) -> BaserowFormulaObject: """ When a formula is used in a service, it must be migrated when we import it because it could contain IDs referencing other objects. For example, the formula @@ -45,11 +46,17 @@ def import_formula( :return: The updated formula (same type as input - string or object). """ - # Figure out what our formula string is. - formula_str = formula if isinstance(formula, str) else formula["formula"] + formula = BaserowFormulaObject.to_formula(formula) - if not formula_str: - return formula_str + if formula["mode"] == BASEROW_FORMULA_MODE_RAW or not formula["formula"]: + return formula - tree = get_parse_tree_for_formula(formula_str) - return BuilderFormulaImporter(id_mapping, **kwargs).visit(tree) + tree = get_parse_tree_for_formula(formula["formula"]) + new_formula = BuilderFormulaImporter(id_mapping, **kwargs).visit(tree) + + if new_formula != formula["formula"]: + # We create a new instance to show it's a different formula + formula = dict(formula) + formula["formula"] = new_formula + + return formula diff --git a/backend/src/baserow/contrib/builder/formula_property_extractor.py b/backend/src/baserow/contrib/builder/formula_property_extractor.py index f45fd781c0..4a85fda514 100644 --- a/backend/src/baserow/contrib/builder/formula_property_extractor.py +++ b/backend/src/baserow/contrib/builder/formula_property_extractor.py @@ -82,6 +82,9 @@ def visitFunctionCall(self, ctx: BaserowFormula.FunctionCallContext): # we can ignore it. Maybe the related data source is gone. pass + def visitBinaryOp(self, ctx: BaserowFormula.BinaryOpContext): + return self.visitChildren(ctx) + def get_element_property_names( elements: List[Element], diff --git a/backend/src/baserow/contrib/builder/mixins.py b/backend/src/baserow/contrib/builder/mixins.py index 492503cd94..a02509452b 100644 --- a/backend/src/baserow/contrib/builder/mixins.py +++ b/backend/src/baserow/contrib/builder/mixins.py @@ -1,6 +1,7 @@ from baserow.contrib.builder.formula_property_extractor import FormulaFieldVisitor from baserow.core.formula.parser.exceptions import BaserowFormulaSyntaxError from baserow.core.formula.parser.parser import get_parse_tree_for_formula +from baserow.core.formula.types import BaserowFormulaObject from baserow.core.registry import InstanceWithFormulaMixin from baserow.core.utils import merge_dicts_no_duplicates @@ -11,7 +12,7 @@ def extract_properties(self, instance, **kwargs): for formula in self.formula_generator(instance): # Figure out what our formula string is. - formula_str = formula if isinstance(formula, str) else formula["formula"] + formula_str = BaserowFormulaObject.to_formula(formula)["formula"] if not formula_str: continue diff --git a/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py b/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py index 4d4e1f1df7..c12046c7e0 100644 --- a/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py +++ b/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py @@ -137,13 +137,17 @@ def formula_generator( yield from super().formula_generator(workflow_action) for index, page_parameter in enumerate(workflow_action.page_parameters): - new_formula = yield page_parameter.get("value") + new_formula = yield BaserowFormulaObject.to_formula( + page_parameter.get("value") + ) if new_formula is not None: workflow_action.page_parameters[index]["value"] = new_formula yield workflow_action for index, query_parameter in enumerate(workflow_action.query_parameters or []): - new_formula = yield query_parameter.get("value") + new_formula = yield BaserowFormulaObject.to_formula( + query_parameter.get("value") + ) if new_formula is not None: workflow_action.query_parameters[index]["value"] = new_formula yield workflow_action diff --git a/backend/src/baserow/contrib/integrations/ai/service_types.py b/backend/src/baserow/contrib/integrations/ai/service_types.py index b8b7b934a6..55873f8e76 100644 --- a/backend/src/baserow/contrib/integrations/ai/service_types.py +++ b/backend/src/baserow/contrib/integrations/ai/service_types.py @@ -22,6 +22,7 @@ from baserow.core.services.dispatch_context import DispatchContext from baserow.core.services.exceptions import ( ServiceImproperlyConfiguredDispatchException, + UnexpectedDispatchException, ) from baserow.core.services.registries import DispatchTypes, ServiceType from baserow.core.services.types import DispatchResult, FormulaToResolve, ServiceDict @@ -91,7 +92,7 @@ class AIAgentServiceType(ServiceType): help_text="The prompt to send to the AI model. Can be a formula.", ), "ai_choices": serializers.ListField( - child=serializers.CharField(), + child=serializers.CharField(allow_blank=True), required=False, default=list, help_text="List of choice options for 'choice' output type.", @@ -163,16 +164,12 @@ def formulas_to_resolve( 'property "ai_prompt"', ) - def resolve_service_formulas( + def dispatch_data( self, service: AIAgentService, + resolved_values: Dict[str, Any], dispatch_context: DispatchContext, ) -> Dict[str, Any]: - if not service.integration_id: - raise ServiceImproperlyConfiguredDispatchException( - "The integration property is missing." - ) - if not service.ai_generative_ai_type: raise ServiceImproperlyConfiguredDispatchException( "The AI provider type is missing." @@ -185,7 +182,9 @@ def resolve_service_formulas( # Check if prompt formula is set (FormulaField returns empty string when not # set) - if not service.ai_prompt or str(service.ai_prompt).strip() == "": + prompt = resolved_values.get("ai_prompt", "") + + if not prompt: raise ServiceImproperlyConfiguredDispatchException("The prompt is missing.") if service.ai_output_type == AIOutputType.CHOICE: @@ -200,15 +199,6 @@ def resolve_service_formulas( "At least one non-empty choice is required when output type is 'choice'." ) - return super().resolve_service_formulas(service, dispatch_context) - - def dispatch_data( - self, - service: AIAgentService, - resolved_values: Dict[str, Any], - dispatch_context: DispatchContext, - ) -> Dict[str, Any]: - prompt = resolved_values.get("ai_prompt", "") ai_model_type = generative_ai_model_type_registry.get( service.ai_generative_ai_type ) @@ -263,7 +253,7 @@ def dispatch_data( **kwargs, ) except GenerativeAIPromptError as e: - raise ServiceImproperlyConfiguredDispatchException( + raise UnexpectedDispatchException( f"AI prompt execution failed: {str(e)}" ) from e diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index 0297af4979..a8342c1d57 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -49,6 +49,7 @@ HTTPHeader, HTTPQueryParam, ) +from baserow.core.formula.types import BaserowFormulaObject from baserow.core.formula.validator import ( ensure_array, ensure_boolean, @@ -261,21 +262,21 @@ def formula_generator( # Return form_data formulas for fdata in service.form_data.all(): - new_formula = yield fdata.value + new_formula = yield BaserowFormulaObject.to_formula(fdata.value) if new_formula is not None: fdata.value = new_formula yield fdata # Return headers formulas for header in service.headers.all(): - new_formula = yield header.value + new_formula = yield BaserowFormulaObject.to_formula(header.value) if new_formula is not None: header.value = new_formula yield header # Return headers formulas for query_param in service.query_params.all(): - new_formula = yield query_param.value + new_formula = yield BaserowFormulaObject.to_formula(query_param.value) if new_formula is not None: query_param.value = new_formula yield query_param diff --git a/backend/src/baserow/contrib/integrations/local_baserow/api/serializers.py b/backend/src/baserow/contrib/integrations/local_baserow/api/serializers.py index 8bcba5fb5f..f878ef52b2 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/api/serializers.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/api/serializers.py @@ -4,10 +4,7 @@ LocalBaserowTableServiceFilter, LocalBaserowTableServiceSort, ) -from baserow.core.formula.serializers import ( - FormulaSerializerField, - OptionalFormulaSerializerField, -) +from baserow.core.formula.serializers import FormulaSerializerField class LocalBaserowTableServiceSortSerializer(serializers.ModelSerializer): @@ -54,9 +51,8 @@ def to_internal_value(self, data): class LocalBaserowTableServiceFilterSerializer(serializers.ModelSerializer): - value = OptionalFormulaSerializerField( + value = FormulaSerializerField( help_text="A formula for the filter's value.", - is_formula_field_name="value_is_formula", ) value_is_formula = serializers.BooleanField( default=False, help_text="Indicates whether the value is a formula or not." diff --git a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py index 4e79bbf68e..c0b97d9cf6 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py @@ -23,6 +23,7 @@ from baserow.core.formula import BaserowFormulaObject, resolve_formula from baserow.core.formula.registries import formula_runtime_function_registry from baserow.core.formula.serializers import FormulaSerializerField +from baserow.core.formula.types import BASEROW_FORMULA_MODE_RAW from baserow.core.formula.validator import ensure_integer, ensure_string from baserow.core.registry import Instance from baserow.core.services.dispatch_context import DispatchContext @@ -267,7 +268,11 @@ def get_dispatch_filters( model_field = model._meta.get_field(field_name) view_filter_type = view_filter_type_registry.get(service_filter.type) - if service_filter.value_is_formula: + # We need this test for compatibility purposes with old values + if ( + service_filter.value_is_formula + or service_filter.value["mode"] == BASEROW_FORMULA_MODE_RAW + ): try: resolved_value = ensure_string( resolve_formula( @@ -278,7 +283,8 @@ def get_dispatch_filters( ) except Exception as exc: raise ServiceImproperlyConfiguredDispatchException( - f"The {field_name} service filter formula can't be resolved: {exc}" + f"The {field_name} service filter formula can't be " + "resolved: {exc}" ) from exc else: resolved_value = service_filter.value["formula"] @@ -303,13 +309,18 @@ def formula_generator( yield from super().formula_generator(service) for service_filter in service.service_filters_with_untrashed_fields: - if service_filter.value_is_formula: - # Service types like LocalBaserowGetRow do not have a value attribute. - new_formula = yield service_filter.value - if new_formula is not None: - # Set the new formula for the Service Filter - service_filter.value = new_formula - yield service_filter + is_formula = service_filter.value_is_formula + formula = BaserowFormulaObject.to_formula(service_filter.value) + + if not is_formula: + formula["mode"] = BASEROW_FORMULA_MODE_RAW + + # Service types like LocalBaserowGetRow do not have a value attribute. + new_formula = yield formula + if new_formula is not None: + # Set the new formula for the Service Filter + service_filter.value = new_formula + yield service_filter def get_table_queryset( self, diff --git a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py index 5739760cc3..b28c11bd15 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py @@ -103,6 +103,7 @@ guess_json_type_from_response_serializer_field, ) from baserow.core.cache import global_cache +from baserow.core.formula.types import BaserowFormulaObject from baserow.core.handler import CoreHandler from baserow.core.registry import Instance from baserow.core.services.dispatch_context import DispatchContext @@ -210,7 +211,7 @@ def _convert_allowed_field_names(self, service, allowed_fields): mapping = { field_obj["field"].db_column: field_obj["field"].name - for field_obj in self.get_table_field_objects(service) + for field_obj in self.get_table_field_objects(service) or [] } return [mapping.get(f, f) for f in allowed_fields] @@ -1801,7 +1802,7 @@ def formula_generator( # Return field_mapping formulas for field_mapping in service.field_mappings.all(): - new_formula = yield field_mapping.value + new_formula = yield BaserowFormulaObject.to_formula(field_mapping.value) if new_formula is not None: field_mapping.value = new_formula yield field_mapping diff --git a/backend/src/baserow/core/formula/__init__.py b/backend/src/baserow/core/formula/__init__.py index fd4f19084c..627cdb61f0 100644 --- a/backend/src/baserow/core/formula/__init__.py +++ b/backend/src/baserow/core/formula/__init__.py @@ -10,6 +10,7 @@ BaserowFormulaVisitor, ) from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_RAW, BaserowFormulaObject, FormulaContext, FunctionCollection, @@ -44,7 +45,10 @@ def resolve_formula( # If we receive a blank formula string, don't attempt to parse it. if not formula["formula"]: - return "" + return formula["formula"] + + if formula["mode"] == BASEROW_FORMULA_MODE_RAW: + return formula["formula"] tree = get_parse_tree_for_formula(formula["formula"]) return BaserowPythonExecutor(functions, formula_context).visit(tree) diff --git a/backend/src/baserow/core/formula/serializers.py b/backend/src/baserow/core/formula/serializers.py index cdf3729e1c..ad01b16591 100644 --- a/backend/src/baserow/core/formula/serializers.py +++ b/backend/src/baserow/core/formula/serializers.py @@ -117,24 +117,3 @@ def to_internal_value(self, data: Union[str, Dict[str, str]]): return data except BaserowFormulaSyntaxError as e: raise ValidationError(f"The formula is invalid: {e}", code="invalid") - - -@extend_schema_field(OpenApiTypes.STR) -class OptionalFormulaSerializerField(FormulaSerializerField): - """ - This field can be used to store a formula, or plain text, in the database. If - `value_is_formula` is `True`, then the value will be treated as a formula and - `FormulaSerializerField` will be used to validate it. Otherwise, the value - will be treated as plain text. - """ - - def __init__(self, *args, is_formula_field_name=None, **kwargs): - self.is_formula_field_name = is_formula_field_name - super().__init__(*args, **kwargs) - - def to_internal_value(self, data): - is_formula = self.parent.data.get(self.is_formula_field_name, False) - if not is_formula: - return data - - return super().to_internal_value(data) diff --git a/backend/src/baserow/core/registry.py b/backend/src/baserow/core/registry.py index 4f8c5eda8e..a1719cc1a0 100644 --- a/backend/src/baserow/core/registry.py +++ b/backend/src/baserow/core/registry.py @@ -1032,8 +1032,10 @@ def formula_generator( """ for formula_field in self.simple_formula_fields: - formula: Union[str, BaserowFormulaObject] = getattr(instance, formula_field) - new_formula = yield formula if isinstance(formula, str) else formula + formula: BaserowFormulaObject = BaserowFormulaObject.to_formula( + getattr(instance, formula_field) + ) + new_formula = yield formula if new_formula is not None: setattr(instance, formula_field, new_formula) yield instance diff --git a/backend/src/baserow/core/services/formula_importer.py b/backend/src/baserow/core/services/formula_importer.py index 02100180d8..5613cfcfde 100644 --- a/backend/src/baserow/core/services/formula_importer.py +++ b/backend/src/baserow/core/services/formula_importer.py @@ -57,6 +57,7 @@ def _do_func_import(self, function_argument_expressions, function_name: str): data_provider_type = self.get_data_provider_type_registry().get( data_provider_name ) + unquoted_arg_list = data_provider_type.import_path( path, self.id_mapping, **self.extra_context ) @@ -66,7 +67,8 @@ def _do_func_import(self, function_argument_expressions, function_name: str): return f"{function_name}({','.join(args)})" def visitBinaryOp(self, ctx: BaserowFormula.BinaryOpContext): - return ctx.getText() + args = [expr.accept(self) for expr in (ctx.expr())] + return f" {ctx.op.text} ".join(args) def visitFunc_name(self, ctx: BaserowFormula.Func_nameContext): return ctx.getText() diff --git a/backend/src/baserow/core/services/registries.py b/backend/src/baserow/core/services/registries.py index abb84cdace..2a017a9970 100644 --- a/backend/src/baserow/core/services/registries.py +++ b/backend/src/baserow/core/services/registries.py @@ -356,8 +356,6 @@ def dispatch( :return: The service dispatch result if any. """ - resolved_values = self.resolve_service_formulas(service, dispatch_context) - # If simulated, try to return existing sample data if ( dispatch_context.use_sample_data @@ -370,22 +368,31 @@ def dispatch( ): return DispatchResult(**sample_data) - data = self.dispatch_data(service, resolved_values, dispatch_context) - serialized_data = self.dispatch_transform(data) - - if dispatch_context.use_sample_data and ( - dispatch_context.update_sample_data_for is None - or service in dispatch_context.update_sample_data_for - ): - sample_data = {} - for field in fields(serialized_data): - value = getattr(serialized_data, field.name) - sample_data[field.name] = value - - service.sample_data = sample_data - service.save() + try: + resolved_values = self.resolve_service_formulas(service, dispatch_context) + data = self.dispatch_data(service, resolved_values, dispatch_context) + serialized_data = self.dispatch_transform(data) + except Exception as e: + if dispatch_context.use_sample_data and ( + dispatch_context.update_sample_data_for is None + or service in dispatch_context.update_sample_data_for + ): + service.sample_data = {"_error": str(e)} + service.save() + raise + else: + if dispatch_context.use_sample_data and ( + dispatch_context.update_sample_data_for is None + or service in dispatch_context.update_sample_data_for + ): + sample_data = {} + for field in fields(serialized_data): + value = getattr(serialized_data, field.name) + sample_data[field.name] = value - return serialized_data + service.sample_data = sample_data + service.save() + return serialized_data def remove_unused_field_names( self, @@ -477,9 +484,6 @@ def import_serialized( import_formula: Callable[[str, Dict[str, Any]], str] = None, **kwargs, ): - if import_formula is None: - raise ValueError("Missing import formula function.") - created_instance = super().import_serialized( parent, serialized_values, @@ -488,11 +492,12 @@ def import_serialized( **kwargs, ) - updated_models = self.import_formulas( - created_instance, id_mapping, import_formula, **kwargs - ) + if import_formula is not None: + updated_models = self.import_formulas( + created_instance, id_mapping, import_formula, **kwargs + ) - [m.save() for m in updated_models] + [m.save() for m in updated_models] return created_instance diff --git a/backend/tests/baserow/contrib/automation/nodes/test_node_actions.py b/backend/tests/baserow/contrib/automation/nodes/test_node_actions.py index fa7020d169..62a335ef06 100644 --- a/backend/tests/baserow/contrib/automation/nodes/test_node_actions.py +++ b/backend/tests/baserow/contrib/automation/nodes/test_node_actions.py @@ -406,7 +406,7 @@ def test_delete_node_action_after_nothing(data_fixture): workflow.assert_reference( { "0": "rows_created", - "Before": {"next": {"": []}}, + "Before": {}, "rows_created": {"next": {"": ["Before"]}}, } ) @@ -435,7 +435,7 @@ def test_delete_node_action_after_nothing(data_fixture): workflow.assert_reference( { "0": "rows_created", - "Before": {"next": {"": []}}, + "Before": {}, "rows_created": {"next": {"": ["Before"]}}, } ) @@ -648,7 +648,7 @@ def test_move_node_action(data_fixture): "rows_created": {"next": {"": ["first action"]}}, "first action": {"next": {"": ["moved node"]}}, "moved node": {"next": {"": ["second action"]}}, - "second action": {"next": {"": []}}, + "second action": {}, } ) @@ -672,7 +672,7 @@ def test_move_node_action(data_fixture): "rows_created": {"next": {"": ["first action"]}}, "first action": {"next": {"": ["moved node"]}}, "moved node": {"next": {"": ["second action"]}}, - "second action": {"next": {"": []}}, + "second action": {}, } ) @@ -726,7 +726,6 @@ def test_move_node_action_to_output(data_fixture): "router": { "next": { "Do this": ["output edge 2"], - "Do that": [], "Default": ["fallback node"], } }, @@ -764,7 +763,6 @@ def test_move_node_action_to_output(data_fixture): "router": { "next": { "Do this": ["output edge 2"], - "Do that": [], "Default": ["fallback node"], } }, diff --git a/backend/tests/baserow/contrib/automation/nodes/test_node_service.py b/backend/tests/baserow/contrib/automation/nodes/test_node_service.py index bb381e015b..524e1c4a2d 100644 --- a/backend/tests/baserow/contrib/automation/nodes/test_node_service.py +++ b/backend/tests/baserow/contrib/automation/nodes/test_node_service.py @@ -530,7 +530,6 @@ def test_move_node_to_edge_above_existing_output(data_fixture: Fixtures): "router": { "next": { "Do this": ["output edge 2"], - "Do that": [], "Default": ["fallback node"], } }, @@ -621,7 +620,7 @@ def test_move_node_outside_of_container(data_fixture: Fixtures): "0": "rows_created", "rows_created": {"next": {"": ["action1"]}}, "action1": {"next": {"": ["iterator"]}}, - "iterator": {"children": [], "next": {"": ["action3"]}}, + "iterator": {"next": {"": ["action3"]}}, "action2": {"next": {"": ["action4"]}}, "action3": {"next": {"": ["action2"]}}, "action4": {}, diff --git a/backend/tests/baserow/contrib/automation/workflows/test_graph_handler.py b/backend/tests/baserow/contrib/automation/workflows/test_graph_handler.py index 740fcd2817..e4c6b2c482 100644 --- a/backend/tests/baserow/contrib/automation/workflows/test_graph_handler.py +++ b/backend/tests/baserow/contrib/automation/workflows/test_graph_handler.py @@ -738,7 +738,7 @@ def test_graph_handler_replace( "2": {"next": {"": [3]}}, "3": {"children": [7], "next": {"": [4]}}, "4": {"next": {"": [5], "randomUid": [9]}}, - "7": {"next": {"": []}}, + "7": {}, "8": {"children": []}, "9": {"next": {"anotherUid": [8]}}, }, diff --git a/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py b/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py index f0aef11f75..ed26d0a5f8 100644 --- a/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py +++ b/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py @@ -18,7 +18,11 @@ from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.views.models import SORT_ORDER_ASC from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL -from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE, BaserowFormulaObject +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_RAW, + BASEROW_FORMULA_MODE_SIMPLE, + BaserowFormulaObject, +) from baserow.core.services.models import Service from baserow.core.user_sources.user_source_user import UserSourceUser from baserow.test_utils.helpers import AnyInt, AnyStr, setup_interesting_test_table @@ -360,7 +364,7 @@ def test_update_data_source_with_filters(api_client, data_fixture): "value": BaserowFormulaObject( formula="foobar", version=BASEROW_FORMULA_VERSION_INITIAL, - mode=BASEROW_FORMULA_MODE_SIMPLE, + mode=BASEROW_FORMULA_MODE_RAW, ), "value_is_formula": False, }, @@ -391,7 +395,7 @@ def test_update_data_source_with_filters(api_client, data_fixture): "value": BaserowFormulaObject( formula="foobar", version=BASEROW_FORMULA_VERSION_INITIAL, - mode=BASEROW_FORMULA_MODE_SIMPLE, + mode=BASEROW_FORMULA_MODE_RAW, ), "trashed": False, "value_is_formula": False, @@ -436,7 +440,7 @@ def test_update_data_source_with_filters(api_client, data_fixture): "value": BaserowFormulaObject( formula="foobar", version=BASEROW_FORMULA_VERSION_INITIAL, - mode=BASEROW_FORMULA_MODE_SIMPLE, + mode=BASEROW_FORMULA_MODE_RAW, ), "value_is_formula": False, } @@ -457,7 +461,7 @@ def test_update_data_source_with_filters(api_client, data_fixture): "value": BaserowFormulaObject( formula="foobar", version=BASEROW_FORMULA_VERSION_INITIAL, - mode=BASEROW_FORMULA_MODE_SIMPLE, + mode=BASEROW_FORMULA_MODE_RAW, ), "trashed": False, "value_is_formula": False, diff --git a/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py b/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py index 49827c23ad..8fcde6df92 100644 --- a/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py +++ b/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py @@ -39,6 +39,7 @@ InvalidRuntimeFormula, ) from baserow.core.formula.registries import DataProviderType +from baserow.core.formula.types import BaserowFormulaObject from baserow.core.services.exceptions import ( ServiceImproperlyConfiguredDispatchException, ) @@ -796,9 +797,12 @@ def test_data_source_formula_import_only_datasource(data_fixture): id_mapping = defaultdict(lambda: MirrorDict()) id_mapping["builder_data_sources"] = {data_source.id: data_source2.id} - result = import_formula(f"get('data_source.{data_source.id}.field_10')", id_mapping) + result = import_formula( + BaserowFormulaObject.create(f"get('data_source.{data_source.id}.field_10')"), + id_mapping, + ) - assert result == f"get('data_source.{data_source2.id}.field_10')" + assert result["formula"] == f"get('data_source.{data_source2.id}.field_10')" @pytest.mark.django_db @@ -819,10 +823,15 @@ def test_data_source_formula_import_get_row_datasource_and_field(data_fixture): id_mapping["database_fields"] = {field_1.id: field_2.id} result = import_formula( - f"get('data_source.{data_source.id}.field_{field_1.id}')", id_mapping + BaserowFormulaObject.create( + f"get('data_source.{data_source.id}.field_{field_1.id}')" + ), + id_mapping, ) - assert result == f"get('data_source.{data_source2.id}.field_{field_2.id}')" + assert ( + result["formula"] == f"get('data_source.{data_source2.id}.field_{field_2.id}')" + ) @pytest.mark.django_db @@ -841,10 +850,16 @@ def test_data_source_formula_import_list_row_datasource_and_field(data_fixture): id_mapping["database_fields"] = {field_1.id: field_2.id} result = import_formula( - f"get('data_source.{data_source.id}.10.field_{field_1.id}')", id_mapping + BaserowFormulaObject.create( + f"get('data_source.{data_source.id}.10.field_{field_1.id}')" + ), + id_mapping, ) - assert result == f"get('data_source.{data_source2.id}.10.field_{field_2.id}')" + assert ( + result["formula"] + == f"get('data_source.{data_source2.id}.10.field_{field_2.id}')" + ) @pytest.mark.django_db @@ -852,15 +867,19 @@ def test_data_source_formula_import_missing_get_row_datasource(data_fixture): id_mapping = defaultdict(lambda: MirrorDict()) id_mapping["builder_data_sources"] = {} - result = import_formula(f"get('data_source.42.field_24')", id_mapping) + result = import_formula( + BaserowFormulaObject.create("get('data_source.42.field_24')"), id_mapping + ) - assert result == f"get('data_source.42.field_24')" + assert result["formula"] == f"get('data_source.42.field_24')" id_mapping["builder_data_sources"] = {42: 42} - result = import_formula(f"get('data_source.42.field_24')", id_mapping) + result = import_formula( + BaserowFormulaObject.create("get('data_source.42.field_24')"), id_mapping + ) - assert result == f"get('data_source.42.field_24')" + assert result["formula"] == "get('data_source.42.field_24')" @pytest.mark.django_db @@ -988,12 +1007,12 @@ def test_table_element_formula_migration_with_current_row_provider(data_fixture) id_mapping["database_fields"] = {fields[0].id: fields2[0].id} result = import_formula( - f"get('current_record.field_{fields[0].id}')", + BaserowFormulaObject.create(f"get('current_record.field_{fields[0].id}')"), id_mapping, data_source_id=data_source2.id, ) - assert result == f"get('current_record.field_{fields2[0].id}')" + assert result["formula"] == f"get('current_record.field_{fields2[0].id}')" @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/builder/elements/test_tags_collection_field_type.py b/backend/tests/baserow/contrib/builder/elements/test_tags_collection_field_type.py index 7f3cfbe974..953eb09f57 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_tags_collection_field_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_tags_collection_field_type.py @@ -3,7 +3,10 @@ from baserow.contrib.builder.pages.service import PageService from baserow.core.formula import BaserowFormulaObject from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL -from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_RAW, + BASEROW_FORMULA_MODE_SIMPLE, +) @pytest.mark.django_db @@ -90,6 +93,6 @@ def test_import_export_tags_collection_field_type(data_fixture): "colors": BaserowFormulaObject( formula="#d06060ff", version=BASEROW_FORMULA_VERSION_INITIAL, - mode=BASEROW_FORMULA_MODE_SIMPLE, + mode=BASEROW_FORMULA_MODE_RAW, ), } diff --git a/backend/tests/baserow/contrib/builder/test_formula_importer.py b/backend/tests/baserow/contrib/builder/test_formula_importer.py index dea9736812..fb5d7e4b3b 100644 --- a/backend/tests/baserow/contrib/builder/test_formula_importer.py +++ b/backend/tests/baserow/contrib/builder/test_formula_importer.py @@ -4,6 +4,7 @@ import pytest from baserow.contrib.builder.formula_importer import import_formula +from baserow.core.formula import BaserowFormulaObject from baserow.core.formula.registries import DataProviderType from baserow.core.formula.runtime_formula_context import RuntimeFormulaContext from baserow.core.utils import MirrorDict @@ -13,12 +14,22 @@ {"input": "42", "output": "42"}, {"input": "'test'", "output": "'test'"}, {"input": "1 + 2", "output": "1 + 2"}, + { + "input": "get('test_provider.1.10') = 'test'", + "output": "get('test_provider.1.10') = 'test'", + "output2": "get('test_provider.10.42') = 'test'", + }, { "input": "get('test_provider.1.10')", "output": "get('test_provider.1.10')", "output2": "get('test_provider.10.42')", }, {"input": "concat('foo','bar')", "output": "concat('foo','bar')"}, + { + "input": "concat(get('test_provider.1.10'),'bar')", + "output": "concat(get('test_provider.1.10'),'bar')", + "output2": "concat(get('test_provider.10.42'),'bar')", + }, ] @@ -59,9 +70,9 @@ def test_formula_import_formula(formula, mutable_builder_data_provider_registry) id_mapping = defaultdict(lambda: MirrorDict()) - result = import_formula(formula["input"], id_mapping) + result = import_formula(BaserowFormulaObject.create(formula["input"]), id_mapping) - assert result == formula["output"] + assert result["formula"] == formula["output"] @pytest.mark.django_db @@ -74,6 +85,6 @@ def test_formula_import_formula_with_import( id_mapping["first"] = {1: 10} id_mapping["second"] = {10: 42} - result = import_formula(formula["input"], id_mapping) + result = import_formula(BaserowFormulaObject.create(formula["input"]), id_mapping) - assert result == formula.get("output2", formula["output"]) + assert result["formula"] == formula.get("output2", formula["output"]) diff --git a/backend/tests/baserow/contrib/builder/test_formula_property_extractor.py b/backend/tests/baserow/contrib/builder/test_formula_property_extractor.py index 61bee30420..30dd948d7b 100644 --- a/backend/tests/baserow/contrib/builder/test_formula_property_extractor.py +++ b/backend/tests/baserow/contrib/builder/test_formula_property_extractor.py @@ -96,7 +96,9 @@ def test_get_builder_used_property_names_returns_all_property_names(data_fixture { "name": "FieldA", "type": "text", - "config": {"value": f"get('current_record.field_{fields[0].id}')"}, + "config": { + "value": f"get('current_record.field_{fields[0].id}') = 'test'" + }, }, { "name": "FieldB", diff --git a/backend/tests/baserow/contrib/integrations/ai/test_ai_agent_service_type.py b/backend/tests/baserow/contrib/integrations/ai/test_ai_agent_service_type.py index 410df2752d..7706d6f998 100644 --- a/backend/tests/baserow/contrib/integrations/ai/test_ai_agent_service_type.py +++ b/backend/tests/baserow/contrib/integrations/ai/test_ai_agent_service_type.py @@ -12,6 +12,7 @@ from baserow.core.integrations.service import IntegrationService from baserow.core.services.exceptions import ( ServiceImproperlyConfiguredDispatchException, + UnexpectedDispatchException, ) from baserow.core.services.handler import ServiceHandler from baserow.test_utils.helpers import AnyInt @@ -311,42 +312,6 @@ def test_ai_agent_service_dispatch_with_formula(data_fixture, settings): assert result.data == {"result": "Summary: AI technology article"} -@pytest.mark.django_db -def test_ai_agent_service_dispatch_missing_integration(data_fixture, settings): - settings.BASEROW_OPENAI_API_KEY = "sk-test" - settings.BASEROW_OPENAI_MODELS = ["gpt-4"] - - user = data_fixture.create_user() - application = data_fixture.create_builder_application(user=user) - - integration_type = AIIntegrationType() - integration = ( - IntegrationService() - .create_integration(user, integration_type, application=application) - .specific - ) - - service = ServiceHandler().create_service( - AIAgentServiceType(), - integration_id=integration.id, - ai_generative_ai_type="openai", - ai_generative_ai_model="gpt-4", - ai_output_type="text", - ai_prompt="'Test'", - ) - - service.integration_id = None - service.save() - - service_type = service.get_type() - dispatch_context = FakeDispatchContext() - - with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info: - service_type.dispatch(service, dispatch_context) - - assert "integration property is missing" in str(exc_info.value) - - @pytest.mark.django_db def test_ai_agent_service_dispatch_missing_provider(data_fixture, settings): settings.BASEROW_OPENAI_API_KEY = "sk-test" @@ -480,7 +445,7 @@ def test_ai_agent_service_dispatch_ai_error(data_fixture, settings): service_type = service.get_type() dispatch_context = FakeDispatchContext() - with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info: + with pytest.raises(UnexpectedDispatchException) as exc_info: with mock_ai_prompt(should_fail=True): service_type.dispatch(service, dispatch_context) diff --git a/web-frontend/modules/automation/components/form/SimulateDispatchNodeForm.vue b/web-frontend/modules/automation/components/form/SimulateDispatchNodeForm.vue index ff19130841..76425e3055 100644 --- a/web-frontend/modules/automation/components/form/SimulateDispatchNodeForm.vue +++ b/web-frontend/modules/automation/components/form/SimulateDispatchNodeForm.vue @@ -22,9 +22,18 @@ {{ $t('simulateDispatch.triggerNodeAwaitingEvent') }} -
{{ sampleData }}
@@ -38,7 +47,11 @@
icon="iconoir-code-brackets simulate-dispatch-node__button-icon"
@click="showSampleDataModal"
>
- {{ $t('simulateDispatch.buttonLabelShowPayload') }}
+ {{
+ isErrorSample
+ ? $t('simulateDispatch.buttonLabelShowError')
+ : $t('simulateDispatch.buttonLabelShowPayload')
+ }}