| 487 | } // namespace |
| 488 | |
| 489 | Expected<DataProcessorService::TransformRecipe> DataProcessorService::installTransform(TransformRecipe recipe) { |
| 490 | // 1. Shape: N inputs -> M outputs (SISO is the 1->1 case). Field-level binding |
| 491 | // (a column within a multi-column input) is still a follow-up: inputs resolve |
| 492 | // to whole scalar topics here. |
| 493 | if (recipe.inputs.empty() || recipe.outputs.empty()) { |
| 494 | return PJ::unexpected("pj.data_processors: transform requires at least one input and one output"); |
| 495 | } |
| 496 | for (const std::string& out_name : recipe.outputs) { |
| 497 | if (out_name.empty()) { |
| 498 | return PJ::unexpected("pj.data_processors: transform output name must be non-empty"); |
| 499 | } |
| 500 | } |
| 501 | // 2. Backend (also the missing/unavailable-backend diagnostic on restore). |
| 502 | auto backend = inferTransformBackend(recipe.script); |
| 503 | if (!backend.has_value()) { |
| 504 | return PJ::unexpected(backend.error()); |
| 505 | } |
| 506 | recipe.backend = std::move(backend).value(); |
| 507 | // 3. Output-name collision, per output (excludes this transform's own outputs, so a replace is allowed). |
| 508 | for (const std::string& out_name : recipe.outputs) { |
| 509 | if (outputNameInUse(out_name, recipe.key)) { |
| 510 | return PJ::unexpected("pj.data_processors: output topic name already in use: " + out_name); |
| 511 | } |
| 512 | } |
| 513 | // 4. Resolve every input by name; the output topics are created in the first input's dataset. |
| 514 | std::vector<TopicId> input_topic_ids; |
| 515 | std::vector<std::size_t> input_columns; // leaf column per input (field-level binding) |
| 516 | input_topic_ids.reserve(recipe.inputs.size()); |
| 517 | input_columns.reserve(recipe.inputs.size()); |
| 518 | for (const std::string& in_name : recipe.inputs) { |
| 519 | const auto resolved = resolveInputField(in_name); |
| 520 | if (!resolved.has_value()) { |
| 521 | return PJ::unexpected("pj.data_processors: input topic/field not found: " + in_name); |
| 522 | } |
| 523 | if (input_topic_ids.empty()) { |
| 524 | recipe.dataset_id = resolved->dataset_id; |
| 525 | } |
| 526 | input_topic_ids.push_back(resolved->topic_id); |
| 527 | input_columns.push_back(resolved->column); |
| 528 | } |
| 529 | // Grouped output: a single structured "topic:f1,f2,..." entry collapses into ONE |
| 530 | // multi-column topic (parsed host-side, explicit — no prefix guessing). A grouped |
| 531 | // output is MIMO even when it is the only output entry, so it must not be mistaken |
| 532 | // for SISO, and its arity (M) is the count of declared fields. |
| 533 | const auto [mimo_group, mimo_fields] = parseGroupedOutput(recipe.outputs); |
| 534 | const bool grouped = !mimo_group.empty(); |
| 535 | const bool is_siso = recipe.inputs.size() == 1 && recipe.outputs.size() == 1 && !grouped; |
| 536 | const std::size_t mimo_num_outputs = grouped ? mimo_fields.size() : recipe.outputs.size(); |
| 537 | // 5. Compile BEFORE tearing down any old node, so a bad script never destroys a |
| 538 | // working transform. Use the catalogue's engine when installed, else a transient |
| 539 | // one (the built transform shares ownership of the engine, so it outlives it). |
| 540 | std::shared_ptr<proc::DataProcessor> siso_op; // set iff is_siso |
| 541 | std::unique_ptr<scripting::LuaMimoTransform> mimo_op; // set iff !is_siso |
| 542 | // Python transforms compile through a transient Python catalogue (the embedded |
| 543 | // interpreter is process-global). Luau reuses the installed catalogue. |
| 544 | const bool is_python = recipe.backend == "python"; |
| 545 | if (is_siso) { |
| 546 | auto compiled = is_python ? scripting::FilterCatalogue(scripting::makePythonEngine()) |
nothing calls this directly
no test coverage detected