enhancement
major
minor
major
minor
Uniqueness Constraint
Motivation
Applications routinely require that certain values are not entered twice. A project must not have two milestones with the same name, a contact's e-mail address should be unique, an order position number must be unique within its order. Today this has to be checked by hand-written constraints or custom code in every application. Since TopLogic is a general framework, this should be a declarative, model-level feature: the modeller states ''which combination of attributes must be unique'' and the framework enforces it and gives the user early feedback.
This is the object-model analog of a SQL UNIQUE index, but expressed in terms of the model (types, attributes, references) instead of tables and columns.
Declaration
A uniqueness constraint is a built-in constraint check declared on the primary attribute whose value is expected to be unique, using the general <constraints> annotation:
{{{#!xml <property name="name">
<annotations>
<constraints>
<unique additional-attributes="project"/>
</constraints>
</annotations>
</property> }}}
Additional attributes may participate in the constraint: the value of the annotated attribute must then be unique only among those objects that share the values of all additional attributes. Participating attributes may be data properties or to-one references — the two are treated uniformly, just as a SQL index mixes data columns and foreign-key columns.
This single mechanism covers all the usual cases:
- Single property — <unique/> on name: a simple globally unique value.
- Several properties — <unique additional-attributes="lastName"/> on firstName: a unique combination.
- Property plus reference — <unique additional-attributes="project"/> on name: unique within a scope. The "scope" (e.g. the container) is not a separate concept: it is simply a reference attribute participating in the constraint.
- References only — at most one link of a given shape between objects: for a membership type with references person and project, annotate one of the references, e.g. <unique additional-attributes="project"/> on person.
Violations are reported at the annotated (primary) attribute — in a form, at exactly the field the user is typing in. Because the declaration is a regular constraint check, it can also be declared in-app through the model editor, and a type="warning" variant shows the conflict in the form without preventing the commit.
Application value: early form feedback
The most important part for the end user is early validation in the form. When the user enters a value that would violate a uniqueness constraint, the affected field is marked invalid immediately (as the value is entered or the field is left), with a clear message — long before the change is committed. This is the visible benefit of the feature.
The form check:
- uses the values currently entered (not yet committed);
- excludes the edited object itself when checking an existing object (there is no "self" yet in a creation dialog);
- re-evaluates when a scoping attribute is changed in the same form (e.g. the user first picks the container, then types the name), not only when the constrained field itself changes.
Enforcement at commit
Independent of the form, the constraint is also enforced for changes that do not go through a form — imports, scripts, the API. The check at commit time is the integrity backstop: it runs inside the commit (before anything is written) and vetoes the transaction on a conflict. Because transactions are sequentialized at commit (to produce a gap-less revision order), the application-level check is race-free without further effort: no two conflicting values can be committed concurrently. Objects created or changed within the same transaction are checked against each other as well.
Form check and commit check share the same "find conflicting objects" implementation, so the two layers cannot drift apart.
Semantic decisions
- Null counts as a value. Unset participates in the constraint like any other value, so at most one object may have the empty (combination of) value(s). This is more intuitive for a model constraint than SQL's rule that multiple NULLs are distinct.
- To-many references cannot participate. A set-valued attribute has no single value to constrain; "unique over a set" has no clean meaning. Participation is restricted to data properties and to-one references.
- Inheritance. A constraint applies to the whole instance set of the type declaring it, including instances of subtypes. A subtype that overrides the constrained attribute inherits the constraint and stays part of the same uniqueness domain — an override made for an unrelated reason (a narrowed type, a different label) does not shrink the domain.
- Declaration on an override. Declaring the constraint on an overriding attribute instead of on its definition narrows the domain to the instances of the overriding type: those are then checked against each other only. Since a local <constraints> annotation replaces the inherited one instead of merging with it, declarations on sibling types constrain independent uniqueness domains.
In-app configuration
The additional attributes participating in a constraint are chosen from the attributes of the type owning the annotated attribute; to-many attributes are not offered, because they cannot participate.
The constraint types offered in the model editor are those annotated as in-app implementations, as for other configurable algorithms. An implementation that only serves as internal adapter for a model annotation of its own (the mandatory, size and range annotations) is therefore not offered as a separately configurable constraint, and the option list holds translated entries only.
Versioning
Uniqueness is meant per branch and for the current state: two objects alive at the same time in the same branch must not share the constrained values. Historic states are not affected — a value freed by a deletion may be reused later. (At the storage level this corresponds to including the branch and the revision-range marker in the lookup, exactly as the Knowledge Base's internal indices already do.)
Optional: DB-level enforcement (advanced DB mapping)
Application-level checks (form + commit) are sufficient for correctness and are the default. Where the constrained attributes are stored in real columns of the object's table, the equality test is already pushed down to an indexable database query. For an advanced database mapping, the constraint may additionally be backed by a real database unique index for efficiency:
- For monomorphic storage (all constrained instances in one table) the index can be placed directly on the storage table (over branch, the revision-range marker, and the participating columns).
- For polymorphic storage (instances spread over several tables) a single native index cannot span them. DB-level enforcement then requires a separate flat auxiliary table holding the participating values (plus branch / revision marker and the owning object), maintained on every commit, with the unique index on that table.
Such a flat auxiliary table, where present, doubles as a fast lookup path for the application-level checks (form and non-form mutations) and is the natural place to realise the "null counts as a value" semantics explicitly. It is, however, an optimisation — not required for the feature to work.