This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new fbf7733860 feat(gui): read the schema's constraints in the property 
panel (#8507)
fbf7733860 is described below

commit fbf773386066ca24c488258a966c175b9b756e1a
Author: Kary Zheng <[email protected]>
AuthorDate: Thu Sep 24 22:43:53 2026 +0000

    feat(gui): read the schema's constraints in the property panel (#8507)
    
    ### What changes were proposed in this PR?
    
    The property panel reads the constraints the schema declares.
    
    A column property is checked against the attribute type rule for every
    column it names rather than only the first. A hyperparameter's value
    renders as the control its parameter implies: a dropdown where the
    accepted set is known, a number input where it is numeric, a plain text
    box where the rules say nothing. A property declared unique among rows
    says which row already holds the value, and a row deleted clears the
    error it left behind, since deleting a row changes no surviving
    control's value and the row left behind would otherwise keep an error
    about a duplicate that is no longer there.
    
    #8350 is the export button; this one is a separate thread of work.
    
    ### Any related issues, documentation, discussions?
    
    Part of #8325, 26 of 27; that issue lists the set in order.
    
    Closes #8510, the task this change is the whole of.
    
    Supersedes #7983, #7980 and the frontend half of #7946, which are closed
    in favour of this. They touched the same three files, so reviewing them
    apart meant reading the same code more than once and resolving a
    conflict between them at merge.
    
    Closes #7981. Part of #7979, #7952 and #7936, whose remaining half is in
    #8348; those three stay open until both land, since each is only fixed
    once the schema declares the rule and the panel enforces it.
    
    ### How was this PR tested?
    
    `formly-utils.spec.ts` covers the mapping from a declared rule to a
    control and the type checking across every named column.
    `constrained-value.component.spec.ts` covers what each control renders.
    `operator-property-edit-frame.component.spec.ts` drives the rendered
    form, including the click that deletes a duplicate row and the error it
    has to clear.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5)
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 frontend/src/app/common/formly/formly-config.ts    |   2 +
 .../src/app/common/formly/formly-utils.spec.ts     | 323 ++++++++++++++++++++-
 frontend/src/app/common/formly/formly-utils.ts     | 133 ++++++++-
 .../constrained-value.component.spec.ts            | 136 +++++++++
 .../constrained-value.component.ts                 |  95 ++++++
 .../operator-property-edit-frame.component.spec.ts | 159 +++++++++-
 .../operator-property-edit-frame.component.ts      | 117 ++++++--
 .../types/custom-json-schema.interface.ts          |  35 +++
 8 files changed, 965 insertions(+), 35 deletions(-)

diff --git a/frontend/src/app/common/formly/formly-config.ts 
b/frontend/src/app/common/formly/formly-config.ts
index 788ed6a784..3d56e1f07c 100644
--- a/frontend/src/app/common/formly/formly-config.ts
+++ b/frontend/src/app/common/formly/formly-config.ts
@@ -30,6 +30,7 @@ import { ExposePropertyWrapperComponent } from 
"./expose-property-wrapper/expose
 import { EditableLabelWrapperComponent } from 
"./editable-label-wrapper/editable-label-wrapper.component";
 import { FormlyRepeatDndComponent } from "./repeat-dnd/repeat-dnd.component";
 import { UiUdfParametersComponent } from 
"../../workspace/component/ui-udf-parameters/ui-udf-parameters.component";
+import { ConstrainedValueComponent } from 
"../../workspace/component/constrained-value/constrained-value.component";
 import { DatasetVersionSelectorComponent } from 
"../../workspace/component/dataset-version-selector/dataset-version-selector.component";
 import { ResourceValueSelectorComponent } from 
"../../workspace/component/resource-value-selector/resource-value-selector.component";
 import { HuggingFaceImageUploadComponent } from 
"../../workspace/component/hugging-face-image-upload/hugging-face-image-upload.component";
@@ -91,6 +92,7 @@ export const TEXERA_FORMLY_CONFIG = {
     { name: "huggingface-image-upload", component: 
HuggingFaceImageUploadComponent, wrappers: ["form-field"] },
     { name: "repeat-section-dnd", component: FormlyRepeatDndComponent },
     { name: "ui-udf-parameters", component: UiUdfParametersComponent, 
wrappers: ["form-field"] },
+    { name: "constrainedvalue", component: ConstrainedValueComponent, 
wrappers: ["form-field"] },
     { name: "resourcevalue", component: ResourceValueSelectorComponent },
   ],
   wrappers: [
diff --git a/frontend/src/app/common/formly/formly-utils.spec.ts 
b/frontend/src/app/common/formly/formly-utils.spec.ts
index 4bec75a669..6f8feea682 100644
--- a/frontend/src/app/common/formly/formly-utils.spec.ts
+++ b/frontend/src/app/common/formly/formly-utils.spec.ts
@@ -17,14 +17,26 @@
  * under the License.
  */
 
-import { FormlyFieldConfig } from "@ngx-formly/core";
+import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
 import {
   createOutputFormChangeEventStream,
   createShouldHideFieldFunc,
+  createValueRulesValidator,
   getFieldByName,
+  matchingValueRule,
   setChildTypeDependency,
   setHideExpression,
+  setValueRules,
+  valueRulesValidationMessage,
 } from "./formly-utils";
+import { ValueRuleSet } from 
"../../workspace/types/custom-json-schema.interface";
+import { Component } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { AbstractControl, FormGroup, ReactiveFormsModule } from 
"@angular/forms";
+import { FormlyNgZorroAntdModule } from "@ngx-formly/ng-zorro-antd";
+import { TEXERA_FORMLY_CONFIG } from "./formly-config";
 import { Subject } from "rxjs";
 import { FORM_DEBOUNCE_TIME_MS } from 
"../../workspace/service/execute-workflow/execute-workflow.service";
 import { PortSchema } from 
"../../workspace/types/workflow-compiling.interface";
@@ -206,3 +218,312 @@ describe("createOutputFormChangeEventStream", () => {
     expect(modelCheck).toHaveBeenCalledTimes(2);
   });
 });
+
+describe("valueRules", () => {
+  // the shape the sklearn trainers emit: one branch per hyperparameter, keyed 
on the
+  // `parameter` chosen beside the value in the same row
+  const rules: ValueRuleSet = {
+    allOf: [
+      {
+        if: { parameter: { valEnum: ["C"] } },
+        then: { type: "number", exclusiveMinimum: 0, examples: ["1.0"] },
+      },
+      {
+        if: { parameter: { valEnum: ["degree"] } },
+        then: { type: "integer", minimum: 0, examples: ["3"] },
+      },
+      { if: { parameter: { valEnum: ["coef0"] } }, then: { type: "number", 
examples: ["0.0"] } },
+      // gamma takes either of two words or a number, which no type names
+      {
+        if: { parameter: { valEnum: ["gamma"] } },
+        then: { pattern: "^\\s*(?:scale|auto|[-+]?[0-9]*\\.?[0-9]+)\\s*$", 
examples: ["scale"] },
+      },
+      // an accepted set carries no example: it already names every value 
worth offering, and
+      // the estimator's own default leads
+      {
+        if: { parameter: { valEnum: ["kernel"] } },
+        then: { enum: ["rbf", "linear", "poly", "sigmoid", "precomputed"] },
+      },
+    ],
+  };
+
+  const rowField = (row: unknown): FormlyFieldConfig => ({ parent: { model: 
row } }) as FormlyFieldConfig;
+  const control = (value: unknown) => ({ value }) as any;
+  const check = (parameter: string, value: unknown) =>
+    createValueRulesValidator(rules)(control(value), rowField({ parameter }));
+
+  describe("matchingValueRule", () => {
+    it("selects the branch the sibling's value names", () => {
+      expect(matchingValueRule(rules, { parameter: "kernel" })?.enum).toEqual([
+        "rbf",
+        "linear",
+        "poly",
+        "sigmoid",
+        "precomputed",
+      ]);
+      expect(matchingValueRule(rules, { parameter: "degree" 
})?.type).toBe("integer");
+    });
+
+    it("selects nothing when the sibling holds a value no branch names", () => 
{
+      expect(matchingValueRule(rules, { parameter: "metric_params" 
})).toBeUndefined();
+    });
+
+    it("selects nothing before the row has a sibling value at all", () => {
+      expect(matchingValueRule(rules, {})).toBeUndefined();
+      expect(matchingValueRule(rules, undefined)).toBeUndefined();
+      expect(matchingValueRule(undefined, { parameter: "C" })).toBeUndefined();
+    });
+  });
+
+  describe("createValueRulesValidator", () => {
+    it("accepts a value the chosen parameter's set contains", () => {
+      expect(check("kernel", "rbf")).toBe(true);
+    });
+
+    it("rejects a value outside that set, including one of another 
parameter's", () => {
+      expect(check("kernel", "1")).toBe(false);
+      expect(check("kernel", "uniform")).toBe(false);
+    });
+
+    it("holds a numeric parameter to a number", () => {
+      expect(check("C", "1.0")).toBe(true);
+      // coef0 carries no bound, so it is where number-ness alone can be 
checked
+      expect(check("coef0", "-2.5e3")).toBe(true);
+      expect(check("C", "abc")).toBe(false);
+    });
+
+    it("holds a whole-number parameter to a whole number", () => {
+      expect(check("degree", "3")).toBe(true);
+      // int() raises on this, so the form should not let it reach the operator
+      expect(check("degree", "1.5")).toBe(false);
+      expect(check("coef0", "-1")).toBe(true);
+    });
+
+    it("leaves emptiness to the required rule rather than answering twice", () 
=> {
+      expect(check("C", "")).toBe(true);
+      expect(check("C", null)).toBe(true);
+      expect(check("kernel", undefined)).toBe(true);
+    });
+
+    it("accepts anything for a parameter no branch constrains", () => {
+      expect(check("metric_params", "whatever")).toBe(true);
+    });
+
+    it("holds a value to the bound the estimator puts on it", () => {
+      // C is open at zero, degree is closed at it, and coef0 has no bound at 
all
+      expect(check("C", "0")).toBe(false);
+      expect(check("C", "-1")).toBe(false);
+      expect(check("C", "0.0001")).toBe(true);
+      expect(check("degree", "0")).toBe(true);
+      expect(check("degree", "-1")).toBe(false);
+      expect(check("coef0", "-100")).toBe(true);
+    });
+
+    it("holds a parameter with a pattern to the shape it declares", () => {
+      // both halves of the union it describes
+      expect(check("gamma", "scale")).toBe(true);
+      expect(check("gamma", "auto")).toBe(true);
+      expect(check("gamma", "0.1")).toBe(true);
+      expect(check("gamma", " 1 ")).toBe(true);
+      expect(check("gamma", "abc")).toBe(false);
+      expect(check("gamma", "scaleauto")).toBe(false);
+    });
+
+    it("judges the same value against whichever parameter the row now holds", 
() => {
+      // a value typed for one parameter is usually wrong for the next, and 
stays visible. That
+      // the form asks again when the parameter changes is the rendered form's 
test below
+      expect(check("C", "1.0")).toBe(true);
+      expect(check("kernel", "1.0")).toBe(false);
+    });
+  });
+
+  describe("valueRulesValidationMessage", () => {
+    const field = (parameter: string): FormlyFieldConfig =>
+      ({ props: { valueRules: rules }, parent: { model: { parameter } } }) as 
FormlyFieldConfig;
+
+    it("names the accepted values when there is a set", () => {
+      expect(valueRulesValidationMessage(null, field("kernel"))).toBe(
+        "must be one of rbf, linear, poly, sigmoid, precomputed"
+      );
+    });
+
+    it("distinguishes a whole number from a number, and names the bound where 
there is one", () => {
+      expect(valueRulesValidationMessage(null, field("degree"))).toBe("must be 
a whole number of at least 0");
+      expect(valueRulesValidationMessage(null, field("C"))).toBe("must be a 
number greater than 0");
+      expect(valueRulesValidationMessage(null, field("coef0"))).toBe("must be 
a number");
+    });
+
+    it("points at a working value where a pattern is what the branch 
declares", () => {
+      expect(valueRulesValidationMessage(null, field("gamma"))).toBe(
+        "is not a value this parameter takes, such as scale"
+      );
+    });
+
+    it("says only what it knows when a pattern branch offers no example", () 
=> {
+      const noExample: ValueRuleSet = {
+        allOf: [{ if: { parameter: { valEnum: ["gamma"] } }, then: { pattern: 
"^scale$" } }],
+      };
+      const bare = { props: { valueRules: noExample }, parent: { model: { 
parameter: "gamma" } } };
+      expect(valueRulesValidationMessage(null, bare as 
FormlyFieldConfig)).toBe("is not a value this parameter takes");
+    });
+
+    it("falls back to the numeric wording when no branch applies at all", () 
=> {
+      // reached when the row's parameter changes between the check failing 
and the message
+      // being read, so the message must still say something rather than throw
+      expect(valueRulesValidationMessage(null, 
field("metric_params"))).toBe("must be a number");
+    });
+  });
+
+  /**
+   * Through a rendered form rather than a hand-made field, because what the 
field carries is
+   * only half of it: the other half is when Angular runs a validator, which 
is when the control
+   * carrying it changes and not when the parameter beside it does.
+   */
+  describe("setValueRules in a rendered form", () => {
+    /** One `paraList` row: the parameter dropdown and the value field that 
follows it. */
+    @Component({
+      standalone: true,
+      imports: [ReactiveFormsModule, FormlyModule],
+      template: `<form [formGroup]="form">
+        <formly-form
+          [form]="form"
+          [fields]="fields"
+          [model]="model"></formly-form>
+      </form>`,
+    })
+    class RowHost {
+      readonly form = new FormGroup({});
+      readonly model: Record<string, unknown> = {
+        paraList: [
+          { parameter: "C", value: "1.0" },
+          { parameter: "kernel", value: "rbf" },
+        ],
+      };
+      readonly fields: FormlyFieldConfig[] = [
+        {
+          key: "paraList",
+          type: "array",
+          fieldArray: {
+            fieldGroup: [
+              {
+                key: "parameter",
+                type: "enum",
+                props: {
+                  options: ["C", "degree", "gamma", "kernel", 
"metric_params"].map(p => ({ label: p, value: p })),
+                },
+              },
+              valueField,
+            ],
+          },
+        },
+      ];
+    }
+
+    let valueField: FormlyFieldConfig;
+    let fixture: ComponentFixture<RowHost>;
+    let parameter: AbstractControl;
+    let value: AbstractControl;
+
+    beforeEach(async () => {
+      valueField = { key: "value" };
+      setValueRules(valueField, rules);
+
+      await TestBed.configureTestingModule({
+        imports: [RowHost, NoopAnimationsModule, 
FormlyModule.forRoot(TEXERA_FORMLY_CONFIG), FormlyNgZorroAntdModule],
+      }).compileComponents();
+
+      fixture = TestBed.createComponent(RowHost);
+      fixture.detectChanges();
+      parameter = rowControl(0, "parameter");
+      value = rowControl(0, "value");
+    });
+
+    const rowControl = (row: number, key: string): AbstractControl =>
+      fixture.componentInstance.form.get(`paraList.${row}.${key}`)!;
+
+    /** What the user does: picks a parameter, leaving whatever value the row 
already held. */
+    const choose = (parameterName: string) => {
+      parameter.setValue(parameterName);
+      fixture.detectChanges();
+    };
+
+    it("gives the value field the control and the validator the rules call 
for", () => {
+      expect(valueField.type).toBe("constrainedvalue");
+      expect(value.valid).toBe(true);
+      
expect(fixture.debugElement.query(By.css("texera-constrained-value"))).not.toBeNull();
+    });
+
+    it("re-judges a value the row already holds when the parameter changes 
under it", () => {
+      // 1.0 is a C, and no kernel at all
+      expect(value.valid).toBe(true);
+
+      choose("kernel");
+
+      expect(value.valid).toBe(false);
+      expect(value.hasError("valueRules")).toBe(true);
+    });
+
+    it("clears the error once the parameter changes to one the value suits", 
() => {
+      choose("kernel");
+      expect(value.valid).toBe(false);
+
+      choose("gamma");
+
+      expect(value.valid).toBe(true);
+    });
+
+    it("re-judges when the new parameter constrains the value no rule did 
before", () => {
+      choose("metric_params");
+      value.setValue("1.5");
+      expect(value.valid).toBe(true);
+
+      choose("degree");
+
+      expect(value.valid).toBe(false);
+    });
+
+    it("leaves an empty value to the required rule whichever parameter it sits 
under", () => {
+      value.setValue("");
+      choose("kernel");
+      expect(value.valid).toBe(true);
+    });
+
+    it("gives the message of the parameter now chosen, not the one judged 
against", () => {
+      choose("kernel");
+      value.markAsTouched();
+      fixture.detectChanges();
+
+      expect(fixture.nativeElement.textContent).toContain("must be one of rbf, 
linear, poly, sigmoid, precomputed");
+    });
+
+    it("follows the parameter with the control the branch calls for", () => {
+      // scoped to the row's value field, so that neither the parameter's own 
dropdown nor the
+      // second row is what is seen
+      const valueControl = (selector: string) =>
+        
fixture.debugElement.queryAll(By.css("texera-constrained-value"))[0].query(By.css(selector));
+      
expect(valueControl("input[nz-input]").nativeElement.type).toBe("number");
+      expect(valueControl("nz-select")).toBeNull();
+
+      choose("kernel");
+
+      expect(valueControl("nz-select")).not.toBeNull();
+      expect(valueControl("input[nz-input]")).toBeNull();
+    });
+
+    it("re-judges only the row whose parameter changed", () => {
+      const otherValue = rowControl(1, "value");
+      expect(otherValue.valid).toBe(true);
+
+      choose("kernel");
+
+      // rbf is still a kernel, whatever the row above holds
+      expect(value.valid).toBe(false);
+      expect(otherValue.valid).toBe(true);
+
+      rowControl(1, "parameter").setValue("degree");
+      fixture.detectChanges();
+
+      expect(otherValue.valid).toBe(false);
+    });
+  });
+});
diff --git a/frontend/src/app/common/formly/formly-utils.ts 
b/frontend/src/app/common/formly/formly-utils.ts
index cb80abe2bd..15c25fb071 100644
--- a/frontend/src/app/common/formly/formly-utils.ts
+++ b/frontend/src/app/common/formly/formly-utils.ts
@@ -22,9 +22,10 @@ import { isDefined } from "../util/predicate";
 
 import { Observable } from "rxjs";
 import { FORM_DEBOUNCE_TIME_MS } from 
"../../workspace/service/execute-workflow/execute-workflow.service";
-import { debounceTime, distinctUntilChanged, filter, share } from 
"rxjs/operators";
-import { HideType } from "../../workspace/types/custom-json-schema.interface";
+import { debounceTime, distinctUntilChanged, filter, share, tap } from 
"rxjs/operators";
+import { HideType, ValueRuleSet } from 
"../../workspace/types/custom-json-schema.interface";
 import { PortSchema } from 
"../../workspace/types/workflow-compiling.interface";
+import { AbstractControl } from "@angular/forms";
 
 export function getFieldByName(fieldName: string, fields: 
FormlyFieldConfig[]): FormlyFieldConfig | undefined {
   return fields.filter((field, _, __) => field.key === fieldName)[0];
@@ -39,6 +40,134 @@ export function setHideExpression(toggleHidden: string[], 
fields: FormlyFieldCon
   });
 }
 
+type ValueRule = ValueRuleSet["allOf"][number]["then"];
+
+/**
+ * The one branch of `valueRules` that the row's current contents select, or 
undefined where
+ * none does. A branch names its sibling fields and the values of theirs it 
applies to, so the
+ * row model is what decides; `field.parent.model` is that row for an array 
item and the
+ * operator itself for a top-level field.
+ */
+export function matchingValueRule(rules: ValueRuleSet | undefined, rowModel: 
any): ValueRule | undefined {
+  if (!isDefined(rules) || !isDefined(rowModel)) {
+    return undefined;
+  }
+  return rules.allOf.find(branch =>
+    Object.entries(branch.if).every(([sibling, condition]) => 
(condition.valEnum ?? []).includes(rowModel[sibling]))
+  )?.then;
+}
+
+/**
+ * Validator holding a field to whichever branch of `valueRules` currently 
applies.
+ *
+ * An empty value passes: whether emptiness is allowed is `required`'s 
business, and a field
+ * that answers twice would report the wrong thing once. The numeric branches 
accept what
+ * JavaScript reads as a number, which is slightly narrower than the Python 
converters on the
+ * other end (they take `1_000` and `inf`); erring narrow here would be wrong 
for a field whose
+ * accepted set is open, but these two are bounded and the values it turns 
away are ones no one
+ * types into a hyperparameter.
+ */
+export function createValueRulesValidator(rules: ValueRuleSet) {
+  return (control: AbstractControl, field: FormlyFieldConfig): boolean => {
+    const rule = matchingValueRule(rules, field?.parent?.model);
+    if (!isDefined(rule)) {
+      return true;
+    }
+    const value = control.value;
+    if (value === null || value === undefined || value === "") {
+      return true;
+    }
+    const text = String(value).trim();
+    if (isDefined(rule.enum)) {
+      return rule.enum.includes(text);
+    }
+    if (isDefined(rule.pattern)) {
+      // anchored the way the declaration writes it, so the same expression 
judges the value
+      // here, in the operator's own tests and in the generated Python
+      return new RegExp(rule.pattern).test(String(value));
+    }
+    if (rule.type === "integer" && !/^[-+]?\d+$/.test(text)) {
+      return false;
+    }
+    if (rule.type === "number" && !(text.length > 0 && 
Number.isFinite(Number(text)))) {
+      return false;
+    }
+    if (isDefined(rule.type)) {
+      // the estimator's own bound, which it would otherwise raise on after 
the run started
+      const value = Number(text);
+      if (isDefined(rule.minimum) && value < rule.minimum) {
+        return false;
+      }
+      if (isDefined(rule.exclusiveMinimum) && value <= rule.exclusiveMinimum) {
+        return false;
+      }
+    }
+    return true;
+  };
+}
+
+/** Says what the field will take, naming the branch rather than the rule that 
rejected it. */
+export function valueRulesValidationMessage(_err: unknown, field: 
FormlyFieldConfig): string {
+  const rule = matchingValueRule(field?.props?.valueRules, 
field?.parent?.model);
+  if (isDefined(rule?.enum)) {
+    return `must be one of ${rule.enum.join(", ")}`;
+  }
+  if (isDefined(rule?.pattern)) {
+    // a pattern covers shapes no short phrase names, so point at a value that 
works instead
+    const example = rule.examples?.[0];
+    return isDefined(example)
+      ? `is not a value this parameter takes, such as ${example}`
+      : "is not a value this parameter takes";
+  }
+  const kind = rule?.type === "integer" ? "a whole number" : "a number";
+  if (isDefined(rule?.minimum)) {
+    return `must be ${kind} of at least ${rule.minimum}`;
+  }
+  if (isDefined(rule?.exclusiveMinimum)) {
+    return `must be ${kind} greater than ${rule.exclusiveMinimum}`;
+  }
+  return `must be ${kind}`;
+}
+
+/**
+ * Gives a field whose accepted values follow a sibling's both the control 
they call for and the
+ * validator holding it to them.
+ *
+ * Angular re-runs a validator only when the control carrying it changes, so 
the field has to be
+ * re-judged when the sibling deciding its rule changes. The hook reads 
formly's own event rather
+ * than the sibling control's because formly writes the row model before 
emitting, and the row
+ * model is what picks the branch.
+ */
+export function setValueRules(field: FormlyFieldConfig, rules: ValueRuleSet): 
void {
+  const siblings = new Set(rules.allOf.flatMap(branch => 
Object.keys(branch.if)));
+  field.type = "constrainedvalue";
+  // written into the existing object rather than over it: `props` and 
`templateOptions` are two
+  // names for one object, and replacing it leaves them pointing at different 
ones
+  field.props = field.props ?? {};
+  (field.props as Record<string, unknown>).valueRules = rules;
+  field.validators = {
+    ...field.validators,
+    valueRules: {
+      expression: createValueRulesValidator(rules),
+      message: valueRulesValidationMessage,
+    },
+  };
+  field.hooks = {
+    ...field.hooks,
+    // returned rather than subscribed, so that formly ends it with the field
+    onInit: valueField =>
+      valueField.options?.fieldChanges?.pipe(
+        filter(
+          change =>
+            change.type === "valueChanges" &&
+            change.field.parent === valueField.parent &&
+            siblings.has(String(change.field.key))
+        ),
+        tap(() => valueField.formControl?.updateValueAndValidity())
+      ),
+  };
+}
+
 /* Factory function to make functions that hide expressions for a particular 
field */
 export function createShouldHideFieldFunc(
   hideTarget: string,
diff --git 
a/frontend/src/app/workspace/component/constrained-value/constrained-value.component.spec.ts
 
b/frontend/src/app/workspace/component/constrained-value/constrained-value.component.spec.ts
new file mode 100644
index 0000000000..ea5221a073
--- /dev/null
+++ 
b/frontend/src/app/workspace/component/constrained-value/constrained-value.component.spec.ts
@@ -0,0 +1,136 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { FormControl } from "@angular/forms";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { FormlyFieldConfig } from "@ngx-formly/core";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ValueRuleSet } from "../../types/custom-json-schema.interface";
+import { ConstrainedValueComponent } from "./constrained-value.component";
+
+describe("ConstrainedValueComponent", () => {
+  // one branch of each shape a rule can take, keyed on the sibling `parameter`
+  const rules: ValueRuleSet = {
+    allOf: [
+      {
+        if: { parameter: { valEnum: ["kernel"] } },
+        then: { enum: ["rbf", "linear", "poly", "sigmoid", "precomputed"] },
+      },
+      { if: { parameter: { valEnum: ["C"] } }, then: { type: "number", 
examples: ["1.0"] } },
+      { if: { parameter: { valEnum: ["degree"] } }, then: { type: "integer", 
examples: ["3"] } },
+      {
+        if: { parameter: { valEnum: ["gamma"] } },
+        then: { pattern: "^\\s*(?:scale|auto|[-+]?[0-9]*\\.?[0-9]+)\\s*$", 
examples: ["scale"] },
+      },
+    ],
+  };
+
+  let fixture: ComponentFixture<ConstrainedValueComponent>;
+  let component: ConstrainedValueComponent;
+
+  /** Puts the component in the row a real `paraList` item would give it. */
+  const showFor = (parameter: string, value: string = ""): FormControl => {
+    const formControl = new FormControl(value);
+    (component as any).field = {
+      key: "value",
+      formControl,
+      props: { valueRules: rules },
+      parent: { model: { parameter } },
+    } as FormlyFieldConfig;
+    fixture.detectChanges();
+    return formControl;
+  };
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [ConstrainedValueComponent, NoopAnimationsModule],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(ConstrainedValueComponent);
+    component = fixture.componentInstance;
+  });
+
+  it("offers the accepted values as a dropdown when the parameter is chosen 
from a set", () => {
+    showFor("kernel");
+    expect(component.acceptedValues).toEqual(["rbf", "linear", "poly", 
"sigmoid", "precomputed"]);
+    expect(fixture.debugElement.query(By.css("nz-select"))).not.toBeNull();
+    // nz-select carries a hidden input of its own, so look for ours rather 
than for any
+    expect(fixture.debugElement.query(By.css("input[nz-input]"))).toBeNull();
+  });
+
+  it("gives a numeric parameter a number input instead", () => {
+    showFor("C");
+    expect(component.acceptedValues).toEqual([]);
+    expect(component.inputType).toBe("number");
+    expect(fixture.debugElement.query(By.css("nz-select"))).toBeNull();
+    
expect(fixture.debugElement.query(By.css("input[nz-input]")).nativeElement.type).toBe("number");
+  });
+
+  it("keeps a parameter described by a pattern on a text input, since it may 
hold a word", () => {
+    showFor("gamma");
+    expect(component.inputType).toBe("text");
+    
expect(fixture.debugElement.query(By.css("input[nz-input]")).nativeElement.type).toBe("text");
+  });
+
+  it("leaves a parameter no branch names as a plain text box", () => {
+    showFor("metric_params");
+    expect(component.acceptedValues).toEqual([]);
+    expect(component.inputType).toBe("text");
+  });
+
+  it("follows the row when the parameter beside it changes", () => {
+    showFor("kernel");
+    expect(fixture.debugElement.query(By.css("nz-select"))).not.toBeNull();
+
+    (component as any).field.parent.model.parameter = "C";
+    fixture.detectChanges();
+
+    expect(fixture.debugElement.query(By.css("nz-select"))).toBeNull();
+    
expect(fixture.debugElement.query(By.css("input[nz-input]")).nativeElement.type).toBe("number");
+  });
+
+  it("writes the control as a string whichever control produced the value", () 
=> {
+    const control = showFor("C");
+    // a number input yields a number once its text parses
+    component.write(0.1);
+    expect(control.value).toBe("0.1");
+    expect(typeof control.value).toBe("string");
+  });
+
+  it("writes an empty string when a dropdown is cleared", () => {
+    const control = showFor("kernel", "rbf");
+    component.write(null);
+    expect(control.value).toBe("");
+  });
+
+  it("marks the control touched so the error shows on the first bad value", () 
=> {
+    const control = showFor("C");
+    expect(control.touched).toBe(false);
+    component.write("abc");
+    expect(control.dirty).toBe(true);
+    expect(control.touched).toBe(true);
+  });
+
+  it("reads an unset control as an empty string rather than null", () => {
+    const control = showFor("C");
+    control.setValue(null);
+    expect(component.current).toBe("");
+  });
+});
diff --git 
a/frontend/src/app/workspace/component/constrained-value/constrained-value.component.ts
 
b/frontend/src/app/workspace/component/constrained-value/constrained-value.component.ts
new file mode 100644
index 0000000000..87e305458e
--- /dev/null
+++ 
b/frontend/src/app/workspace/component/constrained-value/constrained-value.component.ts
@@ -0,0 +1,95 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ChangeDetectionStrategy, Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core";
+import { NzInputModule } from "ng-zorro-antd/input";
+import { NzSelectModule } from "ng-zorro-antd/select";
+import { matchingValueRule } from "../../../common/formly/formly-utils";
+
+/**
+ * A field whose accepted values depend on what a sibling field holds: a 
chosen-from-a-set
+ * parameter renders as a dropdown, a numeric one as a number input, and 
anything the rules do
+ * not cover stays a plain text box.
+ *
+ * The value stays a string whichever control is showing. Operators that read 
one of these put
+ * the text through a converter of their own, so handing them a JSON number 
instead would only
+ * move the coercion somewhere less visible.
+ */
+@Component({
+  selector: "texera-constrained-value",
+  standalone: true,
+  imports: [CommonModule, FormsModule, FormlyModule, NzInputModule, 
NzSelectModule],
+  changeDetection: ChangeDetectionStrategy.Default,
+  template: `
+    <nz-select
+      *ngIf="acceptedValues.length > 0; else freeInput"
+      [ngModel]="current"
+      (ngModelChange)="write($event)"
+      [nzDisabled]="to.disabled ?? false"
+      nzAllowClear>
+      <nz-option
+        *ngFor="let accepted of acceptedValues"
+        [nzValue]="accepted"
+        [nzLabel]="accepted"></nz-option>
+    </nz-select>
+
+    <ng-template #freeInput>
+      <input
+        nz-input
+        [type]="inputType"
+        [disabled]="to.disabled ?? false"
+        [ngModel]="current"
+        (ngModelChange)="write($event)" />
+    </ng-template>
+  `,
+})
+export class ConstrainedValueComponent extends FieldType<FieldTypeConfig> {
+  /** The branch of the rules that the sibling's current value selects, if 
any. */
+  private get rule() {
+    return matchingValueRule(this.props.valueRules, this.field?.parent?.model);
+  }
+
+  get acceptedValues(): ReadonlyArray<string> {
+    return this.rule?.enum ?? [];
+  }
+
+  /** A number input where the rules call for a number, so a keyboard offers 
digits and the
+   * browser refuses most of what the converter would reject.
+   */
+  get inputType(): string {
+    return this.rule?.type === undefined ? "text" : "number";
+  }
+
+  get current(): string {
+    return this.formControl.value ?? "";
+  }
+
+  /** Writes the control as a string whatever the control was. `nz-select` 
clears to null and a
+   * number input yields a number once its value parses, and both reach an 
operator expecting
+   * text.
+   */
+  write(raw: unknown): void {
+    this.formControl.setValue(raw === null || raw === undefined ? "" : 
String(raw));
+    this.formControl.markAsDirty();
+    this.formControl.markAsTouched();
+  }
+}
diff --git 
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
 
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index 9cf755d575..dbb5dccbff 100644
--- 
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -29,7 +29,7 @@ import { FORM_DEBOUNCE_TIME_MS } from 
"../../../service/execute-workflow/execute
 import { DatePipe } from "@angular/common";
 import { By } from "@angular/platform-browser";
 import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
-import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from 
"@angular/forms";
+import { AbstractControl, FormControl, FormGroup, FormsModule, 
ReactiveFormsModule } from "@angular/forms";
 import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
 import { TEXERA_FORMLY_CONFIG } from "../../../../common/formly/formly-config";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
@@ -1905,6 +1905,62 @@ describe("OperatorPropertyEditFrameComponent", () => {
       vi.spyOn(compiling, 
"getOperatorInputAttributeType").mockReturnValue("string");
       expect(validator.expression({ value: { attr: "colA", mode: "loose" } } 
as any, rootField())).toBe(true);
     });
+
+    // A property that takes several columns holds a list of names, and the 
rule
+    // has to reach each of them rather than the list as a whole.
+    const multiColumnSchema: CustomJSONSchema7 = {
+      type: "object",
+      properties: { attrs: { type: "array", items: { type: "string" }, 
autofillAttributeOnPort: 0 } },
+      attributeTypeRules: { attrs: { enum: ["integer"] } },
+    };
+
+    it("enum rule passes when every column a multi-column property names 
matches", () => {
+      const validator = bindSchema(multiColumnSchema);
+      const spy = vi.spyOn(compiling, 
"getOperatorInputAttributeType").mockReturnValue("integer");
+
+      expect(validator.expression({ value: { attrs: ["colA", "colB"] } } as 
any, rootField())).toBe(true);
+      expect(spy).toHaveBeenCalledWith("attr-rules-op", 0, "colA");
+      expect(spy).toHaveBeenCalledWith("attr-rules-op", 0, "colB");
+    });
+
+    it("enum rule names the one column of a multi-column property that 
violates it", () => {
+      const validator = bindSchema(multiColumnSchema);
+      vi.spyOn(compiling, 
"getOperatorInputAttributeType").mockImplementation((_id, _port, name) =>
+        name === "colA" ? "integer" : "string"
+      );
+      const field = rootField();
+
+      expect(validator.expression({ value: { attrs: ["colA", "colB"] } } as 
any, field)).toBe(false);
+      expect((field as any).validators.checkAttributeType.message).toContain(
+        "The type of 'colB' is string, but it's expected to be integer"
+      );
+    });
+
+    it("enum rule names the timestamp among columns a trainer cannot fit 
together", () => {
+      // The shape the sklearn advanced trainers are in: a timestamp fits on 
its own but
+      // raises DTypePromotionError beside any other column, so the accepted 
set leaves it
+      // out and the warning has to name the timestamp rather than the numeric 
column.
+      const validator = bindSchema({
+        type: "object",
+        properties: { attrs: { type: "array", items: { type: "string" }, 
autofillAttributeOnPort: 0 } },
+        attributeTypeRules: { attrs: { enum: ["integer", "long", "double", 
"boolean"] } },
+      });
+      vi.spyOn(compiling, 
"getOperatorInputAttributeType").mockImplementation((_id, _port, name) =>
+        name === "when" ? "timestamp" : "double"
+      );
+      const field = rootField();
+
+      expect(validator.expression({ value: { attrs: ["amount", "when"] } } as 
any, field)).toBe(false);
+      expect((field as 
any).validators.checkAttributeType.message).toContain("The type of 'when' is 
timestamp");
+    });
+
+    it("enum rule is skipped when a multi-column property names nothing", () 
=> {
+      const validator = bindSchema(multiColumnSchema);
+      const spy = vi.spyOn(compiling, "getOperatorInputAttributeType");
+
+      expect(validator.expression({ value: { attrs: [] } } as any, 
rootField())).toBe(true);
+      expect(spy).not.toHaveBeenCalled();
+    });
   });
 
   // ──────────────────────────────────────────────────────────────────────────
@@ -1929,6 +1985,36 @@ describe("OperatorPropertyEditFrameComponent", () => {
       );
     });
 
+    it("adds a uniqueAmongRows validator that rejects a value another row 
already holds", () => {
+      component.setFormlyFormBinding({
+        type: "object",
+        properties: {
+          paraList: {
+            type: "array",
+            items: {
+              type: "object",
+              properties: { parameter: { type: "string", uniqueAmongRows: true 
} },
+            },
+          },
+        },
+      } as CustomJSONSchema7);
+      // A row's own fields exist only once formly is asked to build a row.
+      const arrayField = getField("paraList")!;
+      const rowField = (arrayField.fieldArray as (root: FormlyFieldConfig) => 
FormlyFieldConfig)(arrayField);
+      const validator = rowField.fieldGroup?.find(f => f.key === 
"parameter")?.validators?.["uniqueAmongRows"];
+      expect(validator).toBeDefined();
+
+      const twoRowsSettingC = {
+        key: "parameter",
+        parent: { parent: { model: [{ parameter: "C" }, { parameter: "C" }] } 
},
+      } as any;
+      expect(validator!.expression({ value: "C" } as any, 
twoRowsSettingC)).toBe(false);
+      expect(validator!.expression({ value: "kernel" } as any, 
twoRowsSettingC)).toBe(true);
+      expect(validator!.message(null, { formControl: { value: "C" } } as 
any)).toBe(
+        '"C" is already set by another row'
+      );
+    });
+
     it("maps datasetVersionPath to the datasetversionselector field type", () 
=> {
       component.setFormlyFormBinding({
         type: "object",
@@ -2333,6 +2419,77 @@ describe("OperatorPropertyEditFrameComponent", () => {
       setupPreview({ kind: "text", title: "T", pills: [] });
       
expect(realFixture.debugElement.query(By.css(".hf-task-preview-pills"))).toBeNull();
     });
+
+    // Two rows holding one parameter mark each other, so the row that 
resolves the duplicate
+    // has to clear the row it left behind. Only a rendered form has the 
second row to clear.
+    describe("uniqueAmongRows across rendered rows", () => {
+      function renderTwoRows(first: string, second: string): void {
+        // A form the frame holds locked is disabled, and Angular does not 
validate a disabled control.
+        realComponent.interactive = true;
+        realComponent.setFormlyFormBinding({
+          type: "object",
+          properties: {
+            paraList: {
+              type: "array",
+              items: {
+                type: "object",
+                properties: { parameter: { type: "string", uniqueAmongRows: 
true } },
+              },
+            },
+          },
+        } as CustomJSONSchema7);
+        realComponent.formData = { paraList: [{ parameter: first }, { 
parameter: second }] };
+        realFixture.detectChanges();
+      }
+
+      function rowControl(index: number): AbstractControl {
+        return realComponent.formlyFormGroup!.get(["paraList", String(index), 
"parameter"])!;
+      }
+
+      function typeIntoRow(index: number, parameter: string): void {
+        const input = 
realFixture.debugElement.queryAll(By.css("input"))[index].nativeElement as 
HTMLInputElement;
+        input.value = parameter;
+        input.dispatchEvent(new Event("input"));
+        realFixture.detectChanges();
+      }
+
+      it("marks both rows that name one parameter", () => {
+        renderTwoRows("C", "C");
+        
expect(realFixture.debugElement.queryAll(By.css("input")).length).toBe(2);
+        expect(rowControl(0).hasError("uniqueAmongRows")).toBe(true);
+        expect(rowControl(1).hasError("uniqueAmongRows")).toBe(true);
+      });
+
+      it("clears the row left behind when the other row picks a free 
parameter", () => {
+        renderTwoRows("C", "C");
+
+        typeIntoRow(1, "kernel");
+
+        expect(rowControl(0).hasError("uniqueAmongRows")).toBe(false);
+        expect(rowControl(1).hasError("uniqueAmongRows")).toBe(false);
+      });
+
+      it("clears the row left behind when the duplicate row is deleted", () => 
{
+        renderTwoRows("C", "C");
+        expect(rowControl(0).hasError("uniqueAmongRows")).toBe(true);
+
+        const removeButtons = 
realFixture.debugElement.queryAll(By.css("button[nzDanger]"));
+        removeButtons[1].nativeElement.click();
+        realFixture.detectChanges();
+
+        expect(rowControl(0).hasError("uniqueAmongRows")).toBe(false);
+      });
+
+      it("marks the row already holding the parameter a row is changed onto", 
() => {
+        renderTwoRows("C", "kernel");
+        expect(rowControl(0).hasError("uniqueAmongRows")).toBe(false);
+
+        typeIntoRow(1, "C");
+
+        expect(rowControl(0).hasError("uniqueAmongRows")).toBe(true);
+        expect(rowControl(1).hasError("uniqueAmongRows")).toBe(true);
+      });
+    });
   });
 
   describe("onFormChanges null handling", () => {
diff --git 
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
 
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
index 3fd8f1afcc..6825eac1a0 100644
--- 
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
+++ 
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
@@ -47,6 +47,7 @@ import {
   createShouldHideFieldFunc,
   setChildTypeDependency,
   setHideExpression,
+  setValueRules,
 } from "src/app/common/formly/formly-utils";
 import {
   TYPE_CASTING_OPERATOR_TYPE,
@@ -939,6 +940,12 @@ export class OperatorPropertyEditFrameComponent implements 
OnInit, OnChanges, On
         };
       }
 
+      // a field whose accepted values follow a sibling's: give it the control 
those values
+      // call for, and hold it to them before the workflow can be run
+      if (isDefined(mapSource.valueRules)) {
+        setValueRules(mappedField, mapSource.valueRules);
+      }
+
       // The custom widget this property renders as (file picker, model 
picker, uploaders, dataset
       // selector, code box, drag-reorder list). Extracted to 
customFormlyFieldType so a later view
       // (the Form View) renders the same control; each field's extra 
behaviour -- the task-driven
@@ -1226,6 +1233,43 @@ export class OperatorPropertyEditFrameComponent 
implements OnInit, OnChanges, On
         };
       }
 
+      // A field the schema marks unique holds a meaning the enclosing list 
cannot repeat.
+      // uniqueItems cannot say this: two hyperparameter rows naming one 
parameter differ in
+      // their other fields, so they are distinct items while still emitting 
one keyword twice.
+      if (mapSource.uniqueAmongRows === true) {
+        mappedField.validators.uniqueAmongRows = {
+          expression: (control: AbstractControl, field: FormlyFieldConfig) => {
+            const rows = field.parent?.parent?.model;
+            const key = field.key;
+            if (!isDefined(control?.value) || !Array.isArray(rows) || typeof 
key !== "string") {
+              return true;
+            }
+            return rows.filter(row => isDefined(row) && row[key] === 
control.value).length <= 1;
+          },
+          message: (error: any, field: FormlyFieldConfig) =>
+            `"${field.formControl?.value}" is already set by another row`,
+        };
+        // Whether a row repeats another is a property of the whole column, 
but Angular reruns a
+        // validator only on the control that changed. The list as a whole is 
watched instead, so
+        // that a row deleted counts as a change as much as a row edited: the 
row that resolves a
+        // duplicate, by any means, clears the one it left behind, and a row 
changed onto a
+        // parameter another row holds marks that row too.
+        mappedField.hooks = {
+          ...mappedField.hooks,
+          onInit: (field: FormlyFieldConfig) => {
+            field.parent?.parent?.formControl?.valueChanges
+              .pipe(untilDestroyed(this))
+              .subscribe(() =>
+                field.parent?.parent?.fieldGroup?.forEach(row =>
+                  row.fieldGroup
+                    ?.find(sibling => sibling.key === field.key)
+                    ?.formControl?.updateValueAndValidity({ emitEvent: false })
+                )
+              );
+          },
+        };
+      }
+
       // Add custom validators for attribute type
       if (isDefined(mapSource.attributeTypeRules)) {
         mappedField.validators.checkAttributeType = {
@@ -1240,7 +1284,18 @@ export class OperatorPropertyEditFrameComponent 
implements OnInit, OnChanges, On
               return true;
             }
 
-            const findAttributeType = (propertyName: string): AttributeType | 
undefined => {
+            // A property that takes several columns holds a list of names 
rather
+            // than one, so both shapes are read as a list here and each name 
is
+            // then checked on its own.
+            const selectedAttributeNames = (propertyName: string): string[] => 
{
+              const value = control.value[propertyName];
+              if (Array.isArray(value)) {
+                return value.filter(name => typeof name === "string");
+              }
+              return typeof value === "string" ? [value] : [];
+            };
+
+            const findAttributeType = (propertyName: string, attributeName: 
string): AttributeType | undefined => {
               if (
                 !isDefined(this.currentOperatorId) ||
                 !isDefined(mapSource.properties) ||
@@ -1252,7 +1307,6 @@ export class OperatorPropertyEditFrameComponent 
implements OnInit, OnChanges, On
               if (!isDefined(portIndex)) {
                 return undefined;
               }
-              const attributeName: string = control.value[propertyName];
               return 
this.workflowCompilingService.getOperatorInputAttributeType(
                 this.currentOperatorId,
                 portIndex,
@@ -1274,14 +1328,17 @@ export class OperatorPropertyEditFrameComponent 
implements OnInit, OnChanges, On
               if (!isDefined(data)) {
                 return;
               }
-              const dataAttributeType = findAttributeType(data);
+              // Every rule written so far compares against a single-column
+              // property, so the first name is that property's whole value.
+              const dataAttributeName = selectedAttributeNames(data)[0];
+              const dataAttributeType = isDefined(dataAttributeName)
+                ? findAttributeType(data, dataAttributeName)
+                : undefined;
               if (!isDefined(dataAttributeType)) {
                 // if data attribute type is not defined, then data attribute 
is not yet selected. skip validation
                 return;
               }
               if (inputAttributeType !== dataAttributeType) {
-                // get data attribute name for error message
-                const dataAttributeName = control.value[data];
                 throw TypeError(`it's expected to be the same type as 
'${dataAttributeName}' (${dataAttributeType}).`);
               }
             };
@@ -1322,21 +1379,30 @@ export class OperatorPropertyEditFrameComponent 
implements OnInit, OnChanges, On
             // Get the type of constrains for each property in 
AttributeTypeRuleSchema
 
             const checkConstraint = (propertyName: string, constraint: 
AttributeTypeRuleSet) => {
-              const inputAttributeType = findAttributeType(propertyName);
+              for (const attributeName of 
selectedAttributeNames(propertyName)) {
+                const inputAttributeType = findAttributeType(propertyName, 
attributeName);
 
-              if (!isDefined(inputAttributeType)) {
-                // when inputAttributeType is undefined, it means the property 
is not set
-                return;
-              }
-              if (isDefined(constraint.enum)) {
-                checkEnumConstraint(inputAttributeType, constraint.enum);
-              }
+                if (!isDefined(inputAttributeType)) {
+                  // when inputAttributeType is undefined, it means the 
property is not set
+                  continue;
+                }
+                try {
+                  if (isDefined(constraint.enum)) {
+                    checkEnumConstraint(inputAttributeType, constraint.enum);
+                  }
 
-              if (isDefined(constraint.const)) {
-                checkConstConstraint(inputAttributeType, constraint.const);
-              }
-              if (isDefined(constraint.allOf)) {
-                checkAllOfConstraint(inputAttributeType, constraint.allOf);
+                  if (isDefined(constraint.const)) {
+                    checkConstConstraint(inputAttributeType, constraint.const);
+                  }
+                  if (isDefined(constraint.allOf)) {
+                    checkAllOfConstraint(inputAttributeType, constraint.allOf);
+                  }
+                } catch (err) {
+                  // The checks above describe the expectation, and only this 
loop
+                  // knows which of several columns broke it.
+                  // @ts-ignore
+                  throw TypeError(`The type of '${attributeName}' is 
${inputAttributeType}, but ${err.message}`);
+                }
               }
             };
 
@@ -1345,22 +1411,11 @@ export class OperatorPropertyEditFrameComponent 
implements OnInit, OnChanges, On
               try {
                 checkConstraint(prop, constraint);
               } catch (err) {
-                // have to get the type, attribute name and property name again
-                // should consider reusing the part in findAttributeType()
-                const attributeName = control.value[prop];
-                const port = (mapSource.properties[prop] as 
CustomJSONSchema7).autofillAttributeOnPort as number;
-                const inputAttributeType = 
this.workflowCompilingService.getOperatorInputAttributeType(
-                  this.currentOperatorId,
-                  port,
-                  attributeName
-                );
-                // @ts-ignore
-                const message = err.message;
                 if (field.validators === undefined) {
                   field.validators = {};
                 }
-                field.validators.checkAttributeType.message =
-                  `Warning: The type of '${attributeName}' is 
${inputAttributeType}, but ` + message;
+                // @ts-ignore
+                field.validators.checkAttributeType.message = `Warning: 
${err.message}`;
                 return false;
               }
             }
diff --git a/frontend/src/app/workspace/types/custom-json-schema.interface.ts 
b/frontend/src/app/workspace/types/custom-json-schema.interface.ts
index 50edb68161..c89ac1be64 100644
--- a/frontend/src/app/workspace/types/custom-json-schema.interface.ts
+++ b/frontend/src/app/workspace/types/custom-json-schema.interface.ts
@@ -46,6 +46,37 @@ export type AttributeTypeRuleSchema = Readonly<{
   [key: string]: AttributeTypeRuleSet;
 }>;
 
+/**
+ * What one field may hold, given what a sibling holds. Borrows 
`attributeTypeRules`' grammar
+ * and, like it, sits under a key of Texera's own rather than as a JSON-Schema 
`allOf`: the
+ * form builder merges the members of an `allOf` into a single field, which 
would leave one
+ * control carrying every branch's constraints at once.
+ */
+export type ValueRuleSet = Readonly<{
+  allOf: ReadonlyArray<{
+    if: {
+      [siblingField: string]: {
+        valEnum?: string[];
+      };
+    };
+    then: {
+      // the accepted set, where the value is chosen from one
+      enum?: ReadonlyArray<string>;
+      // otherwise how the value is read, in JSON Schema's names, with the 
bound the estimator
+      // puts on it where it has one
+      type?: "number" | "integer";
+      minimum?: number;
+      exclusiveMinimum?: number;
+      // or, where the value is a choice between a set and a number and no 
type names it,
+      // the shape it takes
+      pattern?: string;
+      // a value that is accepted, for a reader that has to supply one; the 
form does not
+      // render it, the same as everywhere else `examples` is declared
+      examples?: ReadonlyArray<string>;
+    };
+  }>;
+}>;
+
 export interface CustomJSONSchema7 extends JSONSchema7 {
   propertyOrder?: number;
   properties?: {
@@ -57,6 +88,7 @@ export interface CustomJSONSchema7 extends JSONSchema7 {
   autofill?: "attributeName" | "attributeNameList";
   autofillAttributeOnPort?: number;
   attributeTypeRules?: AttributeTypeRuleSchema;
+  valueRules?: ValueRuleSet;
 
   "enable-presets"?: boolean; // include property in schema of preset
 
@@ -69,4 +101,7 @@ export interface CustomJSONSchema7 extends JSONSchema7 {
   hideOnNull?: boolean;
 
   additionalEnumValue?: string;
+
+  // no two rows of the enclosing list may hold the same value for this field
+  uniqueAmongRows?: boolean;
 }

Reply via email to