From aceabe3e9f9b9682b47fbff8465c8b284c1f243c Mon Sep 17 00:00:00 2001 From: Nico Matentzoglu Date: Sun, 29 Mar 2026 00:16:25 +0200 Subject: [PATCH 1/2] Skip recursive map_object for scalars with no range When a slot has `range=None` and no `any_of` enums, `_map_value_by_range` now returns scalar values directly instead of recursing into `map_object` with `None` as the source type. This eliminates spurious "Unexpected: ... for type None" warnings. --- src/linkml_map/transformer/object_transformer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/linkml_map/transformer/object_transformer.py b/src/linkml_map/transformer/object_transformer.py index 973f741..8455f55 100644 --- a/src/linkml_map/transformer/object_transformer.py +++ b/src/linkml_map/transformer/object_transformer.py @@ -596,6 +596,9 @@ def _map_value_by_range( if source_class_slot.multivalued and isinstance(v, list): return [self.transform_enum(v1, any_of_enums, source_obj) for v1 in v] return self.transform_enum(v, any_of_enums, source_obj) + # No range and no any_of enums: nothing to recurse into for scalars + if not isinstance(v, (dict, list)): + return v if source_class_slot.multivalued: if isinstance(v, list): From 7ced275dbc7ee9fee27ed6cfac93261651aeab14 Mon Sep 17 00:00:00 2001 From: Nico Matentzoglu Date: Sun, 29 Mar 2026 00:23:29 +0200 Subject: [PATCH 2/2] Fix expr resolving to None when source class name contains a space dynamic_object() created Python types using the raw class name (e.g. "my record"), but ObjectIndex._class_map is keyed by camelcase names via SchemaView.class_name_mappings() (e.g. "MyRecord"). When ObjectIndex.bless() looked up type(obj).__name__, the mismatch caused a KeyError, silently returning None for all expr-based slot derivations. Apply camelcase() to the type name in dynamic_object() to match ObjectIndex's class map. The populated_from path was unaffected because it reads directly from the source dict without going through ObjectIndex. Fixes #170 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/linkml_map/utils/dynamic_object.py | 5 +- .../test_class_name_spaces.py | 130 ++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/test_transformer/test_class_name_spaces.py diff --git a/src/linkml_map/utils/dynamic_object.py b/src/linkml_map/utils/dynamic_object.py index f6752a3..47ae56c 100644 --- a/src/linkml_map/utils/dynamic_object.py +++ b/src/linkml_map/utils/dynamic_object.py @@ -3,6 +3,7 @@ from typing import Any from linkml_runtime import SchemaView +from linkml_runtime.utils.formatutils import camelcase class DynObj: @@ -47,5 +48,7 @@ def dynamic_object(obj: dict, sv: SchemaView, target: str): else: v = dynamic_object(v, sv, rng) attrs[k] = v - cls = type(target, (DynObj,), {}) + # Use camelcase so the Python type name matches ObjectIndex._class_map, + # which is keyed by SchemaView.class_name_mappings() (camelcase). + cls = type(camelcase(target), (DynObj,), {}) return cls(**attrs) diff --git a/tests/test_transformer/test_class_name_spaces.py b/tests/test_transformer/test_class_name_spaces.py new file mode 100644 index 0000000..7efd77e --- /dev/null +++ b/tests/test_transformer/test_class_name_spaces.py @@ -0,0 +1,130 @@ +"""Regression test for expr with spaces in source class names. + +When the source class name contains a space (e.g. "my record"), expr-based +slot derivations should still be able to access source object fields. + +See: https://github.com/linkml/linkml-map/issues/170 +""" + +import textwrap + +from linkml_runtime import SchemaView + +from linkml_map.transformer.object_transformer import ObjectTransformer + +SOURCE_SCHEMA = textwrap.dedent("""\ + id: https://example.org/source + name: source + prefixes: + linkml: https://w3id.org/linkml/ + imports: + - linkml:types + default_range: string + + classes: + my record: + attributes: + record_id: + identifier: true + label: {} + source_ref: {} +""") + +TARGET_SCHEMA = textwrap.dedent("""\ + id: https://example.org/target + name: target + prefixes: + linkml: https://w3id.org/linkml/ + imports: + - linkml:types + default_range: string + + classes: + Output: + attributes: + id: {} + name: {} + source: {} +""") + +INPUT_OBJ = { + "record_id": "rec-001", + "label": "Hello World", + "source_ref": "db:12345", +} + + +def _make_transformer(transform_spec: dict) -> ObjectTransformer: + """Build an ObjectTransformer from inline schemas and a transform dict.""" + transform_spec["source_schema"] = "https://example.org/source" + transform_spec["target_schema"] = "https://example.org/target" + tr = ObjectTransformer() + tr.source_schemaview = SchemaView(SOURCE_SCHEMA) + tr.target_schemaview = SchemaView(TARGET_SCHEMA) + tr.create_transformer_specification(transform_spec) + return tr + + +def test_expr_with_space_in_class_name_no_index(): + """expr should resolve source fields when class name has a space (no index).""" + transform = { + "class_derivations": { + "Output": { + "populated_from": "my record", + "slot_derivations": { + "id": {"populated_from": "record_id"}, + "name": {"expr": "label"}, + "source": {"expr": "source_ref"}, + }, + } + }, + } + tr = _make_transformer(transform) + result = tr.map_object(INPUT_OBJ, "my record") + + assert result["id"] == "rec-001" + assert result["name"] == "Hello World" + assert result["source"] == "db:12345" + + +def test_expr_with_space_in_class_name_with_index(): + """expr should resolve source fields when class name has a space (with index).""" + transform = { + "class_derivations": { + "Output": { + "populated_from": "my record", + "slot_derivations": { + "id": {"populated_from": "record_id"}, + "name": {"expr": "label"}, + "source": {"expr": "source_ref"}, + }, + } + }, + } + tr = _make_transformer(transform) + tr.index(INPUT_OBJ, "my record") + result = tr.map_object(INPUT_OBJ, "my record") + + assert result["id"] == "rec-001" + assert result["name"] == "Hello World" + assert result["source"] == "db:12345" + + +def test_expr_string_concat_with_space_in_class_name(): + """expr with string operations should work when class name has a space.""" + transform = { + "class_derivations": { + "Output": { + "populated_from": "my record", + "slot_derivations": { + "id": {"populated_from": "record_id"}, + "name": {"expr": "label + ' (copy)'"}, + "source": {"expr": "source_ref"}, + }, + } + }, + } + tr = _make_transformer(transform) + result = tr.map_object(INPUT_OBJ, "my record") + + assert result["name"] == "Hello World (copy)"