From 7af9458c59b6aa7d9e0a6e64006e46f20eab2102 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Wed, 14 Oct 2026 12:58:17 +1100 Subject: [PATCH 1/2] mcp: create entities and add attributes in bulk (one validated transaction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_entity + N add_attribute calls was O(N) MCP round trips AND O(N) full load/save/validate cycles — building a 54-column entity meant 55 calls, and a mid-sequence failure left a half-built entity. Add a bulk path end to end: - core: new add_attributes command adds many columns to one entity in a single load->append-all->one save->one validate; create_entity takes an optional inline attributes list. Both share _append_attribute, which rejects a duplicate name against existing columns AND earlier in the same batch, so the whole batch is all-or-nothing. CommandResult gains created_ids (the full list; created_id stays the first id for back-compat). - mcp: create_entity gains an attributes param; update_entity's add_attribute accepts EITHER one object or a list. New ids come back as created_ids / attribute_ids so an agent can build a composite key from them with no re-read. - server: add_attributes is proposable (SME PR flow); preview records a bulk change's primary (first) id, mirroring apply_command. Tests: core (inline create, bulk add, all-or-nothing on dup, empty-list reject, supplied-id parity) + mcp (one-call wide entity, list add, dup roll-back, and the create->key-group-from-returned-ids money path). Full suite 999 passed. --- packages/core/src/mdl_core/commands.py | 77 +++++++++++-- .../core/tests/test_structural_commands.py | 103 ++++++++++++++++++ packages/mcp/src/mdl_mcp/server.py | 59 ++++++++-- packages/mcp/tests/test_server.py | 90 +++++++++++++++ packages/server/src/mdl_server/git_api.py | 1 + packages/server/src/mdl_server/preview.py | 5 +- 6 files changed, 311 insertions(+), 24 deletions(-) diff --git a/packages/core/src/mdl_core/commands.py b/packages/core/src/mdl_core/commands.py index 8a93f1a..0d7258e 100644 --- a/packages/core/src/mdl_core/commands.py +++ b/packages/core/src/mdl_core/commands.py @@ -98,6 +98,10 @@ class CommandResult: fingerprint: str diagnostics: list[dict] = field(default_factory=list) created_id: str | None = None + # Bulk handlers (e.g. add_attributes) mint several ids in one validated transaction. + # created_id stays the FIRST of these for single-create back-compat; created_ids holds + # them all, in creation order, so a caller can reference each new object. + created_ids: list[str] = field(default_factory=list) error: str | None = None @@ -120,9 +124,19 @@ def apply_command( raise CommandError(f"unknown command {op!r}") repo = ModelRepo.load(model_dir) - created_id = handler(repo, payload) + result = handler(repo, payload) repo.save() + # A handler returns either a single new id (str), several (list[str], a bulk op), or + # None. Normalise: created_id is the first id (single-create back-compat), created_ids + # is the full list. + if isinstance(result, list): + created_ids = result + created_id = created_ids[0] if created_ids else None + else: + created_id = result + created_ids = [created_id] if created_id else [] + diags = validate(ModelRepo.load(model_dir).model) return CommandResult( ok=True, @@ -132,6 +146,7 @@ def apply_command( for d in diags.items ], created_id=created_id, + created_ids=created_ids, ) @@ -218,9 +233,20 @@ def _create_entity(repo: ModelRepo, p: dict) -> str: "realises": ce_id, "attributes": [], } + # Optional inline attributes: create the shell AND its columns in one transaction, so a + # wide entity is a single create call, not create + N add_attribute round trips. The + # same _append_attribute guard rejects a duplicate name within the batch. + attr_ids: list[str] = [] + specs = p.get("attributes") + if specs is not None: + if not isinstance(specs, list): + raise CommandError("attributes must be a list") + seen: set[str] = set() + attr_ids = [_append_attribute(le, seen, spec) for spec in specs] repo.add_file(f"conceptual/entities/{name}.yaml", ce, ce_id) repo.add_file(f"logical/entities/{name}.yaml", le, le_id) - return le_id + # Entity id first (back-compat: created_id stays the entity), then each attribute id. + return [le_id, *attr_ids] if attr_ids else le_id def _rename_entity(repo: ModelRepo, p: dict) -> None: @@ -388,24 +414,50 @@ def _entity_node(repo: ModelRepo, entity_id: str): return le, _node_for(repo, le.id)[1] -def _add_attribute(repo: ModelRepo, p: dict) -> str: - _require(p, "entity_id", "name") - le, node = _entity_node(repo, p["entity_id"]) - name = _slug(p["name"]) - if any(a.name == name for a in le.attributes): - raise CommandError(f"attribute {name!r} already exists on {le.name!r}") - attr_id = _id_from(p) +def _append_attribute(node: dict, existing: set[str], spec: dict) -> str: + """Append one attribute to a logical-entity YAML `node`, guarding against a duplicate + name (checked against `existing`, which is updated in place so a bulk add also rejects + duplicates WITHIN the same batch). Returns the attribute's id. Mints an id unless the + spec supplies a valid ULID (`id`), so a preview and its replay share identities.""" + if not spec.get("name"): + raise CommandError("attribute: missing field(s): name") + name = _slug(spec["name"]) + if name in existing: + raise CommandError(f"attribute {name!r} already exists on this entity") + attr_id = _id_from(spec) attr: dict = { "id": attr_id, "name": name, - "domain": p.get("domain", "string"), - "role": p.get("role", "attribute"), - "nullable": bool(p.get("nullable", True)), + "domain": spec.get("domain", "string"), + "role": spec.get("role", "attribute"), + "nullable": bool(spec.get("nullable", True)), } node.setdefault("attributes", []).append(attr) + existing.add(name) return attr_id +def _add_attribute(repo: ModelRepo, p: dict) -> str: + _require(p, "entity_id", "name") + le, node = _entity_node(repo, p["entity_id"]) + return _append_attribute(node, {a.name for a in le.attributes}, p) + + +def _add_attributes(repo: ModelRepo, p: dict) -> list[str]: + """Add MANY attributes to one entity in a single validated transaction, so building a + 54-column entity is one load->append-all->one save->one validate instead of 54 round + trips. `attributes` is an ordered list of {name, domain?, role?, nullable?, id?}. The + whole batch is all-or-nothing: a duplicate name (against existing columns or earlier in + the same batch) raises before anything is saved. Returns the new ids in list order.""" + _require(p, "entity_id") + specs = p.get("attributes") + if not isinstance(specs, list) or not specs: + raise CommandError("attributes must be a non-empty list") + le, node = _entity_node(repo, p["entity_id"]) + existing = {a.name for a in le.attributes} + return [_append_attribute(node, existing, spec) for spec in specs] + + def _update_attribute(repo: ModelRepo, p: dict) -> None: _require(p, "entity_id", "attribute_id") _, node = _entity_node(repo, p["entity_id"]) @@ -1186,6 +1238,7 @@ def _set_subject_area_members(repo: ModelRepo, p: dict) -> None: "set_subject_area_members": _set_subject_area_members, "create_term": _create_term, "add_attribute": _add_attribute, + "add_attributes": _add_attributes, "update_attribute": _update_attribute, "delete_attribute": _delete_attribute, "create_relationship": _create_relationship, diff --git a/packages/core/tests/test_structural_commands.py b/packages/core/tests/test_structural_commands.py index 12e174c..eaf604a 100644 --- a/packages/core/tests/test_structural_commands.py +++ b/packages/core/tests/test_structural_commands.py @@ -404,3 +404,106 @@ def test_supplied_ulids_work_for_every_creator(tmp_path: Path): ) assert kg in _load(tmp_path).key_groups _clean(tmp_path) + + +# --- bulk attributes ----------------------------------------------------------- + + +def test_create_entity_with_inline_attributes(tmp_path: Path): + """create_entity can build the shell AND its columns in one validated transaction.""" + write_model(tmp_path) # seed the project so there is a model to create into + r = apply_command( + tmp_path, + "create_entity", + { + "name": "adverse_event", + "definition": "An AE record.", + "attributes": [ + {"name": "usubjid", "domain": "string", "nullable": False}, + {"name": "aeseq", "domain": "integer"}, + {"name": "aeterm"}, # domain defaults to string + ], + }, + ) + assert r.ok and r.created_id + # created_id is the entity; created_ids is entity + each attribute, in order + assert r.created_ids[0] == r.created_id + assert len(r.created_ids) == 4 + le = _load(tmp_path).logical_entities[r.created_id] + assert [a.name for a in le.attributes] == ["usubjid", "aeseq", "aeterm"] + assert le.attributes[0].nullable is False + assert le.attributes[1].domain == "integer" + assert le.attributes[2].domain == "string" # defaulted + # the minted attribute ids match what came back (entity first) + assert [a.id for a in le.attributes] == r.created_ids[1:] + _clean(tmp_path) + + +def test_add_attributes_bulk_to_existing_entity(tmp_path: Path): + ids = write_model(tmp_path) + before = len(_load(tmp_path).logical_entities[ids["le"]].attributes) + r = apply_command( + tmp_path, + "add_attributes", + { + "entity_id": ids["le"], + "attributes": [ + {"name": "lei", "domain": "string"}, + {"name": "incorporated_on", "domain": "date"}, + {"name": "jurisdiction"}, + ], + }, + ) + assert r.ok and len(r.created_ids) == 3 + le = _load(tmp_path).logical_entities[ids["le"]] + assert len(le.attributes) == before + 3 + names = {a.name for a in le.attributes} + assert {"lei", "incorporated_on", "jurisdiction"} <= names + _clean(tmp_path) + + +def test_add_attributes_is_all_or_nothing_on_duplicate(tmp_path: Path): + """A duplicate name (vs an existing column OR earlier in the same batch) rejects the + WHOLE batch — nothing is written.""" + ids = write_model(tmp_path) + existing = _load(tmp_path).logical_entities[ids["le"]].attributes + existing_name = existing[0].name + before = len(existing) + # duplicate against an existing column + with pytest.raises(CommandError, match="already exists"): + apply_command( + tmp_path, + "add_attributes", + {"entity_id": ids["le"], "attributes": [{"name": "fresh_one"}, {"name": existing_name}]}, + ) + assert len(_load(tmp_path).logical_entities[ids["le"]].attributes) == before + # duplicate WITHIN the batch + with pytest.raises(CommandError, match="already exists"): + apply_command( + tmp_path, + "add_attributes", + {"entity_id": ids["le"], "attributes": [{"name": "dup_x"}, {"name": "dup_x"}]}, + ) + assert len(_load(tmp_path).logical_entities[ids["le"]].attributes) == before + _clean(tmp_path) + + +def test_add_attributes_rejects_empty_list(tmp_path: Path): + ids = write_model(tmp_path) + with pytest.raises(CommandError, match="non-empty list"): + apply_command(tmp_path, "add_attributes", {"entity_id": ids["le"], "attributes": []}) + + +def test_add_attributes_honours_supplied_ids(tmp_path: Path): + """A caller may pre-mint ids (preview/propose parity); add_attributes uses them.""" + from mdl_core.ids import new_ulid + + ids = write_model(tmp_path) + a, b = new_ulid(), new_ulid() + r = apply_command( + tmp_path, + "add_attributes", + {"entity_id": ids["le"], "attributes": [{"name": "c_a", "id": a}, {"name": "c_b", "id": b}]}, + ) + assert r.created_ids == [a, b] + _clean(tmp_path) diff --git a/packages/mcp/src/mdl_mcp/server.py b/packages/mcp/src/mdl_mcp/server.py index 8ae5840..54d024b 100644 --- a/packages/mcp/src/mdl_mcp/server.py +++ b/packages/mcp/src/mdl_mcp/server.py @@ -221,11 +221,21 @@ def create_entity( definition: str | None = None, subject_area: str | None = None, layer: str | None = None, + attributes: list[dict[str, Any]] | None = None, ) -> str: """Create a conceptual + logical entity pair. Writes to the local model on disk (validated before it lands). `subject_area` is a subject-area ULID; - `layer` is one of industry|core|domain|specialised. Returns the new entity's - ULID or an error object.""" + `layer` is one of industry|core|domain|specialised. + + `attributes` optionally creates the entity's columns IN THE SAME CALL — pass a list + of {name, domain?, role?, nullable?} (domain defaults to "string", nullable to true). + Prefer this over create_entity + many add_attribute calls: a wide entity (e.g. 54 + columns) is then one validated transaction, not 55 round trips. The whole create is + all-or-nothing — a duplicate column name fails before anything is written. + + Returns the new entity's ULID as `created_id`; when `attributes` is given, the + minted attribute ULIDs follow in `created_ids` (entity first, then each column in + order), so you can reference them in create_key_group without a re-read.""" return _write( repo_dir, "create_entity", @@ -234,16 +244,23 @@ def create_entity( "definition": definition, "subject_area": subject_area, "layer": layer, + "attributes": attributes, }, ) @mcp.tool() def update_entity(name: str, changes: dict[str, Any]) -> str: """Update an existing entity by name (or ULID). `changes` may contain: - `definition` (str), `rename` (str, the new name), `add_attribute` - ({name, domain?, definition?, nullable?}), or `subject_area` (ULID). Each is - applied through the validated command engine; the whole update is rejected - (nothing written) if any step is invalid. Returns ok or an error object.""" + `definition` (str), `rename` (str, the new name), `add_attribute`, or + `subject_area` (ULID). Each is applied through the validated command engine; the + whole update is rejected (nothing written) if any step is invalid. + + `add_attribute` accepts EITHER one column {name, domain?, role?, nullable?} OR a + LIST of them to add many columns at once. A list is one validated transaction, so + widening an entity by many columns is a single call (not one per column); its new + ids come back as `attribute_ids` (a single column still returns `attribute_id`). + + Returns ok (with any created attribute ids) or an error object.""" le = query._entity_by_name(_model(), name) # noqa: SLF001 - shared helper if le is None: return _json({"error": f"no entity named {name!r}"}) @@ -390,6 +407,7 @@ def _apply_updates( applied: list[str] = [] result = None attr_id: str | None = None + attr_ids: list[str] = [] try: if "definition" in changes: if conceptual_id is None: @@ -416,11 +434,23 @@ def _apply_updates( applied.append("subject_area") if "add_attribute" in changes: attr = changes["add_attribute"] - if not isinstance(attr, dict) or "name" not in attr: - return _json({"error": "add_attribute must be an object with at least a name"}) - result = apply_command(repo_dir, "add_attribute", {"entity_id": entity_id, **attr}) - attr_id = result.created_id - applied.append(f"add_attribute:{attr['name']}") + # Accept EITHER one attribute object or a LIST of them. A list adds all columns + # in one validated transaction (one load/save/validate), so widening an entity + # by many columns is a single call, not one per column. + if isinstance(attr, list): + if not attr or not all(isinstance(a, dict) and a.get("name") for a in attr): + return _json({"error": "add_attribute list items must each have a name"}) + result = apply_command( + repo_dir, "add_attributes", {"entity_id": entity_id, "attributes": attr} + ) + attr_ids = result.created_ids + applied += [f"add_attribute:{a['name']}" for a in attr] + elif isinstance(attr, dict) and attr.get("name"): + result = apply_command(repo_dir, "add_attribute", {"entity_id": entity_id, **attr}) + attr_id = result.created_id + applied.append(f"add_attribute:{attr['name']}") + else: + return _json({"error": "add_attribute must be an object (or list) with a name"}) except StaleModelError as e: return _json({"error": f"model changed on disk mid-update: {e}", "applied": applied}) except (CommandError, FileNotFoundError, ValueError) as e: @@ -431,6 +461,9 @@ def _apply_updates( if attr_id is not None: # so the agent can reference the new column in create_key_group without a re-read out["attribute_id"] = attr_id + if attr_ids: + # bulk add_attribute: every new column id, in list order + out["attribute_ids"] = attr_ids if result is not None: out["diagnostics"] = result.diagnostics hints = _domain_hints(result.diagnostics) @@ -463,6 +496,10 @@ def _write(repo_dir: Path, op: str, payload: dict) -> str: "diagnostics": result.diagnostics, "error": result.error, } + # Bulk creates (create_entity with attributes, add_attributes) mint several ids; expose + # them all so the agent can reference each new column without a re-read. + if len(result.created_ids) > 1: + out["created_ids"] = result.created_ids # Call out a foreign-key domain mismatch plainly — e.g. a create_relationship with # create_from_fk whose minted column's domain disagrees with the key it references. hints = _domain_hints(result.diagnostics) diff --git a/packages/mcp/tests/test_server.py b/packages/mcp/tests/test_server.py index 19f8df3..6453ad7 100644 --- a/packages/mcp/tests/test_server.py +++ b/packages/mcp/tests/test_server.py @@ -335,3 +335,93 @@ def test_apply_reverse_config_tool_validates(model_dir): srv, "apply_reverse_config", {"block": {"layers": [{"name": "x", "role": "nope"}]}} ) assert "error" in bad + + +def test_create_entity_with_inline_attributes_one_call(model_dir): + """A wide entity is one create_entity call, not create + N add_attribute calls.""" + srv = build_server(model_dir) + r = _call( + srv, + "create_entity", + { + "name": "adverse_event", + "attributes": [ + {"name": "usubjid", "domain": "string", "nullable": False}, + {"name": "aeseq", "domain": "integer"}, + {"name": "aeterm"}, + ], + }, + ) + assert r["ok"] is True + assert r["created_id"] + # all minted ids come back: entity first, then each column, in order + assert len(r["created_ids"]) == 4 + assert r["created_ids"][0] == r["created_id"] + e = _call(srv, "get_entity", {"name": "adverse_event"}) + assert [a["name"] for a in e["attributes"]] == ["usubjid", "aeseq", "aeterm"] + + +def test_update_entity_add_many_attributes_in_a_list(model_dir): + """update_entity add_attribute accepts a LIST to widen an entity in one transaction.""" + srv = build_server(model_dir) + _call(srv, "create_entity", {"name": "lab"}) + r = _call( + srv, + "update_entity", + { + "name": "lab", + "changes": { + "add_attribute": [ + {"name": "lbseq", "domain": "integer"}, + {"name": "lbtestcd"}, + {"name": "lborres"}, + ] + }, + }, + ) + assert r["ok"] is True + assert len(r["attribute_ids"]) == 3 # every new column id, in order + e = _call(srv, "get_entity", {"name": "lab"}) + names = {a["name"] for a in e["attributes"]} + assert {"lbseq", "lbtestcd", "lborres"} <= names + + +def test_bulk_add_attribute_duplicate_is_all_or_nothing(model_dir): + """A duplicate in the list rejects the whole batch — the entity is unchanged.""" + srv = build_server(model_dir) + _call(srv, "create_entity", {"name": "vitals", "attributes": [{"name": "vsseq"}]}) + r = _call( + srv, + "update_entity", + {"name": "vitals", "changes": {"add_attribute": [{"name": "vstestcd"}, {"name": "vsseq"}]}}, + ) + assert "error" in r + e = _call(srv, "get_entity", {"name": "vitals"}) + # only the original column survives; neither of the batch's columns landed + assert [a["name"] for a in e["attributes"]] == ["vsseq"] + + +def test_bulk_attributes_then_key_group_in_one_plan(model_dir): + """The money path: create a wide entity, then build a composite key from the returned + attribute ids with no intervening re-read.""" + srv = build_server(model_dir) + r = _call( + srv, + "create_entity", + { + "name": "exposure", + "attributes": [ + {"name": "studyid"}, + {"name": "usubjid"}, + {"name": "exseq", "domain": "integer"}, + ], + }, + ) + entity_id = r["created_id"] + member_ids = r["created_ids"][1:] # the three attribute ids, in order + kg = _call( + srv, + "create_key_group", + {"entity": entity_id, "name": "pk_exposure", "type": "pk", "members": member_ids}, + ) + assert kg["ok"] is True and kg["created_id"] diff --git a/packages/server/src/mdl_server/git_api.py b/packages/server/src/mdl_server/git_api.py index 4303bf2..ad4b7a3 100644 --- a/packages/server/src/mdl_server/git_api.py +++ b/packages/server/src/mdl_server/git_api.py @@ -160,6 +160,7 @@ def _branch_user(ident: Identity, client_user: str) -> str: "rename_entity", "delete_entity", "add_attribute", + "add_attributes", "update_attribute", "delete_attribute", "create_relationship", diff --git a/packages/server/src/mdl_server/preview.py b/packages/server/src/mdl_server/preview.py index 337921e..409d970 100644 --- a/packages/server/src/mdl_server/preview.py +++ b/packages/server/src/mdl_server/preview.py @@ -112,7 +112,10 @@ def preview_model( failed_index, error = i, str(e) break if made: - created_ids[i] = made + # A bulk handler (create_entity with attributes, add_attributes) returns a + # LIST of ids; this map is change-index -> the change's primary new id, so + # record the first (the entity for create_entity), mirroring apply_command. + created_ids[i] = made[0] if isinstance(made, list) else made # Handlers mutate `raw` (the YAML nodes) but not `model` (the parsed # object graph the NEXT handler resolves ids against), and From 6b37fe0064e15d9f06c95ad20d81c08d54ec26f3 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Thu, 15 Oct 2026 14:51:39 +1100 Subject: [PATCH 2/2] server: /api/command surfaces created_ids for the bulk case too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP tools returned every minted id from a bulk create_entity/add_attributes, but the HTTP command route returned only created_id (the first id) — a canvas/API caller doing a bulk create got the entity id and lost the new attribute ids, forcing a re-read to reference the columns. Echo created_ids on the response when a command minted more than one id, matching the MCP _write convention; single-create responses are unchanged (created_id alone). Test: HTTP bulk create_entity returns entity id + each column id in order; single create has created_id and no created_ids. --- packages/server/src/mdl_server/app.py | 21 +++++++++++------- packages/server/tests/test_commands.py | 30 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/server/src/mdl_server/app.py b/packages/server/src/mdl_server/app.py index ace6d80..a8fba80 100644 --- a/packages/server/src/mdl_server/app.py +++ b/packages/server/src/mdl_server/app.py @@ -525,14 +525,19 @@ def command(body: dict, ident: Identity = Depends(_identity)) -> JSONResponse: cache["repo"] = None # bust the model cache _cache_on_align(model_dir, op, payload) audit.record("command", target=op, outcome="ok", **_who) - return JSONResponse( - { - "ok": True, - "fingerprint": result.fingerprint, - "created_id": result.created_id, - "diagnostics": result.diagnostics, - } - ) + out = { + "ok": True, + "fingerprint": result.fingerprint, + "created_id": result.created_id, + "diagnostics": result.diagnostics, + } + # A bulk command (create_entity with attributes, add_attributes) mints several + # ids in one transaction; surface them all so an API/canvas caller can reference + # each new object without a re-read, matching the MCP tools. Single-create + # responses are unchanged (created_id alone). + if len(result.created_ids) > 1: + out["created_ids"] = result.created_ids + return JSONResponse(out) # git_router is mounted unconditionally: its READS (status, branch, diff, # classify, conflicts, context, proposals) must work under --read-only, which diff --git a/packages/server/tests/test_commands.py b/packages/server/tests/test_commands.py index c173917..90955e1 100644 --- a/packages/server/tests/test_commands.py +++ b/packages/server/tests/test_commands.py @@ -291,3 +291,33 @@ def test_command_endpoint_and_read_only(model_dir): resp = ro.post("/api/command", json={"op": "create_entity", "payload": {"name": "y"}}) assert resp.status_code in (404, 405) # router not mounted assert ro.get("/api/model").json()["read_only"] is True + + +def test_command_endpoint_surfaces_bulk_created_ids(model_dir): + """A bulk create_entity (with inline attributes) must return EVERY minted id over HTTP, + so an API/canvas caller can reference the new columns without a re-read. A single create + still returns created_id alone (no created_ids noise).""" + from fastapi.testclient import TestClient + from mdl_server import create_app + + c = TestClient(create_app(model_dir)) + # single create: created_id only + single = c.post("/api/command", json={"op": "create_entity", "payload": {"name": "lonely"}}) + assert single.json()["created_id"] + assert "created_ids" not in single.json() + + # bulk create: entity id + each attribute id, in order + bulk = c.post( + "/api/command", + json={ + "op": "create_entity", + "payload": { + "name": "wide_entity", + "attributes": [{"name": "c1"}, {"name": "c2"}, {"name": "c3"}], + }, + }, + ) + body = bulk.json() + assert body["ok"] + assert body["created_ids"][0] == body["created_id"] # entity first + assert len(body["created_ids"]) == 4 # entity + 3 columns