Skip to content

Commit 7609c87

Browse files
dmealingclaude
andauthored
fix(codegen): read inherited attrs via effective accessor across TS/Python/Kotlin + regression gates (#57, #56)
* test(gate): RED tripwires for inherit-without-restate codegen bug (#56) Two failing gates (RED until #56 is fixed) asserting codegen reads INHERITED attrs via the effective accessor, not own-only: - TS (codegen-ts): an identity.primary that inherits @fields via node-level extends without restating it must still yield a primary key. pk-resolver reads primary.ownAttr(@fields) → undefined → the entity is dropped from the PK map → no PK. - Python (codegen): a field that inherits @required via extends (abstract-field reuse) must generate as a REQUIRED Pydantic field, not Optional. entity_model reads field.attr(@required) — which in this port is OWN-ONLY despite the name — so the inherited @required is invisible and `label: str | None = None` is wrongly emitted. Both assert the CORRECT behavior and turn green when the ownAttr→attr fix lands (see #56 for the full cross-port inventory). Intentional RED tripwire PR — do not merge until #56 is fixed; then these become the permanent regression gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(gate): add Kotlin tripwire for inherit-without-restate (#56) Kotlin (codegen-kotlin): a field that inherits @maxlength via extends (abstract-field reuse) without restating it must emit varchar with the inherited length, but KotlinTypeMapper reads @maxlength own-only → falls back to the 255 default. Confirmed RED: emits varchar("label", 255) for an inherited @maxlength=80. Completes the confirmed-port coverage (Python + Kotlin) alongside the latent TS edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(codegen): read inherited attributes via the effective accessor, not own-only (#56) Completes the #56 fix across the three affected ports (the gates in this PR now pass). Builds on #55, which fixed the TS *field*-attr reads. - TS (codegen-ts): identity @fields/@generation/@unique reads switched ownAttr → attr (pk-resolver, drizzle-schema, zod-validators, queries, docs-data-builder). #55 fixed field reads but not identity reads, so a BaseEntity-inherited PK was still dropped. - Python (codegen): ~17 field-attr reads switched field.attr(X) → field.attrs().get(X). Python's attr() is OWN-ONLY despite the name (the footgun); attrs() is the effective map. Covers @required/@maxLength/@objectRef/@default/@values/@isArray/@filterable/ @enumAlias/@enumDoc/@column. (Validator/origin/template reads left own — correct as-is.) - Kotlin (codegen-kotlin): field-attr reads switched getMetaAttr/hasMetaAttr(..., false) → (..., true) for @maxLength/@storage/@objectRef/@filterable (TypeMapper, ExposedTableGenerator, EntityGenerator, RenderHelperGenerator, FilterAllowlist). Node-level reads (identity/relationship via effective iterators) and the validation reference-walk (validate-at-declaration) are deliberately left own-only. Gates green: TS codegen-ts 883 + react/tanstack 69 + whole-workspace typecheck clean; Python 1207; Kotlin codegen-kotlin 35. C# and Java codegen need no change (their attr accessors already default effective / inherit whole nodes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0e8af0c commit 7609c87

23 files changed

Lines changed: 205 additions & 43 deletions

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,8 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
221221

222222
/** Read the `@objectRef` attr off a field (own-only); null if absent. */
223223
private fun readObjectRef(field: MetaField<*>): String? {
224-
if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, false)) return null
225-
return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, false).valueAsString }
224+
if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, true)) return null
225+
return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, true).valueAsString }
226226
.getOrNull()
227227
}
228228

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -486,14 +486,14 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject
486486

487487
/** Read the `@storage` attr (own-only); null when absent. */
488488
private fun readStorage(field: ObjectField): String? {
489-
if (!field.hasMetaAttr(ATTR_STORAGE, false)) return null
490-
return runCatching { field.getMetaAttr(ATTR_STORAGE, false).valueAsString }.getOrNull()
489+
if (!field.hasMetaAttr(ATTR_STORAGE, true)) return null
490+
return runCatching { field.getMetaAttr(ATTR_STORAGE, true).valueAsString }.getOrNull()
491491
}
492492

493493
/** Read the `@objectRef` attr (own-only); null when absent. */
494494
private fun readObjectRef(field: ObjectField): String? {
495-
if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, false)) return null
496-
return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, false).valueAsString }
495+
if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, true)) return null
496+
return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, true).valueAsString }
497497
.getOrNull()
498498
}
499499

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ open class KotlinFilterAllowlistGenerator : MultiFileDirectGeneratorBase<MetaObj
167167

168168
/** True iff `field` carries `@filterable: true` as a metadata attribute. */
169169
private fun isFilterable(field: MetaField<*>): Boolean {
170-
if (!field.hasMetaAttr(ATTR_FILTERABLE, false)) return false
171-
val raw = runCatching { field.getMetaAttr(ATTR_FILTERABLE, false).value }.getOrNull()
170+
if (!field.hasMetaAttr(ATTR_FILTERABLE, true)) return false
171+
val raw = runCatching { field.getMetaAttr(ATTR_FILTERABLE, true).value }.getOrNull()
172172
return when (raw) {
173173
is Boolean -> raw
174174
is String -> raw.equals("true", ignoreCase = true)

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,8 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase<MetaObject
254254
* `object.value` is found.
255255
*/
256256
private fun resolveNestedObjectRef(loader: MetaDataLoader, field: ObjectField): MetaObject? {
257-
if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF, false)) return null
258-
val ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF, false).valueAsString
257+
if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF, true)) return null
258+
val ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF, true).valueAsString
259259
if (ref.isNullOrEmpty()) return null
260260
val refShort = ref.substringAfterLast("::")
261261
for (obj in loader.metaObjects) {

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,9 @@ object KotlinTypeMapper {
412412

413413
/** Resolve @maxLength on a StringField; default 255. */
414414
private fun stringMaxLength(field: StringField): Int {
415-
if (!field.hasMetaAttr(StringField.ATTR_MAX_LENGTH, false)) return 255
415+
if (!field.hasMetaAttr(StringField.ATTR_MAX_LENGTH, true)) return 255
416416
val raw = runCatching {
417-
field.getMetaAttr(StringField.ATTR_MAX_LENGTH, false).value
417+
field.getMetaAttr(StringField.ATTR_MAX_LENGTH, true).value
418418
}.getOrNull()
419419
return when (raw) {
420420
is Number -> raw.toInt()
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.metaobjects.metadata.ktx.loadString
4+
import java.nio.file.Files
5+
import kotlin.test.Test
6+
import kotlin.test.assertTrue
7+
8+
/**
9+
* GATE / TRIPWIRE for issue #56 — Kotlin codegen must read INHERITED field attributes via
10+
* the effective accessor, not own-only. This test asserts the CORRECT behavior and is RED
11+
* until #56 is fixed: a `field.string` that inherits `@maxLength` via `extends`
12+
* (abstract-field reuse) without restating it must emit `varchar("...", <inherited>)`, but
13+
* `KotlinTypeMapper` reads `@maxLength` own-only → falls back to the 255 default. See #56.
14+
*/
15+
class InheritWithoutRestateGateTest {
16+
17+
private val fixture = """{
18+
"metadata.root": { "package": "test", "children": [
19+
{ "object.entity": { "name": "Base", "abstract": true, "children": [
20+
{ "field.string": { "name": "baseLabel", "@maxLength": 80, "@required": true } }
21+
] } },
22+
{ "object.entity": { "name": "Widget", "children": [
23+
{ "field.long": { "name": "id" } },
24+
{ "field.string": { "name": "label", "extends": "test::Base.baseLabel" } },
25+
{ "source.rdb": { "@table": "widgets" } },
26+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
27+
] } }
28+
] }
29+
}""".trimIndent()
30+
31+
@Test
32+
fun `inherited maxLength emits the inherited varchar length, not the 255 default`() {
33+
val outDir = Files.createTempDirectory("ktgate-")
34+
try {
35+
val gen = KotlinExposedTableGenerator()
36+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
37+
gen.execute(loadString("test", fixture))
38+
39+
val src = Files.readString(outDir.resolve("test/WidgetTable.kt"))
40+
// RED until #56 is fixed: inherited @maxLength=80 should win, not the 255 fallback.
41+
assertTrue("varchar(\"label\", 80)" in src,
42+
"expected inherited @maxLength=80 on the label column, got:\n$src")
43+
} finally {
44+
outDir.toFile().deleteRecursively()
45+
}
46+
}
47+
}

server/python/src/metaobjects/codegen/extract_delegate_emitter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def ref_vo(field: MetaData, root: MetaData) -> MetaData | None:
4747
"""The ``@objectRef`` target VO for a nested-object field, or ``None`` when
4848
unresolvable. Matches first on the full ref, then the trailing simple-name
4949
segment (mirrors the runtime ``_resolve_object_ref`` short-name fallback)."""
50-
ref = field.attr(fc.FIELD_ATTR_OBJECT_REF)
50+
ref = field.attrs().get(fc.FIELD_ATTR_OBJECT_REF)
5151
if not isinstance(ref, str) or not ref:
5252
return None
5353
direct = _find_object(root, ref)

server/python/src/metaobjects/codegen/extract_schema_emitter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def _field_spec_literal(field: MetaData, owner: MetaData) -> str:
5353

5454
if field.sub_type == fc.FIELD_SUBTYPE_ENUM:
5555
values_lit = fm.string_list_literal(fm.enum_values(field))
56-
alias_lit = fm.properties_map_literal(field.attr(fc.FIELD_ATTR_ENUM_ALIAS))
56+
alias_lit = fm.properties_map_literal(field.attrs().get(fc.FIELD_ATTR_ENUM_ALIAS))
5757
# FR-011: resolve the three new enum args (field → object.value → "strip" for
5858
# normalize). Keep the back-compat 4-arg form when nothing is set; otherwise
5959
# emit the 7-arg form (..., coerce_default, normalize, default_value).

server/python/src/metaobjects/codegen/fr010_field_mapping.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ def fields(vo: MetaData) -> list[MetaData]:
3434
def is_array(field: MetaData) -> bool:
3535
"""Array-ness from either form: the node property (programmatic build) or the
3636
``@isArray`` attr (how metadata loads from JSON)."""
37-
return bool(field.is_array) or field.attr(KEY_IS_ARRAY) is True
37+
return bool(field.is_array) or field.attrs().get(KEY_IS_ARRAY) is True
3838

3939

4040
def is_required(field: MetaData) -> bool:
4141
"""``@required`` — accepts a bool ``True`` or the string ``"true"``."""
42-
v = field.attr(fc.FIELD_ATTR_REQUIRED)
42+
v = field.attrs().get(fc.FIELD_ATTR_REQUIRED)
4343
if v is True:
4444
return True
4545
return isinstance(v, str) and v.lower() == "true"
@@ -49,15 +49,15 @@ def xml_text(field: MetaData) -> bool:
4949
"""``@xmlText`` — the XML text-content extract marker (accepts a bool ``True`` or the
5050
string ``"true"``). When set, codegen bakes a ``FieldSpec.text_content_field(...)``.
5151
Mirrors the TS ``xmlText(field)`` helper."""
52-
v = field.attr(TEMPLATE_ATTR_XML_TEXT)
52+
v = field.attrs().get(TEMPLATE_ATTR_XML_TEXT)
5353
if v is True:
5454
return True
5555
return isinstance(v, str) and v.lower() == "true"
5656

5757

5858
def enum_values(field: MetaData) -> list[str]:
5959
"""The string members of an enum field's ``@values`` attr (empty when absent)."""
60-
v = field.attr(fc.FIELD_ATTR_VALUES)
60+
v = field.attrs().get(fc.FIELD_ATTR_VALUES)
6161
if isinstance(v, (list, tuple)):
6262
return [str(x) for x in v]
6363
return []

server/python/src/metaobjects/codegen/generators/entity_model.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def _validator_constraints(field: MetaField) -> dict[str, object]:
5757
# String length: validator.length @min/@max + field @maxLength (max wins per field attr).
5858
min_len = _first_attr(field, vc.VALIDATOR_SUBTYPE_LENGTH, vc.VALIDATOR_ATTR_MIN)
5959
max_len = _first_attr(field, vc.VALIDATOR_SUBTYPE_LENGTH, vc.VALIDATOR_ATTR_MAX)
60-
field_max = field.attr(fc.FIELD_ATTR_MAX_LENGTH)
60+
field_max = field.attrs().get(fc.FIELD_ATTR_MAX_LENGTH)
6161
if _is_int(field_max):
6262
max_len = field_max
6363
if min_len is not None:
@@ -110,14 +110,14 @@ def _field_line(field: MetaField, imports: set[str], config: GenConfig) -> tuple
110110
imports.update(pt.imports)
111111
type_expr = pt.expr
112112
if field.sub_type == fc.FIELD_SUBTYPE_OBJECT:
113-
ref = field.attr(fc.FIELD_ATTR_OBJECT_REF)
113+
ref = field.attrs().get(fc.FIELD_ATTR_OBJECT_REF)
114114
if ref:
115115
# @objectRef is FQN-expanded at load time; the generated VOs live
116116
# flat in one package, so import by the bare class name.
117117
ref_name = str(ref).split("::")[-1]
118118
imports.add(f"from .{ref_name} import {ref_name}")
119-
required = field.attr(fc.FIELD_ATTR_REQUIRED) is True
120-
default_raw = field.attr(fc.FIELD_ATTR_DEFAULT)
119+
required = field.attrs().get(fc.FIELD_ATTR_REQUIRED) is True
120+
default_raw = field.attrs().get(fc.FIELD_ATTR_DEFAULT)
121121
has_default = default_raw is not None
122122
enum_type_name = type_name if shared is not None else None
123123

0 commit comments

Comments
 (0)