diff --git a/SysML2.NET.CodeGenerator.Tests/Expected/UML/AutoGenSql/schema.generated.sql b/SysML2.NET.CodeGenerator.Tests/Expected/UML/AutoGenSql/schema.generated.sql index 0f6f49dc0..df4aad9f1 100644 --- a/SysML2.NET.CodeGenerator.Tests/Expected/UML/AutoGenSql/schema.generated.sql +++ b/SysML2.NET.CodeGenerator.Tests/Expected/UML/AutoGenSql/schema.generated.sql @@ -55,6 +55,12 @@ CREATE TYPE sysml2.visibility_kind AS ENUM ('private', 'protected', 'public'); -- stale registration, fingerprint mismatch) instead of silently renumbering, and the seed -- INSERTs are idempotent (ON CONFLICT DO NOTHING) — safe to re-apply to a populated database. -- +-- Ids at or above 1000 are RESERVED for the Systems Modeling API Clause 7 data records +-- (ExternalData, ExternalRelationship, ProjectUsage). DataVersion.payload is typed Data, which +-- those records realize alongside Element, so they flow through the same identity/version +-- stream (§4, §5, §7b) — the band is how DDL tells a record row from an element row. They are +-- not metaclasses: the UML drift check skips them, and metaclass ids stay below the band. +-- -- The property->storage routing that earlier designs kept in a property_catalog table is -- deliberately NOT in the database: the generated service layer carries it as static -- per-release C# (model-version descriptors). Nothing in this schema reads such a catalog — @@ -262,7 +268,10 @@ INSERT INTO sysml2.class_kind (id, name, is_abstract, introduced_in, removed_in) (172, 'ViewUsage', false, 1, NULL), (173, 'ViewpointDefinition', false, 1, NULL), (174, 'ViewpointUsage', false, 1, NULL), - (175, 'WhileLoopActionUsage', false, 1, NULL) + (175, 'WhileLoopActionUsage', false, 1, NULL), + (1001, 'ExternalData', false, 1, NULL), + (1002, 'ExternalRelationship', false, 1, NULL), + (1003, 'ProjectUsage', false, 1, NULL) ON CONFLICT (id) DO NOTHING; ------------------------------------------------------------------------------------------------ -- 3. PIM — PROJECTS, COMMITS, BRANCHES, TAGS [HAND-WRITTEN] @@ -306,7 +315,8 @@ CREATE TABLE sysml2.commit ( -- stamp, never from the branch or the project. model_version_id smallint NOT NULL REFERENCES sysml2.model_version (id), - PRIMARY KEY (id) + PRIMARY KEY (id), + UNIQUE (id, project_id) -- FK target for project_usage_version's usedProject consistency check (§7b) ); CREATE INDEX ix_commit_project_created ON sysml2.commit (project_id, created DESC); @@ -483,14 +493,6 @@ CREATE TABLE sysml2.tag ( CREATE UNIQUE INDEX ux_tag_project_name_live ON sysml2.tag (project_id, name) WHERE deleted IS NULL; -CREATE TABLE sysml2.project_usage ( - id uuid NOT NULL, - project_id uuid NOT NULL REFERENCES sysml2.project (id) ON DELETE CASCADE, - used_project_id uuid NOT NULL REFERENCES sysml2.project (id), - used_project_commit_id uuid NOT NULL REFERENCES sysml2.commit (id), - PRIMARY KEY (id) -); - -- Query is the spec's stored-query record (Clause 7): a saved select/where/orderBy definition, -- executed on demand against a commit or branch head. Mutable + destructible — the API exposes -- PUT and DELETE for it. The definition is stored as the spec's own JSON shape; the service's @@ -520,7 +522,7 @@ CREATE UNIQUE INDEX ux_query_project_name_live -- This table is deliberately NOT partitioned and its PK is the bare uuid: ProjectUsage lets an -- element in project A reference an element in project B, so a (project_id, id) composite key -- would make every cross-project reference un-FK-able. Project scoping is enforced by the --- service layer via project_usage, not by the FK. +-- service layer via the usage resolved at the commit (project_usage_version, §7b), not by the FK. ------------------------------------------------------------------------------------------------ -- DELETION IS EXPLICIT, NOT CASCADED. At scale (~1M identities per project), FK cascades are a @@ -529,7 +531,14 @@ CREATE UNIQUE INDEX ux_query_project_name_live -- Project deletion is therefore an ordered, batched, per-table procedure (each statement prunes -- to one partition and uses a PK prefix): -- --- DELETE FROM sysml2. WHERE project_id = $1; +-- -- 0. While any commit of ANOTHER project pins this project through project_usage_version +-- -- (§7b), its NO ACTION FK onto commit blocks the procedure: the API answers 409. This +-- -- is the deleteProject behaviour chosen from the PIM's ProjectService note ("only if +-- -- none of their commits are referenced by project usages in any commit of any other +-- -- project"). Removing that usage history to proceed is an ADMINISTRATIVE PURGE — an +-- -- explicit exception to the append-only rule of §5 that also drops DataVersions from +-- -- the using project's commit change sets. +-- DELETE FROM sysml2. WHERE project_id = $1; -- §6, §7 and §7b -- DELETE FROM sysml2.derived_version WHERE project_id = $1; -- DELETE FROM sysml2.element_version WHERE project_id = $1; -- DELETE FROM sysml2.branch_head WHERE project_id = $1; @@ -561,7 +570,7 @@ CREATE TABLE sysml2.data_identity ( UNIQUE (id, class_kind) -- FK target for element_version's type-consistency check ); -CREATE INDEX ix_data_identity_project ON sysml2.data_identity (project_id); +CREATE INDEX ix_data_identity_project_kind ON sysml2.data_identity (project_id, class_kind); ------------------------------------------------------------------------------------------------ -- 5. STORED ELEMENT STATE — append-only [HAND-WRITTEN] @@ -608,8 +617,13 @@ CREATE TABLE sysml2.element_version ( CONSTRAINT element_version_tombstone_empty CHECK (NOT tombstone OR (stored_json IS NULL AND element_id IS NULL)), + -- API data records (class_kind >= 1000, §2 / §7b) carry stored_json only — never Element columns CONSTRAINT element_version_payload_present - CHECK (tombstone OR (stored_json IS NOT NULL AND element_id IS NOT NULL AND is_implied_included IS NOT NULL)) + CHECK (tombstone OR (stored_json IS NOT NULL + AND (class_kind >= 1000 OR (element_id IS NOT NULL AND is_implied_included IS NOT NULL)))), + CONSTRAINT element_version_api_record_shape + CHECK (class_kind < 1000 OR (element_id IS NULL AND declared_name IS NULL AND declared_short_name IS NULL + AND is_implied_included IS NULL AND owning_relationship IS NULL)) ) PARTITION BY HASH (project_id); -- Clause 7.1.2: "DataVersion.identity is unique among records listed in Commit.change." @@ -1311,6 +1325,70 @@ CREATE TABLE sysml2.requirement_definition_version ( REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE ) PARTITION BY HASH (project_id); +------------------------------------------------------------------------------------------------ +-- 7b. API DATA RECORDS — ExternalData, ExternalRelationship, ProjectUsage [HAND-WRITTEN] +-- +-- Systems Modeling API Clause 7: DataVersion.payload is typed Data, realized by Element AND by +-- these three records. The REST binding posts them inside Commit.change like any element, and +-- ProjectUsageService / ExternalRelationshipService read at a commit and write through a branch +-- (a new commit; the branch head moves). So a record is an identity (§4, class_kind in the +-- >= 1000 band of §2) plus element_version rows (§5: tombstone = deletion, stored_json = the +-- wire payload, no Element columns), and these subtype tables hold its typed columns exactly as +-- the generated §7 tables do for metaclasses. Everything downstream — CAS commit protocol, +-- branch_head overlay, checkpoints, resolvers, diff, merge, GET .../changes — applies unchanged. +-- +-- Deliberately NOT partitioned: a project holds a handful of usages and at most thousands of +-- external relationships; the (project_id, version_id) PK keeps every probe project-local. +-- +-- Read functions live in §10 (resolve_kind_at_commit and the get_*_at_commit family). +------------------------------------------------------------------------------------------------ + +CREATE TABLE sysml2.external_data_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + resource_identifier text NOT NULL, -- ExternalData::resourceIdentifier : IRI [1..1] + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE +); + +-- elementEnd names ANY Element identity — cross-project targets included, as for every reference +-- (§4); externalDataEnd names an ExternalData identity. Both are type-checked by §13 through the +-- typed identity, and reverse-indexed for getExternalRelationshipsByElement and §13's incoming arm. +CREATE TABLE sysml2.external_relationship_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + element_end uuid NOT NULL REFERENCES sysml2.data_identity (id), + external_data_end uuid NOT NULL REFERENCES sysml2.data_identity (id), + language text NULL, + specification text NULL, + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE +); + +CREATE INDEX ix_external_relationship_version_element_end + ON sysml2.external_relationship_version (project_id, element_end); +CREATE INDEX ix_external_relationship_version_external_data_end + ON sysml2.external_relationship_version (project_id, external_data_end); + +-- The PIM constraint usedProject = usedProjectCommit.owningProject is the composite FK onto +-- commit (id, project_id): a usage can never pin a commit of a different project. Both FKs are +-- NO ACTION, so a used project stays undeletable while any commit anywhere pins it (§4, step 0). +CREATE TABLE sysml2.project_usage_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + used_project uuid NOT NULL REFERENCES sysml2.project (id), + used_project_commit uuid NOT NULL REFERENCES sysml2.commit (id), + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE, + FOREIGN KEY (used_project_commit, used_project) REFERENCES sysml2.commit (id, project_id) +); + +CREATE INDEX ix_project_usage_version_used_project + ON sysml2.project_usage_version (used_project); + ------------------------------------------------------------------------------------------------ -- 8. DERIVED ELEMENT STATE — the second append-only stream [HAND-WRITTEN] -- @@ -1630,6 +1708,12 @@ $$; -- -- The normalized columns of §5-§7 are the system of record and carry the referential integrity; -- stored_json / derived_json are the read model built from them at commit time. +-- +-- API data records (§7b) are read by KIND: resolve_kind_at_commit folds one class_kind over the +-- checkpoint-bounded ancestry, driven from the few identities of that kind — O(records x log), +-- never O(model). Reads at a branch head go through branch.head_commit_id and that fold (no +-- overlay fast path; records are few). A record by id is get_element_at_commit — its payload is +-- the record's wire JSON. The element SET reads below return Elements only (class_kind < 1000). ------------------------------------------------------------------------------------------------ -- Branch-head single-element read over the OVERLAY: the overlay row wins (a tombstoned overlay @@ -1689,7 +1773,8 @@ AS $$ ON ev.project_id = h.project_id AND ev.version_id = h.version_id LEFT JOIN sysml2.derived_version dv ON dv.project_id = h.project_id AND dv.derived_id = h.derived_id - WHERE NOT ev.tombstone; + WHERE NOT ev.tombstone + AND ev.class_kind < 1000; $$; CREATE OR REPLACE FUNCTION sysml2.get_elements_at_commit(p_project_id uuid, p_commit_id uuid) @@ -1703,7 +1788,8 @@ AS $$ JOIN sysml2.element_version ev ON ev.project_id = p_project_id AND ev.version_id = r.version_id LEFT JOIN sysml2.derived_version dv - ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id; + ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id + WHERE ev.class_kind < 1000; $$; CREATE OR REPLACE FUNCTION sysml2.get_element_at_commit(p_project_id uuid, p_commit_id uuid, p_identity_id uuid) @@ -1719,6 +1805,131 @@ AS $$ ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id; $$; +-- Kind-filtered resolver for the API data records (§7b): the same checkpoint-bounded walk as +-- resolve_element_at_commit, folded over ONE class_kind. The checkpoint arm is driven from the +-- identities of that kind (ix_data_identity_project_kind) probing the commit_checkpoint PK, so +-- the cost is O(records x walked ancestry) — never a scan of the checkpoint. +CREATE OR REPLACE FUNCTION sysml2.resolve_kind_at_commit(p_project_id uuid, p_commit_id uuid, p_class_kind smallint) +RETURNS TABLE (identity_id uuid, version_id uuid) +LANGUAGE sql +STABLE +AS $$ + WITH RECURSIVE + checkpoint AS ( + SELECT ccr.commit_id + FROM sysml2.commit_checkpoint_registry ccr + WHERE ccr.project_id = p_project_id AND ccr.commit_id = p_commit_id + ), + ancestry AS ( + SELECT c.id, c.created, (SELECT count(*) > 0 FROM checkpoint) AS at_checkpoint + FROM sysml2.commit c + WHERE c.id = p_commit_id + + UNION + + SELECT parent.id, parent.created, + EXISTS (SELECT 1 FROM sysml2.commit_checkpoint_registry ccr + WHERE ccr.project_id = p_project_id AND ccr.commit_id = parent.id) + FROM ancestry a + JOIN sysml2.commit_parent cp ON cp.commit_id = a.id + JOIN sysml2.commit parent ON parent.id = cp.parent_commit_id + WHERE NOT a.at_checkpoint + ), + folded AS ( + SELECT DISTINCT ON (ev.identity_id) + ev.identity_id, + ev.version_id, + ev.tombstone + FROM ancestry a + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.commit_id = a.id AND ev.class_kind = p_class_kind + ORDER BY ev.identity_id, a.created DESC, a.id DESC + ), + checkpointed AS ( + SELECT cc.identity_id, cc.version_id + FROM sysml2.data_identity di + JOIN ancestry a ON a.at_checkpoint + JOIN sysml2.commit_checkpoint cc + ON cc.project_id = p_project_id AND cc.commit_id = a.id AND cc.identity_id = di.id + WHERE di.project_id = p_project_id + AND di.class_kind = p_class_kind + AND NOT EXISTS (SELECT 1 FROM folded f WHERE f.identity_id = di.id) + ) + SELECT f.identity_id, f.version_id FROM folded f WHERE NOT f.tombstone + UNION ALL + SELECT c.identity_id, c.version_id FROM checkpointed c; +$$; + +-- ProjectUsageService::getProjectUsages(project, commit). +CREATE OR REPLACE FUNCTION sysml2.get_project_usages_at_commit(p_project_id uuid, p_commit_id uuid) +RETURNS TABLE (identity_id uuid, used_project uuid, used_project_commit uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT r.identity_id, pu.used_project, pu.used_project_commit, ev.stored_json + FROM sysml2.resolve_kind_at_commit(p_project_id, p_commit_id, + (SELECT ck.id FROM sysml2.class_kind ck WHERE ck.name = 'ProjectUsage')) r + JOIN sysml2.project_usage_version pu + ON pu.project_id = p_project_id AND pu.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id; +$$; + +-- GET /projects/{p}/commits/{c}/elements/{e}/projectUsage: the usage, at the commit, whose used +-- project owns the element's identity. +CREATE OR REPLACE FUNCTION sysml2.get_project_usage_for_element_at_commit(p_project_id uuid, p_commit_id uuid, p_identity_id uuid) +RETURNS TABLE (identity_id uuid, used_project uuid, used_project_commit uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT pu.identity_id, pu.used_project, pu.used_project_commit, pu.payload + FROM sysml2.data_identity di + JOIN sysml2.get_project_usages_at_commit(p_project_id, p_commit_id) pu + ON pu.used_project = di.project_id + WHERE di.id = p_identity_id; +$$; + +-- ExternalRelationshipService::getExternalRelationships(project, commit). +CREATE OR REPLACE FUNCTION sysml2.get_external_relationships_at_commit(p_project_id uuid, p_commit_id uuid) +RETURNS TABLE (identity_id uuid, element_end uuid, external_data_end uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT r.identity_id, er.element_end, er.external_data_end, ev.stored_json + FROM sysml2.resolve_kind_at_commit(p_project_id, p_commit_id, + (SELECT ck.id FROM sysml2.class_kind ck WHERE ck.name = 'ExternalRelationship')) r + JOIN sysml2.external_relationship_version er + ON er.project_id = p_project_id AND er.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id; +$$; + +-- ExternalRelationshipService::getExternalRelationshipsByElement(project, commit, elementId): +-- candidates through the reverse index, each resolved at the commit; a candidate counts only +-- when its RESOLVED version still names the element. +CREATE OR REPLACE FUNCTION sysml2.get_external_relationships_by_element_at_commit(p_project_id uuid, p_commit_id uuid, p_element_id uuid) +RETURNS TABLE (identity_id uuid, element_end uuid, external_data_end uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + WITH candidates AS ( + SELECT DISTINCT ev.identity_id + FROM sysml2.external_relationship_version er + JOIN sysml2.element_version ev + ON ev.project_id = er.project_id AND ev.version_id = er.version_id + WHERE er.project_id = p_project_id + AND er.element_end = p_element_id + ) + SELECT r.identity_id, er.element_end, er.external_data_end, ev.stored_json + FROM candidates c + CROSS JOIN LATERAL sysml2.resolve_element_at_commit(p_project_id, p_commit_id, c.identity_id) r + JOIN sysml2.external_relationship_version er + ON er.project_id = p_project_id AND er.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id + WHERE er.element_end = p_element_id; +$$; + ------------------------------------------------------------------------------------------------ -- 11. PER-METACLASS FLATTENING VIEWS [GENERATED] -- @@ -3901,7 +4112,7 @@ $$; -- checked via the TYPED IDENTITY (data_identity.class_kind, §4) — this -- check applies to cross-project targets too. -- Liveness of cross-project targets is deliberately NOT checked here: it depends on the --- used-project commit (project_usage), which is service-layer resolution. +-- used-project commit (project_usage_version, §7b), which is service-layer resolution. -- -- TIER 1 — validate_references_at_commit: the FULL periodic audit over one commit's whole -- snapshot. It materializes and ANALYZEs the snapshot in a temp table first, so the planner @@ -3957,15 +4168,16 @@ BEGIN RETURN QUERY SELECT 'annotation_version'::text, 'annotated_element'::text, - snap.identity_id, src.annotated_element, 'dangling'::text + snap.identity_id, src.annotated_element, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.annotation_version src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.annotated_element LEFT JOIN validation_snapshot live ON live.identity_id = src.annotated_element WHERE src.project_id = p_project_id AND src.annotated_element IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'conjugated_port_typing_version'::text, 'conjugated_port_definition'::text, snap.identity_id, src.conjugated_port_definition, @@ -4016,26 +4228,28 @@ BEGIN OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'dependency_client'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.dependency_client src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'dependency_supplier'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.dependency_supplier src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'differencing_version'::text, 'differencing_type'::text, snap.identity_id, src.differencing_type, @@ -4170,15 +4384,16 @@ BEGIN OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'membership_version'::text, 'member_element'::text, - snap.identity_id, src.member_element, 'dangling'::text + snap.identity_id, src.member_element, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.membership_version src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.member_element LEFT JOIN validation_snapshot live ON live.identity_id = src.member_element WHERE src.project_id = p_project_id AND src.member_element IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'membership_import_version'::text, 'imported_membership'::text, snap.identity_id, src.imported_membership, @@ -4253,48 +4468,52 @@ BEGIN OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'relationship_version'::text, 'owning_related_element'::text, - snap.identity_id, src.owning_related_element, 'dangling'::text + snap.identity_id, src.owning_related_element, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.relationship_version src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.owning_related_element LEFT JOIN validation_snapshot live ON live.identity_id = src.owning_related_element WHERE src.project_id = p_project_id AND src.owning_related_element IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'relationship_owned_related_element'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.relationship_owned_related_element src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'relationship_source'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.relationship_source src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'relationship_target'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.relationship_target src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'specialization_version'::text, 'general'::text, snap.identity_id, src.general, @@ -4402,6 +4621,30 @@ BEGIN WHERE src.project_id = p_project_id AND src.unioning_type IS NOT NULL AND (ti.class_kind NOT IN (1, 2, 3, 5, 6, 7, 8, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 29, 30, 31, 34, 35, 36, 38, 39, 40, 43, 44, 45, 53, 54, 55, 56, 58, 59, 60, 64, 67, 68, 69, 70, 71, 72, 74, 75, 77, 78, 80, 81, 82, 84, 85, 86, 87, 88, 90, 91, 92, 93, 94, 95, 100, 101, 102, 103, 104, 105, 106, 107, 111, 113, 114, 115, 119, 120, 121, 122, 124, 125, 126, 129, 131, 132, 134, 135, 139, 140, 141, 144, 146, 147, 148, 152, 153, 154, 155, 156, 159, 160, 161, 164, 165, 166, 168, 169, 170, 172, 173, 174, 175) + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) + UNION ALL + SELECT 'external_relationship_version'::text, 'element_end'::text, + snap.identity_id, src.element_end, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END + FROM sysml2.external_relationship_version src + JOIN validation_snapshot snap ON snap.version_id = src.version_id + JOIN sysml2.data_identity ti ON ti.id = src.element_end + LEFT JOIN validation_snapshot live ON live.identity_id = src.element_end + WHERE src.project_id = p_project_id + AND src.element_end IS NOT NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) + UNION ALL + SELECT 'external_relationship_version'::text, 'external_data_end'::text, + snap.identity_id, src.external_data_end, + CASE WHEN ti.class_kind NOT IN (1001) THEN 'wrong-type' ELSE 'dangling' END + FROM sysml2.external_relationship_version src + JOIN validation_snapshot snap ON snap.version_id = src.version_id + JOIN sysml2.data_identity ti ON ti.id = src.external_data_end + LEFT JOIN validation_snapshot live ON live.identity_id = src.external_data_end + WHERE src.project_id = p_project_id + AND src.external_data_end IS NOT NULL + AND (ti.class_kind NOT IN (1001) OR (ti.project_id = p_project_id AND live.identity_id IS NULL)); END; $$; @@ -4430,7 +4673,7 @@ AS $$ FROM ( SELECT 'annotation_version'::text, 'annotated_element'::text, changed.identity_id, src.annotated_element, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.annotation_version src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -4439,8 +4682,9 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.annotated_element IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.annotated_element))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.annotated_element)))) UNION ALL SELECT 'conjugated_port_typing_version'::text, 'conjugated_port_definition'::text, changed.identity_id, src.conjugated_port_definition, @@ -4504,7 +4748,7 @@ AS $$ UNION ALL SELECT 'dependency_client'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.dependency_client src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -4513,12 +4757,13 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'dependency_supplier'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.dependency_supplier src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -4527,8 +4772,9 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'differencing_version'::text, 'differencing_type'::text, changed.identity_id, src.differencing_type, @@ -4695,7 +4941,7 @@ AS $$ UNION ALL SELECT 'membership_version'::text, 'member_element'::text, changed.identity_id, src.member_element, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.membership_version src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -4704,8 +4950,9 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.member_element IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.member_element))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.member_element)))) UNION ALL SELECT 'membership_import_version'::text, 'imported_membership'::text, changed.identity_id, src.imported_membership, @@ -4799,7 +5046,7 @@ AS $$ UNION ALL SELECT 'relationship_version'::text, 'owning_related_element'::text, changed.identity_id, src.owning_related_element, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.relationship_version src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -4808,12 +5055,13 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.owning_related_element IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.owning_related_element))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.owning_related_element)))) UNION ALL SELECT 'relationship_owned_related_element'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.relationship_owned_related_element src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -4822,12 +5070,13 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'relationship_source'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.relationship_source src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -4836,12 +5085,13 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'relationship_target'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.relationship_target src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -4850,8 +5100,9 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'specialization_version'::text, 'general'::text, changed.identity_id, src.general, @@ -4988,6 +5239,36 @@ AS $$ OR (ti.project_id = p_project_id AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.unioning_type)))) UNION ALL + SELECT 'external_relationship_version'::text, 'element_end'::text, + changed.identity_id, src.element_end, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END + FROM sysml2.element_version changed + JOIN sysml2.external_relationship_version src + ON src.project_id = changed.project_id AND src.version_id = changed.version_id + JOIN sysml2.data_identity ti ON ti.id = src.element_end + WHERE changed.project_id = p_project_id + AND changed.commit_id = p_commit_id + AND NOT changed.tombstone + AND src.element_end IS NOT NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.element_end)))) + UNION ALL + SELECT 'external_relationship_version'::text, 'external_data_end'::text, + changed.identity_id, src.external_data_end, + CASE WHEN ti.class_kind NOT IN (1001) THEN 'wrong-type' ELSE 'dangling' END + FROM sysml2.element_version changed + JOIN sysml2.external_relationship_version src + ON src.project_id = changed.project_id AND src.version_id = changed.version_id + JOIN sysml2.data_identity ti ON ti.id = src.external_data_end + WHERE changed.project_id = p_project_id + AND changed.commit_id = p_commit_id + AND NOT changed.tombstone + AND src.external_data_end IS NOT NULL + AND (ti.class_kind NOT IN (1001) + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.external_data_end)))) + UNION ALL SELECT 'annotation_version'::text, 'annotated_element'::text, holder.identity_id, dead.identity_id, 'dangling'::text FROM sysml2.element_version dead @@ -5479,6 +5760,32 @@ AS $$ AND dead.tombstone AND EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, holder.identity_id) alive WHERE alive.version_id = holder.version_id) + UNION ALL + SELECT 'external_relationship_version'::text, 'element_end'::text, + holder.identity_id, dead.identity_id, 'dangling'::text + FROM sysml2.element_version dead + JOIN sysml2.external_relationship_version src + ON src.project_id = dead.project_id AND src.element_end = dead.identity_id + JOIN sysml2.element_version holder + ON holder.project_id = src.project_id AND holder.version_id = src.version_id + WHERE dead.project_id = p_project_id + AND dead.commit_id = p_commit_id + AND dead.tombstone + AND EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, holder.identity_id) alive + WHERE alive.version_id = holder.version_id) + UNION ALL + SELECT 'external_relationship_version'::text, 'external_data_end'::text, + holder.identity_id, dead.identity_id, 'dangling'::text + FROM sysml2.element_version dead + JOIN sysml2.external_relationship_version src + ON src.project_id = dead.project_id AND src.external_data_end = dead.identity_id + JOIN sysml2.element_version holder + ON holder.project_id = src.project_id AND holder.version_id = src.version_id + WHERE dead.project_id = p_project_id + AND dead.commit_id = p_commit_id + AND dead.tombstone + AND EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, holder.identity_id) alive + WHERE alive.version_id = holder.version_id) ) AS findings (source_table, source_column, source_identity, target_identity, problem); $$; ------------------------------------------------------------------------------------------------ diff --git a/SysML2.NET.CodeGenerator.Tests/Expected/UML/Core/AutoGenEnum/ClassKind.cs b/SysML2.NET.CodeGenerator.Tests/Expected/UML/Core/AutoGenEnum/ClassKind.cs index 30c0dba27..41d5d61ea 100644 --- a/SysML2.NET.CodeGenerator.Tests/Expected/UML/Core/AutoGenEnum/ClassKind.cs +++ b/SysML2.NET.CodeGenerator.Tests/Expected/UML/Core/AutoGenEnum/ClassKind.cs @@ -32,6 +32,8 @@ namespace SysML2.NET.Core /// (SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistry.cs) and are /// frozen forever: never renumbered, never deleted. The canonical identity of a metaclass /// remains its name (the API @type value); this enum is the registry interning of it. + /// Values from 1000 upward are the Systems Modeling API Clause 7 data records, versioned + /// through the commit stream like elements. /// [GeneratedCode("SysML2.NET", "latest")] public enum ClassKind : short @@ -911,6 +913,21 @@ public enum ClassKind : short /// WhileLoopActionUsage = 175, + /// + /// The ExternalData API data record — frozen class_kind id 1001, introduced in sysml-2.0-2026-08. + /// + ExternalData = 1001, + + /// + /// The ExternalRelationship API data record — frozen class_kind id 1002, introduced in sysml-2.0-2026-08. + /// + ExternalRelationship = 1002, + + /// + /// The ProjectUsage API data record — frozen class_kind id 1003, introduced in sysml-2.0-2026-08. + /// + ProjectUsage = 1003, + } } diff --git a/SysML2.NET.CodeGenerator.Tests/Generators/UmlHandleBarsGenerators/SQLSchemaGeneratorTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/Generators/UmlHandleBarsGenerators/SQLSchemaGeneratorTestFixture.cs index fb1ca5999..34e9d379e 100644 --- a/SysML2.NET.CodeGenerator.Tests/Generators/UmlHandleBarsGenerators/SQLSchemaGeneratorTestFixture.cs +++ b/SysML2.NET.CodeGenerator.Tests/Generators/UmlHandleBarsGenerators/SQLSchemaGeneratorTestFixture.cs @@ -72,6 +72,18 @@ public async Task Verify_that_sql_schema_is_generated() Assert.That(generatedSchema, Does.Contain("CREATE OR REPLACE FUNCTION sysml2.validate_references_in_commit(")); Assert.That(generatedSchema, Does.Contain("ANALYZE validation_snapshot;"), "the full pass must feed the planner true snapshot cardinality"); Assert.That(generatedSchema, Does.Contain("'wrong-type'"), "the reference validation must type-check via the typed identity"); + Assert.That(generatedSchema, Does.Contain("(1003, 'ProjectUsage', false, 1, NULL)"), "the API data records are seeded from the registry's reserved band"); + Assert.That(generatedSchema, Does.Contain("UNIQUE (id, project_id)"), "commit must expose the FK target for the usedProject = usedProjectCommit.owningProject check"); + Assert.That(generatedSchema, Does.Contain("CREATE TABLE sysml2.external_data_version")); + Assert.That(generatedSchema, Does.Contain("CREATE TABLE sysml2.external_relationship_version")); + Assert.That(generatedSchema, Does.Contain("CREATE TABLE sysml2.project_usage_version")); + Assert.That(generatedSchema, Does.Contain("FOREIGN KEY (used_project_commit, used_project) REFERENCES sysml2.commit (id, project_id)")); + Assert.That(generatedSchema, Does.Contain("CREATE OR REPLACE FUNCTION sysml2.resolve_kind_at_commit(")); + Assert.That(generatedSchema, Does.Contain("CREATE OR REPLACE FUNCTION sysml2.get_project_usages_at_commit(")); + Assert.That(generatedSchema, Does.Contain("CREATE OR REPLACE FUNCTION sysml2.get_external_relationships_by_element_at_commit(")); + Assert.That(generatedSchema, Does.Contain("SELECT 'external_relationship_version'::text, 'external_data_end'::text,"), "the hand-written record reference columns must get validation arms"); + Assert.That(generatedSchema, Does.Contain("CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END"), "an Element-typed reference naming an API data record must be reported as wrong-type"); + Assert.That(generatedSchema, Does.Not.Contain("CREATE TABLE sysml2.project_usage ("), "project usages are versioned through the commit stream, not stored as a flat PIM row"); Assert.That(generatedSchema, Does.Not.Contain("CREATE TABLE sysml2.property_catalog"), "the property->storage routing lives in generated C#, not in the database"); Assert.That(generatedSchema, Does.Not.Contain("CREATE TABLE sysml2.class_kind_table"), "subtype-table participation lives in generated C#, not in the database"); Assert.That(generatedSchema, Does.Not.Contain("{{"), "no unresolved handlebars expressions may survive generation"); diff --git a/SysML2.NET.CodeGenerator.Tests/Generators/UmlHandleBarsGenerators/SqlSchemaSmokeTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/Generators/UmlHandleBarsGenerators/SqlSchemaSmokeTestFixture.cs index 5ac0ef04e..f8ec5b462 100644 --- a/SysML2.NET.CodeGenerator.Tests/Generators/UmlHandleBarsGenerators/SqlSchemaSmokeTestFixture.cs +++ b/SysML2.NET.CodeGenerator.Tests/Generators/UmlHandleBarsGenerators/SqlSchemaSmokeTestFixture.cs @@ -86,6 +86,8 @@ public async Task Verify_that_smoke_test_passes_against_generated_schema() Assert.That(passNotices, Has.Some.StartWith("PASS 2a"), "the derived-state axiom assertion must be present"); Assert.That(passNotices, Has.Some.StartWith("PASS 11a"), "the multi-version registry assertion must be present"); Assert.That(passNotices, Has.Some.StartWith("PASS 13c"), "the incremental reference-validation assertion must be present"); + Assert.That(passNotices, Has.Some.StartWith("PASS 16e"), "the ProjectUsageService commit-stream assertions must be present"); + Assert.That(passNotices, Has.Some.StartWith("PASS 28e"), "the ExternalRelationshipService commit-stream assertions must be present"); } } } diff --git a/SysML2.NET.CodeGenerator.Tests/HandleBarHelpers/SqlSchemaHelpersTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/HandleBarHelpers/SqlSchemaHelpersTestFixture.cs new file mode 100644 index 000000000..a98b3afaf --- /dev/null +++ b/SysML2.NET.CodeGenerator.Tests/HandleBarHelpers/SqlSchemaHelpersTestFixture.cs @@ -0,0 +1,111 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright (C) 2022-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.CodeGenerator.Tests.HandleBarHelpers +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading.Tasks; + + using NUnit.Framework; + + using SysML2.NET.CodeGenerator.Generators.UmlHandleBarsGenerators; + using SysML2.NET.CodeGenerator.HandleBarHelpers; + using SysML2.NET.CodeGenerator.Tests.Generators.UmlHandleBarsGenerators; + + using uml4net.xmi.Readers; + + [TestFixture] + public class SqlSchemaHelpersTestFixture + { + private HandlebarsPayload payload; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + this.payload = PayloadGenerator.Create(GeneratorSetupFixture.XmiReaderResult); + } + + [Test] + public void VerifyAssertRegistryInSyncWithModel() + { + var modelVersions = ClassKindRegistry.ModelVersions; + var classKinds = ClassKindRegistry.ClassKinds; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(null), Throws.TypeOf()); + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, null, classKinds), Throws.TypeOf()); + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, modelVersions, null), Throws.TypeOf()); + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload), Throws.Nothing); + + IReadOnlyList unreleasedVersions = [.. modelVersions, new(2, "next-release", "SysML:_unreleased")]; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, unreleasedVersions, classKinds), + Throws.TypeOf().With.Message.Contains("fingerprint")); + + IReadOnlyList metaclassInRecordBand = [.. classKinds, new(2001, "StrayMetaclass", false, 1)]; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, modelVersions, metaclassInRecordBand), + Throws.TypeOf().With.Message.Contains("StrayMetaclass")); + + IReadOnlyList recordBelowBand = [.. classKinds, new(999, "StrayRecord", false, 1, null, true)]; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, modelVersions, recordBelowBand), + Throws.TypeOf().With.Message.Contains("reserved for API data records")); + + IReadOnlyList withoutPartUsage = [.. classKinds.Where(registration => registration.Name != "PartUsage")]; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, modelVersions, withoutPartUsage), + Throws.TypeOf().With.Message.Contains("new(176, \"PartUsage\", false, 1),")); + + IReadOnlyList withRetiredMetaclass = [.. classKinds, new(176, "RetiredMetaclass", false, 1)]; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, modelVersions, withRetiredMetaclass), + Throws.TypeOf().With.Message.Contains("RetiredMetaclass")); + + IReadOnlyList withClosedMetaclass = [.. classKinds, new(176, "RetiredMetaclass", false, 1, 1)]; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, modelVersions, withClosedMetaclass), Throws.Nothing); + + IReadOnlyList recordNamedLikeMetaclass = [.. classKinds, new(1004, "Package", false, 1, null, true)]; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, modelVersions, recordNamedLikeMetaclass), + Throws.TypeOf().With.Message.Contains("also declares as metaclasses: Package")); + + IReadOnlyList driftedAbstractness = + [.. classKinds.Select(registration => registration.Name == "Element" ? registration with { IsAbstract = false } : registration)]; + + Assert.That(() => SqlSchemaHelpers.AssertRegistryInSyncWithModel(this.payload, modelVersions, driftedAbstractness), + Throws.TypeOf().With.Message.Contains("abstractness of Element")); + } + + private sealed class PayloadGenerator : UmlHandleBarsGenerator + { + public static HandlebarsPayload Create(XmiReaderResult xmiReaderResult) + { + return CreateHandlebarsPayload(xmiReaderResult); + } + + public override Task GenerateAsync(XmiReaderResult xmiReaderResult, DirectoryInfo outputDirectory) + { + throw new NotSupportedException(); + } + + protected override void RegisterHelpers() + { + } + + protected override void RegisterTemplates() + { + } + } + } +} diff --git a/SysML2.NET.CodeGenerator.Tests/Sql/SQL.md b/SysML2.NET.CodeGenerator.Tests/Sql/SQL.md index ee5d1dea7..bfe5c0ad3 100644 --- a/SysML2.NET.CodeGenerator.Tests/Sql/SQL.md +++ b/SysML2.NET.CodeGenerator.Tests/Sql/SQL.md @@ -42,7 +42,7 @@ so the scripts always run against the live generator output, never against the e | File | Role | Consumed by | |---|---|---| -| `Sql/schema.smoke.sql` | End-to-end functional scenario on a clean database: create, rename, tombstone, merge, checkpoint, conversion commit, reference validation, tags, project usages, stored queries, diff, deletion ordering and soft delete. Every check raises on failure and emits a `RAISE NOTICE 'PASS …'` on success. | `SqlSchemaSmokeTestFixture` (`[Category("Integration")]`). The fixture counts the `PASS` notices declared in the script and asserts the same number came back, so adding a check to the script never requires touching the fixture. | +| `Sql/schema.smoke.sql` | End-to-end functional scenario on a clean database: create, rename, tombstone, merge, checkpoint, conversion commit, reference validation, tags, project usages and external relationships (the API data records of the commit stream), stored queries, diff, deletion ordering and soft delete. Every check raises on failure and emits a `RAISE NOTICE 'PASS …'` on success. | `SqlSchemaSmokeTestFixture` (`[Category("Integration")]`). The fixture counts the `PASS` notices declared in the script and asserts the same number came back, so adding a check to the script never requires touching the fixture. | | `Sql/schema.concurrency.setup.sql` | Seeds one project with 1,000 elements, a checkpointed base commit and 16 branches, and installs two test-only functions: `sysml2.bench_try_commit(branch_index, seed)` (one compare-and-swap commit attempt) and `sysml2.bench_read(n)` (one branch-head element read). | `SqlSchemaConcurrencyTestFixture` (`[Category("Integration")]`) runs it once after installing the schema. Also the first step of a manual `pgbench` session. | | `Sql/schema.concurrency.verify.sql` | Asserts the invariants C1 to C5 the commit protocol promises under contention (no accidental merges, every head walks back to the seed, no lost updates, attempts equal wins plus losses, overlay rows stay on their own branch) and reports the tallies. | `SqlSchemaConcurrencyTestFixture` runs it after every scenario. Also the last step of a manual `pgbench` session. | diff --git a/SysML2.NET.CodeGenerator.Tests/Sql/schema.golden.sql b/SysML2.NET.CodeGenerator.Tests/Sql/schema.golden.sql index 66d2e05aa..0cde93f5d 100644 --- a/SysML2.NET.CodeGenerator.Tests/Sql/schema.golden.sql +++ b/SysML2.NET.CodeGenerator.Tests/Sql/schema.golden.sql @@ -76,6 +76,12 @@ CREATE TYPE sysml2.visibility_kind AS ENUM ('private', 'protected' -- stale registration, fingerprint mismatch) instead of silently renumbering, and the seed -- INSERTs are idempotent (ON CONFLICT DO NOTHING) — safe to re-apply to a populated database. -- +-- Ids at or above 1000 are RESERVED for the Systems Modeling API Clause 7 data records +-- (ExternalData, ExternalRelationship, ProjectUsage). DataVersion.payload is typed Data, which +-- those records realize alongside Element, so they flow through the same identity/version +-- stream (§4, §5, §7b) — the band is how DDL tells a record row from an element row. They are +-- not metaclasses: the UML drift check skips them, and metaclass ids stay below the band. +-- -- The property->storage routing that earlier designs kept in a property_catalog table is -- deliberately NOT in the database: the generated service layer carries it as static -- per-release C# (model-version descriptors). Nothing in this schema reads such a catalog — @@ -109,7 +115,9 @@ CREATE TABLE sysml2.class_kind ( -- ON CONFLICT (id) DO NOTHING; -- -- INSERT INTO sysml2.class_kind (id, name, is_abstract, introduced_in, removed_in) VALUES --- (1, 'AcceptActionUsage', false, 1, NULL), ..., (120, 'PartUsage', false, 1, NULL), ... +-- (1, 'AcceptActionUsage', false, 1, NULL), ..., (120, 'PartUsage', false, 1, NULL), ..., +-- (1001, 'ExternalData', false, 1, NULL), (1002, 'ExternalRelationship', false, 1, NULL), +-- (1003, 'ProjectUsage', false, 1, NULL) -- ON CONFLICT (id) DO NOTHING; ------------------------------------------------------------------------------------------------ @@ -154,7 +162,8 @@ CREATE TABLE sysml2.commit ( -- stamp, never from the branch or the project. model_version_id smallint NOT NULL REFERENCES sysml2.model_version (id), - PRIMARY KEY (id) + PRIMARY KEY (id), + UNIQUE (id, project_id) -- FK target for project_usage_version's usedProject consistency check (§7b) ); CREATE INDEX ix_commit_project_created ON sysml2.commit (project_id, created DESC); @@ -331,14 +340,6 @@ CREATE TABLE sysml2.tag ( CREATE UNIQUE INDEX ux_tag_project_name_live ON sysml2.tag (project_id, name) WHERE deleted IS NULL; -CREATE TABLE sysml2.project_usage ( - id uuid NOT NULL, - project_id uuid NOT NULL REFERENCES sysml2.project (id) ON DELETE CASCADE, - used_project_id uuid NOT NULL REFERENCES sysml2.project (id), - used_project_commit_id uuid NOT NULL REFERENCES sysml2.commit (id), - PRIMARY KEY (id) -); - -- Query is the spec's stored-query record (Clause 7): a saved select/where/orderBy definition, -- executed on demand against a commit or branch head. Mutable + destructible — the API exposes -- PUT and DELETE for it. The definition is stored as the spec's own JSON shape; the service's @@ -368,7 +369,7 @@ CREATE UNIQUE INDEX ux_query_project_name_live -- This table is deliberately NOT partitioned and its PK is the bare uuid: ProjectUsage lets an -- element in project A reference an element in project B, so a (project_id, id) composite key -- would make every cross-project reference un-FK-able. Project scoping is enforced by the --- service layer via project_usage, not by the FK. +-- service layer via the usage resolved at the commit (project_usage_version, §7b), not by the FK. ------------------------------------------------------------------------------------------------ -- DELETION IS EXPLICIT, NOT CASCADED. At scale (~1M identities per project), FK cascades are a @@ -377,7 +378,14 @@ CREATE UNIQUE INDEX ux_query_project_name_live -- Project deletion is therefore an ordered, batched, per-table procedure (each statement prunes -- to one partition and uses a PK prefix): -- --- DELETE FROM sysml2. WHERE project_id = $1; +-- -- 0. While any commit of ANOTHER project pins this project through project_usage_version +-- -- (§7b), its NO ACTION FK onto commit blocks the procedure: the API answers 409. This +-- -- is the deleteProject behaviour chosen from the PIM's ProjectService note ("only if +-- -- none of their commits are referenced by project usages in any commit of any other +-- -- project"). Removing that usage history to proceed is an ADMINISTRATIVE PURGE — an +-- -- explicit exception to the append-only rule of §5 that also drops DataVersions from +-- -- the using project's commit change sets. +-- DELETE FROM sysml2. WHERE project_id = $1; -- §6, §7 and §7b -- DELETE FROM sysml2.derived_version WHERE project_id = $1; -- DELETE FROM sysml2.element_version WHERE project_id = $1; -- DELETE FROM sysml2.branch_head WHERE project_id = $1; @@ -409,7 +417,7 @@ CREATE TABLE sysml2.data_identity ( UNIQUE (id, class_kind) -- FK target for element_version's type-consistency check ); -CREATE INDEX ix_data_identity_project ON sysml2.data_identity (project_id); +CREATE INDEX ix_data_identity_project_kind ON sysml2.data_identity (project_id, class_kind); ------------------------------------------------------------------------------------------------ -- 5. STORED ELEMENT STATE — append-only [HAND-WRITTEN] @@ -456,8 +464,13 @@ CREATE TABLE sysml2.element_version ( CONSTRAINT element_version_tombstone_empty CHECK (NOT tombstone OR (stored_json IS NULL AND element_id IS NULL)), + -- API data records (class_kind >= 1000, §2 / §7b) carry stored_json only — never Element columns CONSTRAINT element_version_payload_present - CHECK (tombstone OR (stored_json IS NOT NULL AND element_id IS NOT NULL AND is_implied_included IS NOT NULL)) + CHECK (tombstone OR (stored_json IS NOT NULL + AND (class_kind >= 1000 OR (element_id IS NOT NULL AND is_implied_included IS NOT NULL)))), + CONSTRAINT element_version_api_record_shape + CHECK (class_kind < 1000 OR (element_id IS NULL AND declared_name IS NULL AND declared_short_name IS NULL + AND is_implied_included IS NULL AND owning_relationship IS NULL)) ) PARTITION BY HASH (project_id); -- Clause 7.1.2: "DataVersion.identity is unique among records listed in Commit.change." @@ -1092,6 +1105,70 @@ CREATE TABLE sysml2.trigger_invocation_expression_version ( REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE ) PARTITION BY HASH (project_id); +------------------------------------------------------------------------------------------------ +-- 7b. API DATA RECORDS — ExternalData, ExternalRelationship, ProjectUsage [HAND-WRITTEN] +-- +-- Systems Modeling API Clause 7: DataVersion.payload is typed Data, realized by Element AND by +-- these three records. The REST binding posts them inside Commit.change like any element, and +-- ProjectUsageService / ExternalRelationshipService read at a commit and write through a branch +-- (a new commit; the branch head moves). So a record is an identity (§4, class_kind in the +-- >= 1000 band of §2) plus element_version rows (§5: tombstone = deletion, stored_json = the +-- wire payload, no Element columns), and these subtype tables hold its typed columns exactly as +-- the generated §7 tables do for metaclasses. Everything downstream — CAS commit protocol, +-- branch_head overlay, checkpoints, resolvers, diff, merge, GET .../changes — applies unchanged. +-- +-- Deliberately NOT partitioned: a project holds a handful of usages and at most thousands of +-- external relationships; the (project_id, version_id) PK keeps every probe project-local. +-- +-- Read functions live in §10 (resolve_kind_at_commit and the get_*_at_commit family). +------------------------------------------------------------------------------------------------ + +CREATE TABLE sysml2.external_data_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + resource_identifier text NOT NULL, -- ExternalData::resourceIdentifier : IRI [1..1] + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE +); + +-- elementEnd names ANY Element identity — cross-project targets included, as for every reference +-- (§4); externalDataEnd names an ExternalData identity. Both are type-checked by §13 through the +-- typed identity, and reverse-indexed for getExternalRelationshipsByElement and §13's incoming arm. +CREATE TABLE sysml2.external_relationship_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + element_end uuid NOT NULL REFERENCES sysml2.data_identity (id), + external_data_end uuid NOT NULL REFERENCES sysml2.data_identity (id), + language text NULL, + specification text NULL, + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE +); + +CREATE INDEX ix_external_relationship_version_element_end + ON sysml2.external_relationship_version (project_id, element_end); +CREATE INDEX ix_external_relationship_version_external_data_end + ON sysml2.external_relationship_version (project_id, external_data_end); + +-- The PIM constraint usedProject = usedProjectCommit.owningProject is the composite FK onto +-- commit (id, project_id): a usage can never pin a commit of a different project. Both FKs are +-- NO ACTION, so a used project stays undeletable while any commit anywhere pins it (§4, step 0). +CREATE TABLE sysml2.project_usage_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + used_project uuid NOT NULL REFERENCES sysml2.project (id), + used_project_commit uuid NOT NULL REFERENCES sysml2.commit (id), + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE, + FOREIGN KEY (used_project_commit, used_project) REFERENCES sysml2.commit (id, project_id) +); + +CREATE INDEX ix_project_usage_version_used_project + ON sysml2.project_usage_version (used_project); + ------------------------------------------------------------------------------------------------ -- 8. DERIVED ELEMENT STATE — the second append-only stream [HAND-WRITTEN] -- @@ -1411,6 +1488,12 @@ $$; -- -- The normalized columns of §5-§7 are the system of record and carry the referential integrity; -- stored_json / derived_json are the read model built from them at commit time. +-- +-- API data records (§7b) are read by KIND: resolve_kind_at_commit folds one class_kind over the +-- checkpoint-bounded ancestry, driven from the few identities of that kind — O(records x log), +-- never O(model). Reads at a branch head go through branch.head_commit_id and that fold (no +-- overlay fast path; records are few). A record by id is get_element_at_commit — its payload is +-- the record's wire JSON. The element SET reads below return Elements only (class_kind < 1000). ------------------------------------------------------------------------------------------------ -- Branch-head single-element read over the OVERLAY: the overlay row wins (a tombstoned overlay @@ -1470,7 +1553,8 @@ AS $$ ON ev.project_id = h.project_id AND ev.version_id = h.version_id LEFT JOIN sysml2.derived_version dv ON dv.project_id = h.project_id AND dv.derived_id = h.derived_id - WHERE NOT ev.tombstone; + WHERE NOT ev.tombstone + AND ev.class_kind < 1000; $$; CREATE OR REPLACE FUNCTION sysml2.get_elements_at_commit(p_project_id uuid, p_commit_id uuid) @@ -1484,7 +1568,8 @@ AS $$ JOIN sysml2.element_version ev ON ev.project_id = p_project_id AND ev.version_id = r.version_id LEFT JOIN sysml2.derived_version dv - ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id; + ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id + WHERE ev.class_kind < 1000; $$; CREATE OR REPLACE FUNCTION sysml2.get_element_at_commit(p_project_id uuid, p_commit_id uuid, p_identity_id uuid) @@ -1500,6 +1585,131 @@ AS $$ ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id; $$; +-- Kind-filtered resolver for the API data records (§7b): the same checkpoint-bounded walk as +-- resolve_element_at_commit, folded over ONE class_kind. The checkpoint arm is driven from the +-- identities of that kind (ix_data_identity_project_kind) probing the commit_checkpoint PK, so +-- the cost is O(records x walked ancestry) — never a scan of the checkpoint. +CREATE OR REPLACE FUNCTION sysml2.resolve_kind_at_commit(p_project_id uuid, p_commit_id uuid, p_class_kind smallint) +RETURNS TABLE (identity_id uuid, version_id uuid) +LANGUAGE sql +STABLE +AS $$ + WITH RECURSIVE + checkpoint AS ( + SELECT ccr.commit_id + FROM sysml2.commit_checkpoint_registry ccr + WHERE ccr.project_id = p_project_id AND ccr.commit_id = p_commit_id + ), + ancestry AS ( + SELECT c.id, c.created, (SELECT count(*) > 0 FROM checkpoint) AS at_checkpoint + FROM sysml2.commit c + WHERE c.id = p_commit_id + + UNION + + SELECT parent.id, parent.created, + EXISTS (SELECT 1 FROM sysml2.commit_checkpoint_registry ccr + WHERE ccr.project_id = p_project_id AND ccr.commit_id = parent.id) + FROM ancestry a + JOIN sysml2.commit_parent cp ON cp.commit_id = a.id + JOIN sysml2.commit parent ON parent.id = cp.parent_commit_id + WHERE NOT a.at_checkpoint + ), + folded AS ( + SELECT DISTINCT ON (ev.identity_id) + ev.identity_id, + ev.version_id, + ev.tombstone + FROM ancestry a + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.commit_id = a.id AND ev.class_kind = p_class_kind + ORDER BY ev.identity_id, a.created DESC, a.id DESC + ), + checkpointed AS ( + SELECT cc.identity_id, cc.version_id + FROM sysml2.data_identity di + JOIN ancestry a ON a.at_checkpoint + JOIN sysml2.commit_checkpoint cc + ON cc.project_id = p_project_id AND cc.commit_id = a.id AND cc.identity_id = di.id + WHERE di.project_id = p_project_id + AND di.class_kind = p_class_kind + AND NOT EXISTS (SELECT 1 FROM folded f WHERE f.identity_id = di.id) + ) + SELECT f.identity_id, f.version_id FROM folded f WHERE NOT f.tombstone + UNION ALL + SELECT c.identity_id, c.version_id FROM checkpointed c; +$$; + +-- ProjectUsageService::getProjectUsages(project, commit). +CREATE OR REPLACE FUNCTION sysml2.get_project_usages_at_commit(p_project_id uuid, p_commit_id uuid) +RETURNS TABLE (identity_id uuid, used_project uuid, used_project_commit uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT r.identity_id, pu.used_project, pu.used_project_commit, ev.stored_json + FROM sysml2.resolve_kind_at_commit(p_project_id, p_commit_id, + (SELECT ck.id FROM sysml2.class_kind ck WHERE ck.name = 'ProjectUsage')) r + JOIN sysml2.project_usage_version pu + ON pu.project_id = p_project_id AND pu.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id; +$$; + +-- GET /projects/{p}/commits/{c}/elements/{e}/projectUsage: the usage, at the commit, whose used +-- project owns the element's identity. +CREATE OR REPLACE FUNCTION sysml2.get_project_usage_for_element_at_commit(p_project_id uuid, p_commit_id uuid, p_identity_id uuid) +RETURNS TABLE (identity_id uuid, used_project uuid, used_project_commit uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT pu.identity_id, pu.used_project, pu.used_project_commit, pu.payload + FROM sysml2.data_identity di + JOIN sysml2.get_project_usages_at_commit(p_project_id, p_commit_id) pu + ON pu.used_project = di.project_id + WHERE di.id = p_identity_id; +$$; + +-- ExternalRelationshipService::getExternalRelationships(project, commit). +CREATE OR REPLACE FUNCTION sysml2.get_external_relationships_at_commit(p_project_id uuid, p_commit_id uuid) +RETURNS TABLE (identity_id uuid, element_end uuid, external_data_end uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT r.identity_id, er.element_end, er.external_data_end, ev.stored_json + FROM sysml2.resolve_kind_at_commit(p_project_id, p_commit_id, + (SELECT ck.id FROM sysml2.class_kind ck WHERE ck.name = 'ExternalRelationship')) r + JOIN sysml2.external_relationship_version er + ON er.project_id = p_project_id AND er.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id; +$$; + +-- ExternalRelationshipService::getExternalRelationshipsByElement(project, commit, elementId): +-- candidates through the reverse index, each resolved at the commit; a candidate counts only +-- when its RESOLVED version still names the element. +CREATE OR REPLACE FUNCTION sysml2.get_external_relationships_by_element_at_commit(p_project_id uuid, p_commit_id uuid, p_element_id uuid) +RETURNS TABLE (identity_id uuid, element_end uuid, external_data_end uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + WITH candidates AS ( + SELECT DISTINCT ev.identity_id + FROM sysml2.external_relationship_version er + JOIN sysml2.element_version ev + ON ev.project_id = er.project_id AND ev.version_id = er.version_id + WHERE er.project_id = p_project_id + AND er.element_end = p_element_id + ) + SELECT r.identity_id, er.element_end, er.external_data_end, ev.stored_json + FROM candidates c + CROSS JOIN LATERAL sysml2.resolve_element_at_commit(p_project_id, p_commit_id, c.identity_id) r + JOIN sysml2.external_relationship_version er + ON er.project_id = p_project_id AND er.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id + WHERE er.element_end = p_element_id; +$$; + ------------------------------------------------------------------------------------------------ -- 11. PER-METACLASS FLATTENING VIEWS [GENERATED] -- @@ -1669,7 +1879,7 @@ $$; -- checked via the TYPED IDENTITY (data_identity.class_kind, §4) — this -- check applies to cross-project targets too. -- Liveness of cross-project targets is deliberately NOT checked here: it depends on the --- used-project commit (project_usage), which is service-layer resolution. +-- used-project commit (project_usage_version, §7b), which is service-layer resolution. -- -- TIER 1 — validate_references_at_commit: the FULL periodic audit over one commit's whole -- snapshot. It materializes and ANALYZEs the snapshot in a temp table first, so the planner @@ -1725,15 +1935,16 @@ BEGIN RETURN QUERY SELECT 'annotation_version'::text, 'annotated_element'::text, - snap.identity_id, src.annotated_element, 'dangling'::text + snap.identity_id, src.annotated_element, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.annotation_version src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.annotated_element LEFT JOIN validation_snapshot live ON live.identity_id = src.annotated_element WHERE src.project_id = p_project_id AND src.annotated_element IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'conjugated_port_typing_version'::text, 'conjugated_port_definition'::text, snap.identity_id, src.conjugated_port_definition, @@ -1784,26 +1995,28 @@ BEGIN OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'dependency_client'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.dependency_client src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'dependency_supplier'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.dependency_supplier src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'differencing_version'::text, 'differencing_type'::text, snap.identity_id, src.differencing_type, @@ -1938,15 +2151,16 @@ BEGIN OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'membership_version'::text, 'member_element'::text, - snap.identity_id, src.member_element, 'dangling'::text + snap.identity_id, src.member_element, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.membership_version src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.member_element LEFT JOIN validation_snapshot live ON live.identity_id = src.member_element WHERE src.project_id = p_project_id AND src.member_element IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'membership_import_version'::text, 'imported_membership'::text, snap.identity_id, src.imported_membership, @@ -2021,48 +2235,52 @@ BEGIN OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'relationship_version'::text, 'owning_related_element'::text, - snap.identity_id, src.owning_related_element, 'dangling'::text + snap.identity_id, src.owning_related_element, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.relationship_version src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.owning_related_element LEFT JOIN validation_snapshot live ON live.identity_id = src.owning_related_element WHERE src.project_id = p_project_id AND src.owning_related_element IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'relationship_owned_related_element'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.relationship_owned_related_element src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'relationship_source'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.relationship_source src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'relationship_target'::text, 'target_identity'::text, - snap.identity_id, src.target_identity, 'dangling'::text + snap.identity_id, src.target_identity, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.relationship_target src JOIN validation_snapshot snap ON snap.version_id = src.version_id JOIN sysml2.data_identity ti ON ti.id = src.target_identity LEFT JOIN validation_snapshot live ON live.identity_id = src.target_identity WHERE src.project_id = p_project_id AND src.target_identity IS NOT NULL - AND ti.project_id = p_project_id - AND live.identity_id IS NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) UNION ALL SELECT 'specialization_version'::text, 'general'::text, snap.identity_id, src.general, @@ -2170,6 +2388,30 @@ BEGIN WHERE src.project_id = p_project_id AND src.unioning_type IS NOT NULL AND (ti.class_kind NOT IN (1, 2, 3, 5, 6, 7, 8, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 29, 30, 31, 34, 35, 36, 38, 39, 40, 43, 44, 45, 53, 54, 55, 56, 58, 59, 60, 64, 67, 68, 69, 70, 71, 72, 74, 75, 77, 78, 80, 81, 82, 84, 85, 86, 87, 88, 90, 91, 92, 93, 94, 95, 100, 101, 102, 103, 104, 105, 106, 107, 111, 113, 114, 115, 119, 120, 121, 122, 124, 125, 126, 129, 131, 132, 134, 135, 139, 140, 141, 144, 146, 147, 148, 152, 153, 154, 155, 156, 159, 160, 161, 164, 165, 166, 168, 169, 170, 172, 173, 174, 175) + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) + UNION ALL + SELECT 'external_relationship_version'::text, 'element_end'::text, + snap.identity_id, src.element_end, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END + FROM sysml2.external_relationship_version src + JOIN validation_snapshot snap ON snap.version_id = src.version_id + JOIN sysml2.data_identity ti ON ti.id = src.element_end + LEFT JOIN validation_snapshot live ON live.identity_id = src.element_end + WHERE src.project_id = p_project_id + AND src.element_end IS NOT NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id AND live.identity_id IS NULL)) + UNION ALL + SELECT 'external_relationship_version'::text, 'external_data_end'::text, + snap.identity_id, src.external_data_end, + CASE WHEN ti.class_kind NOT IN (1001) THEN 'wrong-type' ELSE 'dangling' END + FROM sysml2.external_relationship_version src + JOIN validation_snapshot snap ON snap.version_id = src.version_id + JOIN sysml2.data_identity ti ON ti.id = src.external_data_end + LEFT JOIN validation_snapshot live ON live.identity_id = src.external_data_end + WHERE src.project_id = p_project_id + AND src.external_data_end IS NOT NULL + AND (ti.class_kind NOT IN (1001) OR (ti.project_id = p_project_id AND live.identity_id IS NULL)); END; $$; @@ -2198,7 +2440,7 @@ AS $$ FROM ( SELECT 'annotation_version'::text, 'annotated_element'::text, changed.identity_id, src.annotated_element, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.annotation_version src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -2207,8 +2449,9 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.annotated_element IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.annotated_element))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.annotated_element)))) UNION ALL SELECT 'conjugated_port_typing_version'::text, 'conjugated_port_definition'::text, changed.identity_id, src.conjugated_port_definition, @@ -2272,7 +2515,7 @@ AS $$ UNION ALL SELECT 'dependency_client'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.dependency_client src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -2281,12 +2524,13 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'dependency_supplier'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.dependency_supplier src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -2295,8 +2539,9 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'differencing_version'::text, 'differencing_type'::text, changed.identity_id, src.differencing_type, @@ -2463,7 +2708,7 @@ AS $$ UNION ALL SELECT 'membership_version'::text, 'member_element'::text, changed.identity_id, src.member_element, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.membership_version src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -2472,8 +2717,9 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.member_element IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.member_element))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.member_element)))) UNION ALL SELECT 'membership_import_version'::text, 'imported_membership'::text, changed.identity_id, src.imported_membership, @@ -2567,7 +2813,7 @@ AS $$ UNION ALL SELECT 'relationship_version'::text, 'owning_related_element'::text, changed.identity_id, src.owning_related_element, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.relationship_version src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -2576,12 +2822,13 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.owning_related_element IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.owning_related_element))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.owning_related_element)))) UNION ALL SELECT 'relationship_owned_related_element'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.relationship_owned_related_element src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -2590,12 +2837,13 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'relationship_source'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.relationship_source src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -2604,12 +2852,13 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'relationship_target'::text, 'target_identity'::text, changed.identity_id, src.target_identity, - 'dangling'::text + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END FROM sysml2.element_version changed JOIN sysml2.relationship_target src ON src.project_id = changed.project_id AND src.version_id = changed.version_id @@ -2618,8 +2867,9 @@ AS $$ AND changed.commit_id = p_commit_id AND NOT changed.tombstone AND src.target_identity IS NOT NULL - AND (ti.project_id = p_project_id - AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity))) + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.target_identity)))) UNION ALL SELECT 'specialization_version'::text, 'general'::text, changed.identity_id, src.general, @@ -2756,6 +3006,36 @@ AS $$ OR (ti.project_id = p_project_id AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.unioning_type)))) UNION ALL + SELECT 'external_relationship_version'::text, 'element_end'::text, + changed.identity_id, src.element_end, + CASE WHEN ti.class_kind >= 1000 THEN 'wrong-type' ELSE 'dangling' END + FROM sysml2.element_version changed + JOIN sysml2.external_relationship_version src + ON src.project_id = changed.project_id AND src.version_id = changed.version_id + JOIN sysml2.data_identity ti ON ti.id = src.element_end + WHERE changed.project_id = p_project_id + AND changed.commit_id = p_commit_id + AND NOT changed.tombstone + AND src.element_end IS NOT NULL + AND (ti.class_kind >= 1000 + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.element_end)))) + UNION ALL + SELECT 'external_relationship_version'::text, 'external_data_end'::text, + changed.identity_id, src.external_data_end, + CASE WHEN ti.class_kind NOT IN (1001) THEN 'wrong-type' ELSE 'dangling' END + FROM sysml2.element_version changed + JOIN sysml2.external_relationship_version src + ON src.project_id = changed.project_id AND src.version_id = changed.version_id + JOIN sysml2.data_identity ti ON ti.id = src.external_data_end + WHERE changed.project_id = p_project_id + AND changed.commit_id = p_commit_id + AND NOT changed.tombstone + AND src.external_data_end IS NOT NULL + AND (ti.class_kind NOT IN (1001) + OR (ti.project_id = p_project_id + AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, src.external_data_end)))) + UNION ALL SELECT 'annotation_version'::text, 'annotated_element'::text, holder.identity_id, dead.identity_id, 'dangling'::text FROM sysml2.element_version dead @@ -3247,5 +3527,31 @@ AS $$ AND dead.tombstone AND EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, holder.identity_id) alive WHERE alive.version_id = holder.version_id) + UNION ALL + SELECT 'external_relationship_version'::text, 'element_end'::text, + holder.identity_id, dead.identity_id, 'dangling'::text + FROM sysml2.element_version dead + JOIN sysml2.external_relationship_version src + ON src.project_id = dead.project_id AND src.element_end = dead.identity_id + JOIN sysml2.element_version holder + ON holder.project_id = src.project_id AND holder.version_id = src.version_id + WHERE dead.project_id = p_project_id + AND dead.commit_id = p_commit_id + AND dead.tombstone + AND EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, holder.identity_id) alive + WHERE alive.version_id = holder.version_id) + UNION ALL + SELECT 'external_relationship_version'::text, 'external_data_end'::text, + holder.identity_id, dead.identity_id, 'dangling'::text + FROM sysml2.element_version dead + JOIN sysml2.external_relationship_version src + ON src.project_id = dead.project_id AND src.external_data_end = dead.identity_id + JOIN sysml2.element_version holder + ON holder.project_id = src.project_id AND holder.version_id = src.version_id + WHERE dead.project_id = p_project_id + AND dead.commit_id = p_commit_id + AND dead.tombstone + AND EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, holder.identity_id) alive + WHERE alive.version_id = holder.version_id) ) AS findings (source_table, source_column, source_identity, target_identity, problem); $$; diff --git a/SysML2.NET.CodeGenerator.Tests/Sql/schema.smoke.sql b/SysML2.NET.CodeGenerator.Tests/Sql/schema.smoke.sql index f3746687c..ff714a474 100644 --- a/SysML2.NET.CodeGenerator.Tests/Sql/schema.smoke.sql +++ b/SysML2.NET.CodeGenerator.Tests/Sql/schema.smoke.sql @@ -17,13 +17,17 @@ INSERT INTO sysml2.model_version (id, name, source_fingerprint) VALUES (2, 'smoke-release-2', 'smoke:v2') ON CONFLICT (id) DO NOTHING; --- The FROZEN registry ids of the three metaclasses this test uses (see ClassKindRegistry) — --- identical to the generated schema's seeds, so this INSERT is a no-op there and every --- hard-coded id in validate_references_at_commit() means the same thing in both contexts. +-- The FROZEN registry ids of the three metaclasses and three API data records this test uses +-- (see ClassKindRegistry) — identical to the generated schema's seeds, so this INSERT is a no-op +-- there and every hard-coded id in validate_references_at_commit() means the same thing in both +-- contexts. INSERT INTO sysml2.class_kind (id, name, is_abstract, introduced_in) VALUES (116, 'OwningMembership', false, 1), (117, 'Package', false, 1), - (120, 'PartUsage', false, 1) + (120, 'PartUsage', false, 1), + (1001, 'ExternalData', false, 1), + (1002, 'ExternalRelationship', false, 1), + (1003, 'ProjectUsage', false, 1) ON CONFLICT (id) DO NOTHING; INSERT INTO sysml2.project (id, name, created) VALUES @@ -840,10 +844,13 @@ END; $$; ---------------------------------------------------------------------------------------------- --- PROJECT-USAGE SCENARIO — cross-project references (GET .../projectUsage). +-- PROJECT-USAGE SCENARIO — ProjectUsageService (getProjectUsages / createProjectUsage / +-- deleteProjectUsage) and GET .../elements/{e}/projectUsage. -- --- A ProjectUsage pins a used project AT a commit. Storage-level contract: the row reads back, --- and it can dangle neither on the used project nor on the used commit. +-- A ProjectUsage is an API data record (§7b): an identity in the >= 1000 class_kind band, one +-- element_version row per commit that changes it, and a project_usage_version row with the typed +-- columns. createProjectUsage is a commit on the branch (c16), deleteProjectUsage a tombstone in +-- a later commit (c17). Reads are AT a commit: c14 sees nothing, c16 the usage, c17 its absence. ---------------------------------------------------------------------------------------------- DO $$ @@ -851,38 +858,160 @@ DECLARE proj constant uuid := '11111111-0000-0000-0000-000000000000'; proj2 constant uuid := '22222222-0000-0000-0000-000000000000'; cu1 constant uuid := 'cf111111-0000-0000-0000-000000000000'; - u1 constant uuid := 'de111111-0000-0000-0000-000000000000'; - used_at uuid; + c14 constant uuid := 'cd111111-0000-0000-0000-000000000000'; + c16 constant uuid := 'cb222222-0000-0000-0000-000000000000'; + c17 constant uuid := 'cc222222-0000-0000-0000-000000000000'; + e9 constant uuid := 'e9999999-0000-0000-0000-000000000000'; + u1 constant uuid := 'eb111111-0000-0000-0000-000000000000'; + u2 constant uuid := 'eb222222-0000-0000-0000-000000000000'; + e10 constant uuid := 'eb333333-0000-0000-0000-000000000000'; + vu1 constant uuid := 'af111111-0000-0000-0000-000000000000'; + vu2 constant uuid := 'af222222-0000-0000-0000-000000000000'; + vneg constant uuid := 'af333333-0000-0000-0000-000000000000'; + usage_kind smallint; + row_count int; BEGIN + SELECT id INTO usage_kind FROM sysml2.class_kind WHERE name = 'ProjectUsage'; + INSERT INTO sysml2.project (id, name, created) VALUES (proj2, 'UsedLibraryProject', '2026-01-02T00:00:00Z'); INSERT INTO sysml2.commit (id, project_id, created, description, model_version_id) VALUES (cu1, proj2, '2026-01-02T01:00:00Z', 'library release', 1); - -- 16a. Create + read back: the usage pins proj -> proj2 AT cu1. - INSERT INTO sysml2.project_usage (id, project_id, used_project_id, used_project_commit_id) - VALUES (u1, proj, proj2, cu1); + -- The library root's identity; its version row arrives with the deletion scenario. + INSERT INTO sysml2.data_identity (id, project_id, class_kind) + VALUES (e9, proj2, (SELECT id FROM sysml2.class_kind WHERE name = 'Package')); + + -- 16a. createProjectUsage(proj, main, usage): one commit whose change set is the usage. + INSERT INTO sysml2.commit (id, project_id, created, description, model_version_id) + VALUES (c16, proj, '2026-01-01T21:00:00Z', 'use the library at its release', 1); + + INSERT INTO sysml2.commit_parent (commit_id, parent_commit_id, ordinal) VALUES (c16, c14, 0); - SELECT pu.used_project_commit_id INTO used_at - FROM sysml2.project_usage pu - WHERE pu.project_id = proj AND pu.used_project_id = proj2; + INSERT INTO sysml2.data_identity (id, project_id, class_kind) VALUES (u1, proj, usage_kind); - IF used_at IS DISTINCT FROM cu1 THEN - RAISE EXCEPTION 'FAIL 16a: project_usage does not pin the used commit (got %)', used_at; + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone, stored_json) + VALUES + (proj, vu1, u1, c16, usage_kind, false, + '{"@id":"eb111111-0000-0000-0000-000000000000","@type":"ProjectUsage","usedCommit":{"@id":"cf111111-0000-0000-0000-000000000000"},"usedProject":{"@id":"22222222-0000-0000-0000-000000000000"}}'); + + INSERT INTO sysml2.project_usage_version (project_id, version_id, used_project, used_project_commit) + VALUES (proj, vu1, proj2, cu1); + + SELECT count(*) INTO row_count + FROM sysml2.get_project_usages_at_commit(proj, c16) pu + WHERE pu.identity_id = u1 AND pu.used_project = proj2 AND pu.used_project_commit = cu1 + AND pu.payload ->> '@type' = 'ProjectUsage'; + + IF row_count <> 1 OR (SELECT count(*) FROM sysml2.get_project_usages_at_commit(proj, c16)) <> 1 THEN + RAISE EXCEPTION 'FAIL 16a: getProjectUsages at c16 does not return exactly the usage pinning % (matching rows: %)', cu1, row_count; END IF; - RAISE NOTICE 'PASS 16a: project_usage pins the used project at a commit and reads back'; - -- 16b. The pinned commit must exist. + SELECT count(*) INTO row_count FROM sysml2.get_project_usages_at_commit(proj, c14); + + IF row_count <> 0 THEN + RAISE EXCEPTION 'FAIL 16a: getProjectUsages at the PARENT commit c14 returned % usages', row_count; + END IF; + RAISE NOTICE 'PASS 16a: createProjectUsage is a commit; getProjectUsages sees the usage at c16 and not at c14'; + + -- 16b. The pinned commit must exist. The scaffolding rolls back with the failing INSERT. BEGIN - INSERT INTO sysml2.project_usage (id, project_id, used_project_id, used_project_commit_id) - VALUES ('de222222-0000-0000-0000-000000000000', proj, proj2, - 'cdeadbee-0000-0000-0000-000000000000'); + INSERT INTO sysml2.data_identity (id, project_id, class_kind) VALUES (u2, proj, usage_kind); + + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone, stored_json) + VALUES (proj, vneg, u2, c16, usage_kind, false, '{}'); + + INSERT INTO sysml2.project_usage_version (project_id, version_id, used_project, used_project_commit) + VALUES (proj, vneg, proj2, 'cdeadbee-0000-0000-0000-000000000000'); - RAISE EXCEPTION 'FAIL 16b: project_usage pinning a non-existent commit was ACCEPTED'; + RAISE EXCEPTION 'FAIL 16b: a usage pinning a non-existent commit was ACCEPTED'; EXCEPTION WHEN foreign_key_violation THEN - RAISE NOTICE 'PASS 16b: project_usage with a dangling used commit rejected by FK'; + RAISE NOTICE 'PASS 16b: a usage with a dangling used commit is rejected by FK'; + END; + + -- 16c. The PIM constraint usedProject = usedProjectCommit.owningProject: cu1 belongs to proj2. + BEGIN + INSERT INTO sysml2.data_identity (id, project_id, class_kind) VALUES (u2, proj, usage_kind); + + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone, stored_json) + VALUES (proj, vneg, u2, c16, usage_kind, false, '{}'); + + INSERT INTO sysml2.project_usage_version (project_id, version_id, used_project, used_project_commit) + VALUES (proj, vneg, proj, cu1); + + RAISE EXCEPTION 'FAIL 16c: a usage whose usedProject does not own usedProjectCommit was ACCEPTED'; + EXCEPTION WHEN foreign_key_violation THEN + RAISE NOTICE 'PASS 16c: usedProject = usedProjectCommit.owningProject is enforced by the composite FK'; + END; + + -- 16d. Shape guards: a record version carries no Element columns; an Element version must. + BEGIN + INSERT INTO sysml2.data_identity (id, project_id, class_kind) VALUES (u2, proj, usage_kind); + + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone, element_id, stored_json) + VALUES (proj, vneg, u2, c16, usage_kind, false, 'eb222222-0000-0000-0000-000000000000', '{}'); + + RAISE EXCEPTION 'FAIL 16d: a ProjectUsage version carrying element_id was ACCEPTED'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + INSERT INTO sysml2.data_identity (id, project_id, class_kind) + VALUES (e10, proj, (SELECT id FROM sysml2.class_kind WHERE name = 'Package')); + + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone, is_implied_included, stored_json) + VALUES (proj, vneg, e10, c16, (SELECT id FROM sysml2.class_kind WHERE name = 'Package'), false, false, '{}'); + + RAISE EXCEPTION 'FAIL 16d: a Package version without element_id was ACCEPTED'; + EXCEPTION WHEN check_violation THEN + NULL; END; + RAISE NOTICE 'PASS 16d: element_version keeps Element columns off record rows and on element rows'; + + -- 16e. deleteProjectUsage(proj, main, u1): a tombstone version in a new commit. + INSERT INTO sysml2.commit (id, project_id, created, description, model_version_id) + VALUES (c17, proj, '2026-01-01T22:00:00Z', 'drop the library usage', 1); + + INSERT INTO sysml2.commit_parent (commit_id, parent_commit_id, ordinal) VALUES (c17, c16, 0); + + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone) + VALUES (proj, vu2, u1, c17, usage_kind, true); + + IF (SELECT count(*) FROM sysml2.get_project_usages_at_commit(proj, c17)) <> 0 + OR (SELECT count(*) FROM sysml2.get_project_usages_at_commit(proj, c16)) <> 1 THEN + RAISE EXCEPTION 'FAIL 16e: deleteProjectUsage did not hide the usage at c17 while keeping it at c16'; + END IF; + RAISE NOTICE 'PASS 16e: deleteProjectUsage is a tombstone commit; the usage is gone at c17 and kept at c16'; + + -- 16f. GET .../commits/{c}/elements/{e}/projectUsage: the usage that brings in the library root. + SELECT count(*) INTO row_count + FROM sysml2.get_project_usage_for_element_at_commit(proj, c16, e9) pu + WHERE pu.identity_id = u1; + + IF row_count <> 1 + OR (SELECT count(*) FROM sysml2.get_project_usage_for_element_at_commit(proj, c17, e9)) <> 0 THEN + RAISE EXCEPTION 'FAIL 16f: the usage originating the library element resolves wrongly (% at c16)', row_count; + END IF; + RAISE NOTICE 'PASS 16f: the usage originating a used-project element resolves at c16 and not at c17'; + + -- 16g. The element SET read returns Elements only; the record itself reads by id. + SELECT count(*) INTO row_count + FROM sysml2.get_elements_at_commit(proj, c16) r + WHERE r.identity_id = u1; + + IF row_count <> 0 + OR sysml2.get_element_at_commit(proj, c16, u1) ->> '@type' IS DISTINCT FROM 'ProjectUsage' THEN + RAISE EXCEPTION 'FAIL 16g: set read listed the usage % times, or the by-id read lost it', row_count; + END IF; + RAISE NOTICE 'PASS 16g: GET /elements excludes API data records; GET /elements/{id} returns the record'; END; $$; @@ -1200,11 +1329,11 @@ $$; -- PROJECT-DELETION SCENARIO — DELETE /projects/{p}, the documented ordered procedure. -- -- The library project (proj2) gets a small model, a branch, a tag, and a CHECKPOINT. Then: --- inbound project_usage blocks deletion (26a); with the usage gone, a direct DELETE is STILL --- blocked — commit_checkpoint_registry's NO ACTION FK to commit is exactly the out-of-order --- guard the procedure comment promises (26b; this FK is why the registry line is part of the --- documented order); the full ordered procedure then completes, and the neighbor project is --- untouched (26c). +-- the inbound usage HISTORY of proj (PASS 16, pinned at cu1) blocks deletion through the commit +-- FK (26a); after the administrative purge of that history, a direct DELETE is STILL blocked — +-- commit_checkpoint_registry's NO ACTION FK to commit is exactly the out-of-order guard the +-- procedure comment promises (26b; this FK is why the registry line is part of the documented +-- order); the full ordered procedure then completes, and the neighbor project is untouched (26c). ---------------------------------------------------------------------------------------------- DO $$ @@ -1221,11 +1350,8 @@ DECLARE neighbor_versions_after int; n int; BEGIN - -- Give the library project a small but complete footprint: identity + stored + derived - -- state, a link row, a branch with overlay, a tag, and a materialized checkpoint. - INSERT INTO sysml2.data_identity (id, project_id, class_kind) - VALUES (e9, proj2, (SELECT id FROM sysml2.class_kind WHERE name = 'Package')); - + -- Give the library project a small but complete footprint: stored + derived state for the + -- identity PASS 16 created, a link row, a branch with overlay, a tag, and a checkpoint. INSERT INTO sysml2.element_version (project_id, version_id, identity_id, commit_id, class_kind, tombstone, element_id, declared_name, is_implied_included, stored_json) @@ -1254,19 +1380,24 @@ BEGIN PERFORM sysml2.build_commit_checkpoint(proj2, cu1); - SELECT count(*) INTO neighbor_versions_before - FROM sysml2.element_version WHERE project_id = proj; - - -- 26a. An inbound project_usage (from PASS 16) blocks deletion of the used project. + -- 26a. The inbound usage history of proj (PASS 16) blocks deletion of the used project, even + -- though the usage is tombstoned at proj's head: history pins cu1 forever. BEGIN DELETE FROM sysml2.project WHERE id = proj2; RAISE EXCEPTION 'FAIL 26a: deleting a USED project was ACCEPTED'; EXCEPTION WHEN foreign_key_violation THEN - RAISE NOTICE 'PASS 26a: inbound project_usage blocks deletion of the used project'; + RAISE NOTICE 'PASS 26a: inbound project_usage_version history blocks deletion of the used project'; END; - DELETE FROM sysml2.project_usage WHERE used_project_id = proj2; + -- The administrative purge (data_identity DDL comment, step 0): drop the using project's + -- usage history; the subtype rows cascade from element_version. + DELETE FROM sysml2.element_version + WHERE project_id = proj + AND class_kind = (SELECT id FROM sysml2.class_kind WHERE name = 'ProjectUsage'); + + SELECT count(*) INTO neighbor_versions_before + FROM sysml2.element_version WHERE project_id = proj; -- 26b. Out-of-order deletion is still blocked, loudly: the checkpoint registry's -- NO ACTION FK to commit refuses the cascade — the guard that makes the ordered @@ -1281,6 +1412,9 @@ BEGIN -- 26c. The documented ordered procedure (data_identity DDL comment), verbatim order. DELETE FROM sysml2.element_owned_relationship WHERE project_id = proj2; + DELETE FROM sysml2.external_relationship_version WHERE project_id = proj2; + DELETE FROM sysml2.external_data_version WHERE project_id = proj2; + DELETE FROM sysml2.project_usage_version WHERE project_id = proj2; DELETE FROM sysml2.derived_version WHERE project_id = proj2; DELETE FROM sysml2.element_version WHERE project_id = proj2; DELETE FROM sysml2.branch_head WHERE project_id = proj2; @@ -1394,3 +1528,185 @@ BEGIN RAISE NOTICE 'PASS 27c: soft-deleted tag keeps its record; the name is reusable'; END; $$; + +---------------------------------------------------------------------------------------------- +-- EXTERNAL-RELATIONSHIP SCENARIO — ExternalRelationshipService (getExternalRelationships, +-- getExternalRelationshipsByElement, getExternalRelationshipById, createExternalRelationship, +-- deleteExternalRelationship). +-- +-- c18 creates an ExternalData x1 and an ExternalRelationship r1 (elementEnd = the Package e1, +-- externalDataEnd = x1), plus two deliberately broken relationships for the validators: r2 whose +-- externalDataEnd names a PartUsage, r3 whose elementEnd names an identity without any version. +-- c19 tombstones x1 (r1 and r3 left dangling — the incoming validation arm); c20 tombstones r1. +---------------------------------------------------------------------------------------------- + +DO $$ +DECLARE + proj constant uuid := '11111111-0000-0000-0000-000000000000'; + c17 constant uuid := 'cc222222-0000-0000-0000-000000000000'; + c18 constant uuid := 'cd222222-0000-0000-0000-000000000000'; + c19 constant uuid := 'ce222222-0000-0000-0000-000000000000'; + c20 constant uuid := 'cf222222-0000-0000-0000-000000000000'; + e1 constant uuid := 'e1111111-0000-0000-0000-000000000000'; + e2 constant uuid := 'e2222222-0000-0000-0000-000000000000'; + e11 constant uuid := 'ec222222-0000-0000-0000-000000000000'; -- identity without a version + x1 constant uuid := 'ec111111-0000-0000-0000-000000000000'; + r1 constant uuid := 'ed111111-0000-0000-0000-000000000000'; + r2 constant uuid := 'ed222222-0000-0000-0000-000000000000'; + r3 constant uuid := 'ed333333-0000-0000-0000-000000000000'; + vx1 constant uuid := 'b0111111-0000-0000-0000-000000000000'; + vr1 constant uuid := 'b0222222-0000-0000-0000-000000000000'; + vr2 constant uuid := 'b0333333-0000-0000-0000-000000000000'; + vr3 constant uuid := 'b0444444-0000-0000-0000-000000000000'; + vx2 constant uuid := 'b0555555-0000-0000-0000-000000000000'; + vr4 constant uuid := 'b0666666-0000-0000-0000-000000000000'; + data_kind smallint; + rel_kind smallint; + row_count int; + other_count int; +BEGIN + SELECT id INTO data_kind FROM sysml2.class_kind WHERE name = 'ExternalData'; + SELECT id INTO rel_kind FROM sysml2.class_kind WHERE name = 'ExternalRelationship'; + + -- 28a. createExternalRelationship(proj, main, r1) with its ExternalData, in one commit. + INSERT INTO sysml2.commit (id, project_id, created, description, model_version_id) + VALUES (c18, proj, '2026-01-01T23:00:00Z', 'trace the package to an external requirement', 1); + + INSERT INTO sysml2.commit_parent (commit_id, parent_commit_id, ordinal) VALUES (c18, c17, 0); + + INSERT INTO sysml2.data_identity (id, project_id, class_kind) VALUES + (x1, proj, data_kind), + (r1, proj, rel_kind), + (r2, proj, rel_kind), + (r3, proj, rel_kind), + (e11, proj, (SELECT id FROM sysml2.class_kind WHERE name = 'Package')); + + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone, stored_json) + VALUES + (proj, vx1, x1, c18, data_kind, false, + '{"@id":"ec111111-0000-0000-0000-000000000000","@type":"ExternalData","resourceIdentifier":"https://example.org/requirements/42"}'), + (proj, vr1, r1, c18, rel_kind, false, + '{"@id":"ed111111-0000-0000-0000-000000000000","@type":"ExternalRelationship","elementEnd":{"@id":"e1111111-0000-0000-0000-000000000000"},"externalDataEnd":{"@id":"ec111111-0000-0000-0000-000000000000"},"language":"sysml","specification":"satisfies"}'), + (proj, vr2, r2, c18, rel_kind, false, + '{"@id":"ed222222-0000-0000-0000-000000000000","@type":"ExternalRelationship","elementEnd":{"@id":"e1111111-0000-0000-0000-000000000000"},"externalDataEnd":{"@id":"e2222222-0000-0000-0000-000000000000"},"language":null,"specification":null}'), + (proj, vr3, r3, c18, rel_kind, false, + '{"@id":"ed333333-0000-0000-0000-000000000000","@type":"ExternalRelationship","elementEnd":{"@id":"ec222222-0000-0000-0000-000000000000"},"externalDataEnd":{"@id":"ec111111-0000-0000-0000-000000000000"},"language":null,"specification":null}'); + + INSERT INTO sysml2.external_data_version (project_id, version_id, resource_identifier) + VALUES (proj, vx1, 'https://example.org/requirements/42'); + + INSERT INTO sysml2.external_relationship_version + (project_id, version_id, element_end, external_data_end, language, specification) + VALUES + (proj, vr1, e1, x1, 'sysml', 'satisfies'), + (proj, vr2, e1, e2, NULL, NULL), + (proj, vr3, e11, x1, NULL, NULL); + + SELECT count(*) INTO row_count + FROM sysml2.get_external_relationships_at_commit(proj, c18) er + WHERE er.identity_id = r1 AND er.element_end = e1 AND er.external_data_end = x1 + AND er.payload ->> '@type' = 'ExternalRelationship' AND er.payload ->> 'specification' = 'satisfies'; + + SELECT count(*) INTO other_count FROM sysml2.get_external_relationships_at_commit(proj, c18); + + IF row_count <> 1 OR other_count <> 3 + OR (SELECT count(*) FROM sysml2.get_external_relationships_at_commit(proj, c17)) <> 0 THEN + RAISE EXCEPTION 'FAIL 28a: getExternalRelationships at c18 returned % rows (% matching r1); expected 3 and 1, none at c17', other_count, row_count; + END IF; + RAISE NOTICE 'PASS 28a: createExternalRelationship is a commit; getExternalRelationships reads the relationships at c18 and not at c17'; + + -- 28b. getExternalRelationshipsByElement: the Package has r1 and r2, the PartUsage none. + SELECT count(*) INTO row_count + FROM sysml2.get_external_relationships_by_element_at_commit(proj, c18, e1); + + SELECT count(*) INTO other_count + FROM sysml2.get_external_relationships_by_element_at_commit(proj, c18, e2); + + IF row_count <> 2 OR other_count <> 0 THEN + RAISE EXCEPTION 'FAIL 28b: by-element lookup returned % for the Package and % for the PartUsage', row_count, other_count; + END IF; + RAISE NOTICE 'PASS 28b: getExternalRelationshipsByElement finds the relationships through the reverse index and the snapshot'; + + -- 28c. getExternalRelationshipById and the ExternalData by id are the element by-id read. + IF sysml2.get_element_at_commit(proj, c18, r1) -> 'externalDataEnd' ->> '@id' IS DISTINCT FROM x1::text + OR sysml2.get_element_at_commit(proj, c18, x1) ->> 'resourceIdentifier' IS DISTINCT FROM 'https://example.org/requirements/42' THEN + RAISE EXCEPTION 'FAIL 28c: by-id reads of the relationship or its external data lost their payload'; + END IF; + RAISE NOTICE 'PASS 28c: getExternalRelationshipById and the ExternalData read by id return the record payloads'; + + -- 28d. Validation: r2 names a PartUsage as externalDataEnd (wrong-type), r3 an identity with + -- no live version as elementEnd (dangling); both tiers agree. + SELECT count(*) INTO row_count + FROM sysml2.validate_references_in_commit(proj, c18) v + WHERE (v.source_table, v.source_column, v.source_identity, v.target_identity, v.problem) + IN (('external_relationship_version', 'external_data_end', r2, e2, 'wrong-type'), + ('external_relationship_version', 'element_end', r3, e11, 'dangling')); + + SELECT count(*) INTO other_count FROM sysml2.validate_references_in_commit(proj, c18); + + IF row_count <> 2 OR other_count <> 2 THEN + RAISE EXCEPTION 'FAIL 28d: incremental validation at c18 reported % findings, % of them expected', other_count, row_count; + END IF; + + SELECT count(*) INTO row_count + FROM sysml2.validate_references_at_commit(proj, c18) v + WHERE v.source_table = 'external_relationship_version' + AND (v.source_column, v.source_identity, v.target_identity, v.problem) + IN (('external_data_end', r2, e2, 'wrong-type'), ('element_end', r3, e11, 'dangling')); + + SELECT count(*) INTO other_count + FROM sysml2.validate_references_at_commit(proj, c18) v + WHERE v.source_table = 'external_relationship_version'; + + IF row_count <> 2 OR other_count <> 2 THEN + RAISE EXCEPTION 'FAIL 28d: full validation at c18 reported % relationship findings, % of them expected', other_count, row_count; + END IF; + + -- Tombstoning the ExternalData leaves r1 and r3 dangling: the incoming arm catches both. + INSERT INTO sysml2.commit (id, project_id, created, description, model_version_id) + VALUES (c19, proj, '2026-01-01T23:30:00Z', 'retire the external requirement', 1); + + INSERT INTO sysml2.commit_parent (commit_id, parent_commit_id, ordinal) VALUES (c19, c18, 0); + + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone) + VALUES (proj, vx2, x1, c19, data_kind, true); + + SELECT count(*) INTO row_count + FROM sysml2.validate_references_in_commit(proj, c19) v + WHERE v.source_table = 'external_relationship_version' AND v.source_column = 'external_data_end' + AND v.source_identity IN (r1, r3) AND v.target_identity = x1 AND v.problem = 'dangling'; + + SELECT count(*) INTO other_count FROM sysml2.validate_references_in_commit(proj, c19); + + IF row_count <> 2 OR other_count <> 2 THEN + RAISE EXCEPTION 'FAIL 28d: tombstoning the ExternalData reported % findings, % of them the dangling holders', other_count, row_count; + END IF; + RAISE NOTICE 'PASS 28d: both validation tiers type-check externalDataEnd, flag a dangling elementEnd, and catch holders a tombstone leaves dangling'; + + -- 28e. deleteExternalRelationship(proj, main, r1): a tombstone version in a new commit. + INSERT INTO sysml2.commit (id, project_id, created, description, model_version_id) + VALUES (c20, proj, '2026-01-01T23:45:00Z', 'drop the trace', 1); + + INSERT INTO sysml2.commit_parent (commit_id, parent_commit_id, ordinal) VALUES (c20, c19, 0); + + INSERT INTO sysml2.element_version + (project_id, version_id, identity_id, commit_id, class_kind, tombstone) + VALUES (proj, vr4, r1, c20, rel_kind, true); + + SELECT count(*) INTO row_count + FROM sysml2.get_external_relationships_at_commit(proj, c20) er + WHERE er.identity_id = r1; + + SELECT count(*) INTO other_count + FROM sysml2.get_external_relationships_by_element_at_commit(proj, c20, e1); + + IF row_count <> 0 OR other_count <> 1 + OR (SELECT count(*) FROM sysml2.get_external_relationships_at_commit(proj, c18) er WHERE er.identity_id = r1) <> 1 + OR sysml2.get_element_at_commit(proj, c19, x1) IS NOT NULL THEN + RAISE EXCEPTION 'FAIL 28e: deleteExternalRelationship left r1 visible at c20 (% rows, % by element) or kept it off c18', row_count, other_count; + END IF; + RAISE NOTICE 'PASS 28e: deleteExternalRelationship is a tombstone commit; r1 is gone at c20 and kept at c18'; +END; +$$; diff --git a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindEnumGenerator.cs b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindEnumGenerator.cs index 7cfd82edf..8a93a050c 100644 --- a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindEnumGenerator.cs +++ b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindEnumGenerator.cs @@ -119,7 +119,8 @@ private async Task GenerateClassKindEnumInternalAsync(XmiReaderResult xm registration.Id, registration.IsAbstract, versionNamesById[registration.IntroducedIn], - registration.RemovedIn.HasValue ? versionNamesById[registration.RemovedIn.Value] : null)) + registration.RemovedIn.HasValue ? versionNamesById[registration.RemovedIn.Value] : null, + registration.IsApiRecord)) .ToList(); var template = this.Templates["core-classkind-enum-template"]; diff --git a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindEnumMember.cs b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindEnumMember.cs index f27295e24..66e9eebd5 100644 --- a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindEnumMember.cs +++ b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindEnumMember.cs @@ -41,5 +41,8 @@ namespace SysML2.NET.CodeGenerator.Generators.UmlHandleBarsGenerators /// The name of the first release WITHOUT the metaclass, or null while it is still part of the /// newest registered release /// - public sealed record ClassKindEnumMember(string Name, int Id, bool IsAbstract, string IntroducedIn, string RemovedIn); + /// + /// Whether the member is a Systems Modeling API Clause 7 data record and not a metaclass + /// + public sealed record ClassKindEnumMember(string Name, int Id, bool IsAbstract, string IntroducedIn, string RemovedIn, bool IsApiRecord); } diff --git a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistration.cs b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistration.cs index ca2689517..b150c6ec0 100644 --- a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistration.cs +++ b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistration.cs @@ -43,5 +43,10 @@ namespace SysML2.NET.CodeGenerator.Generators.UmlHandleBarsGenerators /// The of the first release WITHOUT the metaclass, /// or null while it is still part of the newest registered release /// - public sealed record ClassKindRegistration(int Id, string Name, bool IsAbstract, int IntroducedIn, int? RemovedIn = null); + /// + /// Whether the kind is a Systems Modeling API Clause 7 data record (ProjectUsage, ExternalRelationship, + /// ExternalData) versioned through the commit stream like an element but absent from the UML model; + /// such kinds live at or above + /// + public sealed record ClassKindRegistration(int Id, string Name, bool IsAbstract, int IntroducedIn, int? RemovedIn = null, bool IsApiRecord = false); } \ No newline at end of file diff --git a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistry.cs b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistry.cs index 425908af1..278eb757b 100644 --- a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistry.cs +++ b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistry.cs @@ -38,9 +38,17 @@ namespace SysML2.NET.CodeGenerator.Generators.UmlHandleBarsGenerators /// * The generator fails fast on any drift between the newest registered release and the UML /// model on disk (unregistered class, stale registration, fingerprint mismatch), printing /// the exact entries to append. + /// * Ids at or above are reserved for the Systems Modeling API + /// Clause 7 data records (not metaclasses, exempt from the UML drift check); metaclass ids + /// stay below it. /// public static class ClassKindRegistry { + /// + /// The first class-kind id reserved for API data records; every id below it is a metaclass. + /// + public const int ApiRecordIdFloor = 1000; + /// /// Gets the registered metamodel releases, ordered by ordinal. The last entry is the release /// the UML model on disk must match. @@ -230,6 +238,9 @@ public static class ClassKindRegistry new(173, "ViewpointDefinition", false, 1), new(174, "ViewpointUsage", false, 1), new(175, "WhileLoopActionUsage", false, 1), + new(1001, "ExternalData", false, 1, null, true), + new(1002, "ExternalRelationship", false, 1, null, true), + new(1003, "ProjectUsage", false, 1, null, true), ]; } } diff --git a/SysML2.NET.CodeGenerator/HandleBarHelpers/SqlSchemaHelpers.cs b/SysML2.NET.CodeGenerator/HandleBarHelpers/SqlSchemaHelpers.cs index 23a625925..a04412c39 100644 --- a/SysML2.NET.CodeGenerator/HandleBarHelpers/SqlSchemaHelpers.cs +++ b/SysML2.NET.CodeGenerator/HandleBarHelpers/SqlSchemaHelpers.cs @@ -44,6 +44,16 @@ namespace SysML2.NET.CodeGenerator.HandleBarHelpers /// public static class SqlSchemaHelpers { + /// + /// The reference columns of the hand-written API data record tables, absent from the UML model: + /// table, column, and the single legal target kind (null when any Element is legal). + /// + private static readonly IReadOnlyList<(string TableName, string ColumnName, string TargetKindName)> ApiRecordReferenceColumns = + [ + ("external_relationship_version", "element_end", null), + ("external_relationship_version", "external_data_end", "ExternalData"), + ]; + /// /// Registers the SQL schema helpers /// @@ -244,6 +254,10 @@ public static void RegisterUmlTemplateSqlSchemaHelpers(this IHandlebars handleba }) .ToList(); + boundedSources.AddRange(ApiRecordReferenceColumns + .Select(source => (source.TableName, source.ColumnName, + AllowedTargetKinds: source.TargetKindName == null ? null : (IReadOnlyList)[registrationsByName[source.TargetKindName].Id]))); + var fullPassBlocks = boundedSources .Select(source => FormatReferenceValidationBlock(source.TableName, source.ColumnName, source.AllowedTargetKinds)); @@ -407,9 +421,44 @@ private static IReadOnlyList> QueryClassKinds(Handleba /// /// The subject /// + /// + /// Thrown when is null + /// + /// + /// Thrown on any drift between the UML model and the registry + /// public static void AssertRegistryInSyncWithModel(HandlebarsPayload payload) { - var newestVersion = ClassKindRegistry.ModelVersions[^1]; + AssertRegistryInSyncWithModel(payload, ClassKindRegistry.ModelVersions, ClassKindRegistry.ClassKinds); + } + + /// + /// Asserts that the UML model matches the newest release of the given registrations: the + /// fingerprint, the API-record id band, every model class registered, no stale metaclass + /// registration, no API record named like a model class, and matching abstractness + /// + /// + /// The subject + /// + /// + /// The registered metamodel releases, ordered by ordinal; the last one must match the model + /// + /// + /// The class-kind registrations to validate the model against + /// + /// + /// Thrown when any argument is null + /// + /// + /// Thrown on any drift between the UML model and the registrations + /// + public static void AssertRegistryInSyncWithModel(HandlebarsPayload payload, IReadOnlyList modelVersions, IReadOnlyList classKinds) + { + ArgumentNullException.ThrowIfNull(payload); + ArgumentNullException.ThrowIfNull(modelVersions); + ArgumentNullException.ThrowIfNull(classKinds); + + var newestVersion = modelVersions[^1]; var fingerprint = $"{payload.RootPackage.Name}:{payload.RootPackage.XmiId}"; if (fingerprint != newestVersion.SourceFingerprint) @@ -420,7 +469,24 @@ public static void AssertRegistryInSyncWithModel(HandlebarsPayload payload) "release, or update the fingerprint in place for an editorial change that adds or removes no metaclasses."); } - var registrationsByName = ClassKindRegistry.ClassKinds.ToDictionary(registration => registration.Name); + var misplacedRegistrations = classKinds + .Where(registration => registration.IsApiRecord != registration.Id >= ClassKindRegistry.ApiRecordIdFloor) + .ToList(); + + if (misplacedRegistrations.Count != 0) + { + var misplacedNames = string.Join(", ", misplacedRegistrations.Select(registration => registration.Name)); + + throw new InvalidOperationException( + $"ClassKindRegistry ids at or above {ClassKindRegistry.ApiRecordIdFloor} are reserved for API data records and metaclass ids " + + $"must stay below it: {misplacedNames}."); + } + + var metaclassRegistrations = classKinds + .Where(registration => !registration.IsApiRecord) + .ToList(); + + var registrationsByName = metaclassRegistrations.ToDictionary(registration => registration.Name); var orderedClasses = QueryOrderedClasses(payload); var unregisteredClasses = orderedClasses @@ -429,7 +495,7 @@ public static void AssertRegistryInSyncWithModel(HandlebarsPayload payload) if (unregisteredClasses.Count != 0) { - var maxId = ClassKindRegistry.ClassKinds.Max(registration => registration.Id); + var maxId = metaclassRegistrations.Max(registration => registration.Id); var suggestedRegistrations = string.Join("\n", unregisteredClasses .Select((@class, classIndex) => $" new({maxId + classIndex + 1}, \"{@class.Name}\", {(@class.IsAbstract ? "true" : "false")}, {newestVersion.Id}),")); @@ -443,7 +509,7 @@ public static void AssertRegistryInSyncWithModel(HandlebarsPayload payload) .Select(@class => @class.Name) .ToHashSet(); - var staleRegistrations = ClassKindRegistry.ClassKinds + var staleRegistrations = metaclassRegistrations .Where(registration => registration.RemovedIn == null && !modelClassNames.Contains(registration.Name)) .ToList(); @@ -456,6 +522,18 @@ public static void AssertRegistryInSyncWithModel(HandlebarsPayload payload) "close them with RemovedIn = the id of the release that dropped them — never delete a registration."); } + var collidingRecords = classKinds + .Where(registration => registration.IsApiRecord && modelClassNames.Contains(registration.Name)) + .ToList(); + + if (collidingRecords.Count != 0) + { + var collidingNames = string.Join(", ", collidingRecords.Select(registration => registration.Name)); + + throw new InvalidOperationException( + $"ClassKindRegistry registers API data records whose names the UML model also declares as metaclasses: {collidingNames}."); + } + var driftedRegistrations = orderedClasses .Where(@class => registrationsByName[@class.Name].IsAbstract != @class.IsAbstract) .ToList(); @@ -598,48 +676,48 @@ private static IReadOnlyList QueryAllowedTargetKinds(HandlebarsPayload payl /// /// /// The legal target class_kind ids, or null when every concrete metaclass is legal (an - /// Element-typed reference) and the type check is omitted + /// Element-typed reference) and only an API data record target is the wrong type /// /// /// The formatted SELECT arm, without a trailing separator /// private static string FormatReferenceValidationBlock(string tableName, string columnName, IReadOnlyList allowedTargetKinds) { + var wrongTypePredicate = FormatWrongTypePredicate(allowedTargetKinds); var stringBuilder = new StringBuilder(); - if (allowedTargetKinds == null) - { - stringBuilder.AppendLine($" SELECT '{tableName}'::text, '{columnName}'::text,"); - stringBuilder.AppendLine($" snap.identity_id, src.{columnName}, 'dangling'::text"); - stringBuilder.AppendLine($" FROM sysml2.{tableName} src"); - stringBuilder.AppendLine(" JOIN validation_snapshot snap ON snap.version_id = src.version_id"); - stringBuilder.AppendLine($" JOIN sysml2.data_identity ti ON ti.id = src.{columnName}"); - stringBuilder.AppendLine($" LEFT JOIN validation_snapshot live ON live.identity_id = src.{columnName}"); - stringBuilder.AppendLine(" WHERE src.project_id = p_project_id"); - stringBuilder.AppendLine($" AND src.{columnName} IS NOT NULL"); - stringBuilder.AppendLine(" AND ti.project_id = p_project_id"); - stringBuilder.Append(" AND live.identity_id IS NULL"); - - return stringBuilder.ToString(); - } - - var allowedIds = string.Join(", ", allowedTargetKinds); - stringBuilder.AppendLine($" SELECT '{tableName}'::text, '{columnName}'::text,"); stringBuilder.AppendLine($" snap.identity_id, src.{columnName},"); - stringBuilder.AppendLine($" CASE WHEN ti.class_kind NOT IN ({allowedIds}) THEN 'wrong-type' ELSE 'dangling' END"); + stringBuilder.AppendLine($" CASE WHEN {wrongTypePredicate} THEN 'wrong-type' ELSE 'dangling' END"); stringBuilder.AppendLine($" FROM sysml2.{tableName} src"); stringBuilder.AppendLine(" JOIN validation_snapshot snap ON snap.version_id = src.version_id"); stringBuilder.AppendLine($" JOIN sysml2.data_identity ti ON ti.id = src.{columnName}"); stringBuilder.AppendLine($" LEFT JOIN validation_snapshot live ON live.identity_id = src.{columnName}"); stringBuilder.AppendLine(" WHERE src.project_id = p_project_id"); stringBuilder.AppendLine($" AND src.{columnName} IS NOT NULL"); - stringBuilder.AppendLine($" AND (ti.class_kind NOT IN ({allowedIds})"); + stringBuilder.AppendLine($" AND ({wrongTypePredicate}"); stringBuilder.Append(" OR (ti.project_id = p_project_id AND live.identity_id IS NULL))"); return stringBuilder.ToString(); } + /// + /// Formats the predicate on the typed identity alias ti that classifies a reference target as + /// 'wrong-type': outside the allowed kinds, or any API data record when every metaclass is legal + /// + /// + /// The legal target class_kind ids, or null when every concrete metaclass is legal + /// + /// + /// The SQL boolean expression + /// + private static string FormatWrongTypePredicate(IReadOnlyList allowedTargetKinds) + { + return allowedTargetKinds == null + ? $"ti.class_kind >= {ClassKindRegistry.ApiRecordIdFloor}" + : $"ti.class_kind NOT IN ({string.Join(", ", allowedTargetKinds)})"; + } + /// /// Formats one UNION ALL arm of the OUTGOING half of validate_references_in_commit: the /// references carried by the versions the commit itself wrote. Liveness is probed per @@ -653,30 +731,22 @@ private static string FormatReferenceValidationBlock(string tableName, string co /// The reference column /// /// - /// The legal target class_kind ids, or null when every concrete metaclass is legal and the - /// type check is omitted + /// The legal target class_kind ids, or null when every concrete metaclass is legal and only + /// an API data record target is the wrong type /// /// /// The formatted SELECT arm, without a trailing separator /// private static string FormatOutgoingValidationBlock(string tableName, string columnName, IReadOnlyList allowedTargetKinds) { + var wrongTypePredicate = FormatWrongTypePredicate(allowedTargetKinds); var stringBuilder = new StringBuilder(); var isCoreTable = tableName == "element_version"; var sourceAlias = isCoreTable ? "changed" : "src"; stringBuilder.AppendLine($" SELECT '{tableName}'::text, '{columnName}'::text,"); stringBuilder.AppendLine($" changed.identity_id, {sourceAlias}.{columnName},"); - - if (allowedTargetKinds == null) - { - stringBuilder.AppendLine(" 'dangling'::text"); - } - else - { - stringBuilder.AppendLine($" CASE WHEN ti.class_kind NOT IN ({string.Join(", ", allowedTargetKinds)}) THEN 'wrong-type' ELSE 'dangling' END"); - } - + stringBuilder.AppendLine($" CASE WHEN {wrongTypePredicate} THEN 'wrong-type' ELSE 'dangling' END"); stringBuilder.AppendLine(" FROM sysml2.element_version changed"); if (!isCoreTable) @@ -695,15 +765,8 @@ private static string FormatOutgoingValidationBlock(string tableName, string col $"(ti.project_id = p_project_id\n" + $" AND NOT EXISTS (SELECT 1 FROM sysml2.resolve_element_at_commit(p_project_id, p_commit_id, {sourceAlias}.{columnName})))"; - if (allowedTargetKinds == null) - { - stringBuilder.Append($" AND {livenessCheck}"); - } - else - { - stringBuilder.AppendLine($" AND (ti.class_kind NOT IN ({string.Join(", ", allowedTargetKinds)})"); - stringBuilder.Append($" OR {livenessCheck})"); - } + stringBuilder.AppendLine($" AND ({wrongTypePredicate}"); + stringBuilder.Append($" OR {livenessCheck})"); return stringBuilder.ToString(); } diff --git a/SysML2.NET.CodeGenerator/Templates/Uml/core-classkind-enum-template.hbs b/SysML2.NET.CodeGenerator/Templates/Uml/core-classkind-enum-template.hbs index 37bc1a3f0..555e2c10a 100644 --- a/SysML2.NET.CodeGenerator/Templates/Uml/core-classkind-enum-template.hbs +++ b/SysML2.NET.CodeGenerator/Templates/Uml/core-classkind-enum-template.hbs @@ -32,13 +32,15 @@ namespace SysML2.NET.Core /// (SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistry.cs) and are /// frozen forever: never renumbered, never deleted. The canonical identity of a metaclass /// remains its name (the API @type value); this enum is the registry interning of it. + /// Values from 1000 upward are the Systems Modeling API Clause 7 data records, versioned + /// through the commit stream like elements. /// [GeneratedCode("SysML2.NET", "latest")] public enum ClassKind : short { {{ #each this.Members as | member | }} /// - /// The {{ member.Name }} metaclass{{#if member.IsAbstract}} (abstract){{/if}} — frozen class_kind id {{ member.Id }}, introduced in {{ member.IntroducedIn }}.{{#if member.RemovedIn}} Removed in {{ member.RemovedIn }}.{{/if}} + /// The {{ member.Name }} {{#if member.IsApiRecord}}API data record{{else}}metaclass{{/if}}{{#if member.IsAbstract}} (abstract){{/if}} — frozen class_kind id {{ member.Id }}, introduced in {{ member.IntroducedIn }}.{{#if member.RemovedIn}} Removed in {{ member.RemovedIn }}.{{/if}} /// {{ member.Name }} = {{ member.Id }}, diff --git a/SysML2.NET.CodeGenerator/Templates/Uml/core-sql-schema.hbs b/SysML2.NET.CodeGenerator/Templates/Uml/core-sql-schema.hbs index 8904baa5a..72aed77ce 100644 --- a/SysML2.NET.CodeGenerator/Templates/Uml/core-sql-schema.hbs +++ b/SysML2.NET.CodeGenerator/Templates/Uml/core-sql-schema.hbs @@ -49,6 +49,12 @@ SET search_path = sysml2, public; -- stale registration, fingerprint mismatch) instead of silently renumbering, and the seed -- INSERTs are idempotent (ON CONFLICT DO NOTHING) — safe to re-apply to a populated database. -- +-- Ids at or above 1000 are RESERVED for the Systems Modeling API Clause 7 data records +-- (ExternalData, ExternalRelationship, ProjectUsage). DataVersion.payload is typed Data, which +-- those records realize alongside Element, so they flow through the same identity/version +-- stream (§4, §5, §7b) — the band is how DDL tells a record row from an element row. They are +-- not metaclasses: the UML drift check skips them, and metaclass ids stay below the band. +-- -- The property->storage routing that earlier designs kept in a property_catalog table is -- deliberately NOT in the database: the generated service layer carries it as static -- per-release C# (model-version descriptors). Nothing in this schema reads such a catalog — @@ -120,7 +126,8 @@ CREATE TABLE sysml2.commit ( -- stamp, never from the branch or the project. model_version_id smallint NOT NULL REFERENCES sysml2.model_version (id), - PRIMARY KEY (id) + PRIMARY KEY (id), + UNIQUE (id, project_id) -- FK target for project_usage_version's usedProject consistency check (§7b) ); CREATE INDEX ix_commit_project_created ON sysml2.commit (project_id, created DESC); @@ -297,14 +304,6 @@ CREATE TABLE sysml2.tag ( CREATE UNIQUE INDEX ux_tag_project_name_live ON sysml2.tag (project_id, name) WHERE deleted IS NULL; -CREATE TABLE sysml2.project_usage ( - id uuid NOT NULL, - project_id uuid NOT NULL REFERENCES sysml2.project (id) ON DELETE CASCADE, - used_project_id uuid NOT NULL REFERENCES sysml2.project (id), - used_project_commit_id uuid NOT NULL REFERENCES sysml2.commit (id), - PRIMARY KEY (id) -); - -- Query is the spec's stored-query record (Clause 7): a saved select/where/orderBy definition, -- executed on demand against a commit or branch head. Mutable + destructible — the API exposes -- PUT and DELETE for it. The definition is stored as the spec's own JSON shape; the service's @@ -334,7 +333,7 @@ CREATE UNIQUE INDEX ux_query_project_name_live -- This table is deliberately NOT partitioned and its PK is the bare uuid: ProjectUsage lets an -- element in project A reference an element in project B, so a (project_id, id) composite key -- would make every cross-project reference un-FK-able. Project scoping is enforced by the --- service layer via project_usage, not by the FK. +-- service layer via the usage resolved at the commit (project_usage_version, §7b), not by the FK. ------------------------------------------------------------------------------------------------ -- DELETION IS EXPLICIT, NOT CASCADED. At scale (~1M identities per project), FK cascades are a @@ -343,7 +342,14 @@ CREATE UNIQUE INDEX ux_query_project_name_live -- Project deletion is therefore an ordered, batched, per-table procedure (each statement prunes -- to one partition and uses a PK prefix): -- --- DELETE FROM sysml2. WHERE project_id = $1; +-- -- 0. While any commit of ANOTHER project pins this project through project_usage_version +-- -- (§7b), its NO ACTION FK onto commit blocks the procedure: the API answers 409. This +-- -- is the deleteProject behaviour chosen from the PIM's ProjectService note ("only if +-- -- none of their commits are referenced by project usages in any commit of any other +-- -- project"). Removing that usage history to proceed is an ADMINISTRATIVE PURGE — an +-- -- explicit exception to the append-only rule of §5 that also drops DataVersions from +-- -- the using project's commit change sets. +-- DELETE FROM sysml2. WHERE project_id = $1; -- §6, §7 and §7b -- DELETE FROM sysml2.derived_version WHERE project_id = $1; -- DELETE FROM sysml2.element_version WHERE project_id = $1; -- DELETE FROM sysml2.branch_head WHERE project_id = $1; @@ -375,7 +381,7 @@ CREATE TABLE sysml2.data_identity ( UNIQUE (id, class_kind) -- FK target for element_version's type-consistency check ); -CREATE INDEX ix_data_identity_project ON sysml2.data_identity (project_id); +CREATE INDEX ix_data_identity_project_kind ON sysml2.data_identity (project_id, class_kind); ------------------------------------------------------------------------------------------------ -- 5. STORED ELEMENT STATE — append-only [HAND-WRITTEN] @@ -422,8 +428,13 @@ CREATE TABLE sysml2.element_version ( CONSTRAINT element_version_tombstone_empty CHECK (NOT tombstone OR (stored_json IS NULL AND element_id IS NULL)), + -- API data records (class_kind >= 1000, §2 / §7b) carry stored_json only — never Element columns CONSTRAINT element_version_payload_present - CHECK (tombstone OR (stored_json IS NOT NULL AND element_id IS NOT NULL AND is_implied_included IS NOT NULL)) + CHECK (tombstone OR (stored_json IS NOT NULL + AND (class_kind >= 1000 OR (element_id IS NOT NULL AND is_implied_included IS NOT NULL)))), + CONSTRAINT element_version_api_record_shape + CHECK (class_kind < 1000 OR (element_id IS NULL AND declared_name IS NULL AND declared_short_name IS NULL + AND is_implied_included IS NULL AND owning_relationship IS NULL)) ) PARTITION BY HASH (project_id); -- Clause 7.1.2: "DataVersion.identity is unique among records listed in Commit.change." @@ -495,6 +506,70 @@ CREATE INDEX ix_element_version_owning_rel ON sysml2.element_version (project_id ------------------------------------------------------------------------------------------------ {{ #uml_template.SQL.WriteSubtypeTables this }} +------------------------------------------------------------------------------------------------ +-- 7b. API DATA RECORDS — ExternalData, ExternalRelationship, ProjectUsage [HAND-WRITTEN] +-- +-- Systems Modeling API Clause 7: DataVersion.payload is typed Data, realized by Element AND by +-- these three records. The REST binding posts them inside Commit.change like any element, and +-- ProjectUsageService / ExternalRelationshipService read at a commit and write through a branch +-- (a new commit; the branch head moves). So a record is an identity (§4, class_kind in the +-- >= 1000 band of §2) plus element_version rows (§5: tombstone = deletion, stored_json = the +-- wire payload, no Element columns), and these subtype tables hold its typed columns exactly as +-- the generated §7 tables do for metaclasses. Everything downstream — CAS commit protocol, +-- branch_head overlay, checkpoints, resolvers, diff, merge, GET .../changes — applies unchanged. +-- +-- Deliberately NOT partitioned: a project holds a handful of usages and at most thousands of +-- external relationships; the (project_id, version_id) PK keeps every probe project-local. +-- +-- Read functions live in §10 (resolve_kind_at_commit and the get_*_at_commit family). +------------------------------------------------------------------------------------------------ + +CREATE TABLE sysml2.external_data_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + resource_identifier text NOT NULL, -- ExternalData::resourceIdentifier : IRI [1..1] + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE +); + +-- elementEnd names ANY Element identity — cross-project targets included, as for every reference +-- (§4); externalDataEnd names an ExternalData identity. Both are type-checked by §13 through the +-- typed identity, and reverse-indexed for getExternalRelationshipsByElement and §13's incoming arm. +CREATE TABLE sysml2.external_relationship_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + element_end uuid NOT NULL REFERENCES sysml2.data_identity (id), + external_data_end uuid NOT NULL REFERENCES sysml2.data_identity (id), + language text NULL, + specification text NULL, + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE +); + +CREATE INDEX ix_external_relationship_version_element_end + ON sysml2.external_relationship_version (project_id, element_end); +CREATE INDEX ix_external_relationship_version_external_data_end + ON sysml2.external_relationship_version (project_id, external_data_end); + +-- The PIM constraint usedProject = usedProjectCommit.owningProject is the composite FK onto +-- commit (id, project_id): a usage can never pin a commit of a different project. Both FKs are +-- NO ACTION, so a used project stays undeletable while any commit anywhere pins it (§4, step 0). +CREATE TABLE sysml2.project_usage_version ( + project_id uuid NOT NULL, + version_id uuid NOT NULL, + used_project uuid NOT NULL REFERENCES sysml2.project (id), + used_project_commit uuid NOT NULL REFERENCES sysml2.commit (id), + PRIMARY KEY (project_id, version_id), + FOREIGN KEY (project_id, version_id) + REFERENCES sysml2.element_version (project_id, version_id) ON DELETE CASCADE, + FOREIGN KEY (used_project_commit, used_project) REFERENCES sysml2.commit (id, project_id) +); + +CREATE INDEX ix_project_usage_version_used_project + ON sysml2.project_usage_version (used_project); + ------------------------------------------------------------------------------------------------ -- 8. DERIVED ELEMENT STATE — the second append-only stream [HAND-WRITTEN] -- @@ -814,6 +889,12 @@ $$; -- -- The normalized columns of §5-§7 are the system of record and carry the referential integrity; -- stored_json / derived_json are the read model built from them at commit time. +-- +-- API data records (§7b) are read by KIND: resolve_kind_at_commit folds one class_kind over the +-- checkpoint-bounded ancestry, driven from the few identities of that kind — O(records x log), +-- never O(model). Reads at a branch head go through branch.head_commit_id and that fold (no +-- overlay fast path; records are few). A record by id is get_element_at_commit — its payload is +-- the record's wire JSON. The element SET reads below return Elements only (class_kind < 1000). ------------------------------------------------------------------------------------------------ -- Branch-head single-element read over the OVERLAY: the overlay row wins (a tombstoned overlay @@ -873,7 +954,8 @@ AS $$ ON ev.project_id = h.project_id AND ev.version_id = h.version_id LEFT JOIN sysml2.derived_version dv ON dv.project_id = h.project_id AND dv.derived_id = h.derived_id - WHERE NOT ev.tombstone; + WHERE NOT ev.tombstone + AND ev.class_kind < 1000; $$; CREATE OR REPLACE FUNCTION sysml2.get_elements_at_commit(p_project_id uuid, p_commit_id uuid) @@ -887,7 +969,8 @@ AS $$ JOIN sysml2.element_version ev ON ev.project_id = p_project_id AND ev.version_id = r.version_id LEFT JOIN sysml2.derived_version dv - ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id; + ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id + WHERE ev.class_kind < 1000; $$; CREATE OR REPLACE FUNCTION sysml2.get_element_at_commit(p_project_id uuid, p_commit_id uuid, p_identity_id uuid) @@ -903,6 +986,131 @@ AS $$ ON dv.project_id = p_project_id AND dv.derived_id = r.derived_id; $$; +-- Kind-filtered resolver for the API data records (§7b): the same checkpoint-bounded walk as +-- resolve_element_at_commit, folded over ONE class_kind. The checkpoint arm is driven from the +-- identities of that kind (ix_data_identity_project_kind) probing the commit_checkpoint PK, so +-- the cost is O(records x walked ancestry) — never a scan of the checkpoint. +CREATE OR REPLACE FUNCTION sysml2.resolve_kind_at_commit(p_project_id uuid, p_commit_id uuid, p_class_kind smallint) +RETURNS TABLE (identity_id uuid, version_id uuid) +LANGUAGE sql +STABLE +AS $$ + WITH RECURSIVE + checkpoint AS ( + SELECT ccr.commit_id + FROM sysml2.commit_checkpoint_registry ccr + WHERE ccr.project_id = p_project_id AND ccr.commit_id = p_commit_id + ), + ancestry AS ( + SELECT c.id, c.created, (SELECT count(*) > 0 FROM checkpoint) AS at_checkpoint + FROM sysml2.commit c + WHERE c.id = p_commit_id + + UNION + + SELECT parent.id, parent.created, + EXISTS (SELECT 1 FROM sysml2.commit_checkpoint_registry ccr + WHERE ccr.project_id = p_project_id AND ccr.commit_id = parent.id) + FROM ancestry a + JOIN sysml2.commit_parent cp ON cp.commit_id = a.id + JOIN sysml2.commit parent ON parent.id = cp.parent_commit_id + WHERE NOT a.at_checkpoint + ), + folded AS ( + SELECT DISTINCT ON (ev.identity_id) + ev.identity_id, + ev.version_id, + ev.tombstone + FROM ancestry a + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.commit_id = a.id AND ev.class_kind = p_class_kind + ORDER BY ev.identity_id, a.created DESC, a.id DESC + ), + checkpointed AS ( + SELECT cc.identity_id, cc.version_id + FROM sysml2.data_identity di + JOIN ancestry a ON a.at_checkpoint + JOIN sysml2.commit_checkpoint cc + ON cc.project_id = p_project_id AND cc.commit_id = a.id AND cc.identity_id = di.id + WHERE di.project_id = p_project_id + AND di.class_kind = p_class_kind + AND NOT EXISTS (SELECT 1 FROM folded f WHERE f.identity_id = di.id) + ) + SELECT f.identity_id, f.version_id FROM folded f WHERE NOT f.tombstone + UNION ALL + SELECT c.identity_id, c.version_id FROM checkpointed c; +$$; + +-- ProjectUsageService::getProjectUsages(project, commit). +CREATE OR REPLACE FUNCTION sysml2.get_project_usages_at_commit(p_project_id uuid, p_commit_id uuid) +RETURNS TABLE (identity_id uuid, used_project uuid, used_project_commit uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT r.identity_id, pu.used_project, pu.used_project_commit, ev.stored_json + FROM sysml2.resolve_kind_at_commit(p_project_id, p_commit_id, + (SELECT ck.id FROM sysml2.class_kind ck WHERE ck.name = 'ProjectUsage')) r + JOIN sysml2.project_usage_version pu + ON pu.project_id = p_project_id AND pu.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id; +$$; + +-- GET /projects/{p}/commits/{c}/elements/{e}/projectUsage: the usage, at the commit, whose used +-- project owns the element's identity. +CREATE OR REPLACE FUNCTION sysml2.get_project_usage_for_element_at_commit(p_project_id uuid, p_commit_id uuid, p_identity_id uuid) +RETURNS TABLE (identity_id uuid, used_project uuid, used_project_commit uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT pu.identity_id, pu.used_project, pu.used_project_commit, pu.payload + FROM sysml2.data_identity di + JOIN sysml2.get_project_usages_at_commit(p_project_id, p_commit_id) pu + ON pu.used_project = di.project_id + WHERE di.id = p_identity_id; +$$; + +-- ExternalRelationshipService::getExternalRelationships(project, commit). +CREATE OR REPLACE FUNCTION sysml2.get_external_relationships_at_commit(p_project_id uuid, p_commit_id uuid) +RETURNS TABLE (identity_id uuid, element_end uuid, external_data_end uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + SELECT r.identity_id, er.element_end, er.external_data_end, ev.stored_json + FROM sysml2.resolve_kind_at_commit(p_project_id, p_commit_id, + (SELECT ck.id FROM sysml2.class_kind ck WHERE ck.name = 'ExternalRelationship')) r + JOIN sysml2.external_relationship_version er + ON er.project_id = p_project_id AND er.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id; +$$; + +-- ExternalRelationshipService::getExternalRelationshipsByElement(project, commit, elementId): +-- candidates through the reverse index, each resolved at the commit; a candidate counts only +-- when its RESOLVED version still names the element. +CREATE OR REPLACE FUNCTION sysml2.get_external_relationships_by_element_at_commit(p_project_id uuid, p_commit_id uuid, p_element_id uuid) +RETURNS TABLE (identity_id uuid, element_end uuid, external_data_end uuid, payload jsonb) +LANGUAGE sql +STABLE +AS $$ + WITH candidates AS ( + SELECT DISTINCT ev.identity_id + FROM sysml2.external_relationship_version er + JOIN sysml2.element_version ev + ON ev.project_id = er.project_id AND ev.version_id = er.version_id + WHERE er.project_id = p_project_id + AND er.element_end = p_element_id + ) + SELECT r.identity_id, er.element_end, er.external_data_end, ev.stored_json + FROM candidates c + CROSS JOIN LATERAL sysml2.resolve_element_at_commit(p_project_id, p_commit_id, c.identity_id) r + JOIN sysml2.external_relationship_version er + ON er.project_id = p_project_id AND er.version_id = r.version_id + JOIN sysml2.element_version ev + ON ev.project_id = p_project_id AND ev.version_id = r.version_id + WHERE er.element_end = p_element_id; +$$; + ------------------------------------------------------------------------------------------------ -- 11. PER-METACLASS FLATTENING VIEWS [GENERATED] -- @@ -1058,7 +1266,7 @@ $$; -- checked via the TYPED IDENTITY (data_identity.class_kind, §4) — this -- check applies to cross-project targets too. -- Liveness of cross-project targets is deliberately NOT checked here: it depends on the --- used-project commit (project_usage), which is service-layer resolution. +-- used-project commit (project_usage_version, §7b), which is service-layer resolution. -- -- TIER 1 — validate_references_at_commit: the FULL periodic audit over one commit's whole -- snapshot. It materializes and ANALYZEs the snapshot in a temp table first, so the planner diff --git a/SysML2.NET/Core/AutoGenEnum/ClassKind.cs b/SysML2.NET/Core/AutoGenEnum/ClassKind.cs index 30c0dba27..41d5d61ea 100644 --- a/SysML2.NET/Core/AutoGenEnum/ClassKind.cs +++ b/SysML2.NET/Core/AutoGenEnum/ClassKind.cs @@ -32,6 +32,8 @@ namespace SysML2.NET.Core /// (SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/ClassKindRegistry.cs) and are /// frozen forever: never renumbered, never deleted. The canonical identity of a metaclass /// remains its name (the API @type value); this enum is the registry interning of it. + /// Values from 1000 upward are the Systems Modeling API Clause 7 data records, versioned + /// through the commit stream like elements. /// [GeneratedCode("SysML2.NET", "latest")] public enum ClassKind : short @@ -911,6 +913,21 @@ public enum ClassKind : short /// WhileLoopActionUsage = 175, + /// + /// The ExternalData API data record — frozen class_kind id 1001, introduced in sysml-2.0-2026-08. + /// + ExternalData = 1001, + + /// + /// The ExternalRelationship API data record — frozen class_kind id 1002, introduced in sysml-2.0-2026-08. + /// + ExternalRelationship = 1002, + + /// + /// The ProjectUsage API data record — frozen class_kind id 1003, introduced in sysml-2.0-2026-08. + /// + ProjectUsage = 1003, + } }