From 08769e7507967a4f1bed2c73dba8aa39b7f8cd7d Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 19:22:15 +0800 Subject: [PATCH 1/7] feat(orm): add Model.Copy and field copy metadata - Add Model.Copy / instance.copy that Browse, build copy values, then Create. - Introduce @Field({ copy }) with BaseModel system fields marked copy: false. - Relink ManyToMany by id arrays and shallow-copy OneToMany via create commands. - Pipe copy through FieldsGet and Go IrField / parser / webapistore. Co-authored-by: Cursor --- .../module/artifact/generate/webapistore.go | 5 + .../artifact/generate/webapistore.ts.tpl | 1 + .../artifact/generate/webapistore_test.go | 34 +++ .../parser/backendtsparser/field_resolved.go | 3 + .../backendtsparser/parser_migration_test.go | 45 +++ .../core/service/orm/decorator/field.test.ts | 18 ++ modules/core/service/orm/decorator/field.ts | 8 + modules/core/service/orm/metadata/field.ts | 10 + modules/core/service/orm/model/model.ts | 29 +- .../core/service/orm/model/model_copy.test.ts | 271 ++++++++++++++++++ modules/core/service/orm/model/model_copy.ts | 252 ++++++++++++++++ .../orm/model/model_fields_get_facade.ts | 5 + pkg/meta/meta_field.go | 2 + 13 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 modules/core/service/orm/model/model_copy.test.ts create mode 100644 modules/core/service/orm/model/model_copy.ts diff --git a/internal/module/artifact/generate/webapistore.go b/internal/module/artifact/generate/webapistore.go index 088938789..25febe559 100644 --- a/internal/module/artifact/generate/webapistore.go +++ b/internal/module/artifact/generate/webapistore.go @@ -53,6 +53,7 @@ type FieldMetadata struct { IsReadonly *bool `json:"isReadonly,omitempty"` Indexed *bool `json:"indexed,omitempty"` Translate *bool `json:"translate,omitempty"` + Copy *bool `json:"copy,omitempty"` RelationInverseField *string `json:"relationInverseField,omitempty"` RelationJoinModel *string `json:"relationJoinModel,omitempty"` @@ -130,6 +131,10 @@ func applyResolvedFieldContract(metadata *FieldMetadata, field *meta.IrField) { metadata.Size = &size } } + if resolved.Structural.Copy != nil && !*resolved.Structural.Copy { + f := false + metadata.Copy = &f + } } type webApiStoreGenerator struct { diff --git a/internal/module/artifact/generate/webapistore.ts.tpl b/internal/module/artifact/generate/webapistore.ts.tpl index 31b066216..66b6081ab 100644 --- a/internal/module/artifact/generate/webapistore.ts.tpl +++ b/internal/module/artifact/generate/webapistore.ts.tpl @@ -48,6 +48,7 @@ export const {{.Model.Name}}FieldsMetadata = { {{- if $field.IsReadonly}}isReadonly: {{$field.IsReadonly}},{{- end}} {{- if $field.Indexed}}indexed: {{$field.Indexed}},{{- end}} {{- if $field.Translate}}translate: {{$field.Translate}},{{- end}} + {{- if $field.Copy}}copy: {{$field.Copy}},{{- end}} {{- if $field.RelationInverseField}}relationInverseField: '{{$field.RelationInverseField}}',{{- end}} {{- if $field.RelationJoinModel}}relationJoinModel: '{{$field.RelationJoinModel}}',{{- end}} {{- if $field.RelationJoinField}}relationJoinField: '{{$field.RelationJoinField}}',{{- end}} diff --git a/internal/module/artifact/generate/webapistore_test.go b/internal/module/artifact/generate/webapistore_test.go index 1a179c525..9e0714a32 100644 --- a/internal/module/artifact/generate/webapistore_test.go +++ b/internal/module/artifact/generate/webapistore_test.go @@ -363,3 +363,37 @@ func TestConvertFieldToMetadata_TranslateContract(t *testing.T) { t.Fatalf("existing Size must win over zero storage hint, got %#v", metadata3.Size) } } + +func TestConvertFieldToMetadata_CopyContract(t *testing.T) { + falseVal := false + field := &meta.IrField{Name: "Code", FieldType: "varchar"} + spec := &meta.IrFieldResolvedSpec{ + FieldName: "Code", + Structural: meta.IrFieldStructuralSpec{ + Copy: &falseVal, + }, + } + if err := field.SetResolvedSpec(spec); err != nil { + t.Fatalf("SetResolvedSpec: %v", err) + } + metadata := convertFieldToMetadata(field) + if metadata.Copy == nil || *metadata.Copy { + t.Fatalf("expected Copy=false, got %#v", metadata.Copy) + } + + trueVal := true + field2 := &meta.IrField{Name: "Name", FieldType: "varchar"} + spec2 := &meta.IrFieldResolvedSpec{ + FieldName: "Name", + Structural: meta.IrFieldStructuralSpec{ + Copy: &trueVal, + }, + } + if err := field2.SetResolvedSpec(spec2); err != nil { + t.Fatalf("SetResolvedSpec true: %v", err) + } + metadata2 := convertFieldToMetadata(field2) + if metadata2.Copy != nil { + t.Fatalf("copy:true must omit Copy flag, got %#v", metadata2.Copy) + } +} diff --git a/internal/parser/backendtsparser/field_resolved.go b/internal/parser/backendtsparser/field_resolved.go index c2b08bfad..05d606025 100644 --- a/internal/parser/backendtsparser/field_resolved.go +++ b/internal/parser/backendtsparser/field_resolved.go @@ -493,6 +493,9 @@ func buildFieldResolvedSpec(field *meta.IrField, binding *resolvedFieldBehaviorB translate = true spec.Structural.Translate = toBoolPtr(true) } + if v, ok := options["copy"].(bool); ok && !v { + spec.Structural.Copy = toBoolPtr(false) + } switch raw := options["default"].(type) { case string: trimmed := strings.TrimSpace(raw) diff --git a/internal/parser/backendtsparser/parser_migration_test.go b/internal/parser/backendtsparser/parser_migration_test.go index c118ebe2b..79d2acbb0 100644 --- a/internal/parser/backendtsparser/parser_migration_test.go +++ b/internal/parser/backendtsparser/parser_migration_test.go @@ -2015,3 +2015,48 @@ func TestParseDecoratorStringArg_EdgeCases(t *testing.T) { t.Fatalf("expected 'single', got %q", got) } } + +func TestTsParser_CopyFalseField(t *testing.T) { + runtimeScope := newBackendParserTestScope() + module := &meta.IrModule{Path: "/virtual/modules/test", ApplicationStr: "test"} + p := NewTsParser(runtimeScope, module) + + path := "/virtual/modules/test/service/copy_field.ts" + content := `import { Model, Field } from '../../core/service'; +import BaseModel from './base'; + +@Model('CopyPilot') +export default class CopyPilot extends BaseModel { + @Field({ type: 'varchar', size: 64, copy: false }) + public Code: string + + @Field({ type: 'varchar', size: 64 }) + public Name: string +} +` + + r, err := p.Parse(map[string]string{}, path, content) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + fieldByName := map[string]*meta.IrField{} + for _, f := range r.Model.Fields { + fieldByName[f.Name] = f + } + + codeSpec, err := fieldByName["Code"].GetResolvedSpec() + if err != nil || codeSpec == nil { + t.Fatalf("Code resolved spec: err=%v", err) + } + if codeSpec.Structural.Copy == nil || *codeSpec.Structural.Copy { + t.Fatalf("expected Copy=false on Code, got %#v", codeSpec.Structural.Copy) + } + + nameSpec, err := fieldByName["Name"].GetResolvedSpec() + if err != nil || nameSpec == nil { + t.Fatalf("Name resolved spec: err=%v", err) + } + if nameSpec.Structural.Copy != nil { + t.Fatalf("omitted copy must leave Structural.Copy nil, got %#v", nameSpec.Structural.Copy) + } +} diff --git a/modules/core/service/orm/decorator/field.test.ts b/modules/core/service/orm/decorator/field.test.ts index 57b3530e6..6bd1cd92e 100644 --- a/modules/core/service/orm/decorator/field.test.ts +++ b/modules/core/service/orm/decorator/field.test.ts @@ -729,3 +729,21 @@ test('Field decorator rejects translate on non-text field types', () => { return BadTypeTranslate; }).toThrow('translate is only supported on char/varchar/text fields'); }); + +test('Field decorator accepts copy:false and rejects non-boolean copy', () => { + class CopyFlagModel extends BaseModel { + @Field({ type: 'varchar', size: 32, copy: false } as any) + Code!: string; + } + const meta = MetadataStorage.instance.getModelMetadata(CopyFlagModel as any).fields.get('Code') as any; + expect(meta.copy).toBe(false); + + expect(() => { + class BadCopy extends BaseModel { + @Field({ type: 'varchar', copy: 'no' as any } as any) + Name!: string; + } + return BadCopy; + }).toThrow('copy must be a boolean'); +}); + diff --git a/modules/core/service/orm/decorator/field.ts b/modules/core/service/orm/decorator/field.ts index 891737082..432501094 100644 --- a/modules/core/service/orm/decorator/field.ts +++ b/modules/core/service/orm/decorator/field.ts @@ -56,6 +56,8 @@ type FieldDecoratorOptionBag = { round?: unknown; /** Data i18n: store per-language values as a JSON/JSONB lang map. */ translate?: unknown; + /** Whether the field participates in Model.Copy (default true when omitted). */ + copy?: unknown; }; function normalizeFieldString(name: string, value: unknown): { string?: string; stringText?: TermReference } { @@ -117,6 +119,10 @@ export function Field & typeof BaseModel; diff --git a/modules/core/service/orm/metadata/field.ts b/modules/core/service/orm/metadata/field.ts index 7fbf53b7d..8e0a59aba 100644 --- a/modules/core/service/orm/metadata/field.ts +++ b/modules/core/service/orm/metadata/field.ts @@ -101,6 +101,11 @@ type FlatCommonOptions = { * See `.dev/docs/infra/i18n/data-i18n-design.md`. */ translate?: boolean; + /** + * Whether the field participates in Model.Copy payloads. + * Omit / true = include; false = skip. Default is true when omitted. + */ + copy?: boolean; }; type FlatNoRelationOption = { relation?: never }; @@ -460,6 +465,11 @@ export interface FieldMetadata { * `size` remains a per-lang value limit in storageHints; it is not a varchar column width. */ translate?: boolean; + /** + * Whether the field participates in Model.Copy payloads. + * Omit / true = include; false = skip. + */ + copy?: boolean; } // Decrement tuple (limits max recursion depth to avoid excessive type expansion) diff --git a/modules/core/service/orm/model/model.ts b/modules/core/service/orm/model/model.ts index 1a38e2d9c..60fe823e4 100644 --- a/modules/core/service/orm/model/model.ts +++ b/modules/core/service/orm/model/model.ts @@ -66,6 +66,7 @@ import { getModelRepository } from './model_internal_facade'; import { defaultModelValues, runModelOnchange } from './model_runtime_service_facade'; import { deleteModels, deleteModelById } from './model_delete_service_facade'; import { createModel, createManyModels } from './model_create_service_facade'; +import { copyModel } from './model_copy'; import { updateModels, updateModelById } from './model_update_service_facade'; import { fieldsGetModels, type FieldsGetFieldMeta } from './model_fields_get_facade'; import { @@ -366,7 +367,7 @@ class BaseModel { /** * Primary key for the model instance. */ - @Field({ type: 'char', size: 20, primaryKey: true }) + @Field({ type: 'char', size: 20, primaryKey: true, copy: false }) public readonly Id: string; /** @@ -397,6 +398,7 @@ class BaseModel { @Field({ type: 'datetime', index: true, + copy: false, string: _lt('Created At', { scope: 'core.model.BaseModel.fields' }), }) public readonly CreatedAt: Date; @@ -407,6 +409,7 @@ class BaseModel { @Field({ type: 'datetime', index: true, + copy: false, string: _lt('Updated At', { scope: 'core.model.BaseModel.fields' }), }) public UpdatedAt: Date; @@ -417,6 +420,7 @@ class BaseModel { @Field({ type: 'datetime', index: true, + copy: false, string: _lt('Deleted At', { scope: 'core.model.BaseModel.fields' }), }) public DeletedAt: Date; @@ -450,6 +454,17 @@ class BaseModel { return getModelRepository(this as unknown as RuntimeModelCtor); } + /** + * Duplicates this instance via Model.Copy(this.Id, defaults). + */ + async copy(defaults?: Partial>): Promise { + const id = String((this as { Id?: string }).Id || '').trim(); + if (!id) { + throw new Error('Cannot copy an instance without Id'); + } + return (await copyModel(this.constructor as unknown as RuntimeModelCtor, id, defaults)) as this; + } + /** * Updates this model instance in place and returns the refreshed instance. */ @@ -523,6 +538,18 @@ class BaseModel { return await updateModelFieldTranslations(this as unknown as RuntimeModelCtor, id, fieldName, translations); } + /** + * Duplicates one record by Id using field `copy` metadata, then Create. + * Soft-deleted sources and relation rows follow default Browse visibility. + */ + static async Copy( + this: BaseModelCtor, + id: string, + defaults?: Partial> + ): Promise { + return await copyModel(this as unknown as RuntimeModelCtor, id, defaults); + } + /** * Creates one record and optionally returns a selected field projection. */ diff --git a/modules/core/service/orm/model/model_copy.test.ts b/modules/core/service/orm/model/model_copy.test.ts new file mode 100644 index 000000000..aa10bc19a --- /dev/null +++ b/modules/core/service/orm/model/model_copy.test.ts @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { Field } from '../decorator/field'; +import { Model } from '../decorator/model'; +import { MetadataStorage } from '../metadata/storage'; +import { RepositoryFactory } from '../repository/repository_factory'; +import BaseModel from './model'; +import { + buildCopyBrowseSelection, + buildCopyValues, + copyModel, + shouldSkipFieldForCopy, +} from './model_copy'; +import { CreateOperations } from './model_create'; +import { ReadOperations } from './model_read'; +import { getModelRuntimeMetadata } from './model_runtime_service_facade'; + +@Model('CopyScalarWidget', { application: 'demo' }) +class CopyScalarWidget extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + Name!: string; + + @Field({ type: 'varchar', size: 64, unique: true }) + Code!: string; + + @Field({ type: 'varchar', size: 64, copy: false }) + Secret!: string; +} + +@Model('CopyLineWidget', { application: 'demo' }) +class CopyLineWidget extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + Name!: string; + + @Field({ + type: 'ManyToOne', + relation: { targetModel: () => CopyOrderWidget }, + } as any) + OrderId!: string; +} + +@Model('CopyTagWidget', { application: 'demo' }) +class CopyTagWidget extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + Name!: string; +} + +@Model('CopyOrderWidget', { application: 'demo' }) +class CopyOrderWidget extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + Name!: string; + + @Field({ + type: 'OneToMany', + relation: { targetModel: () => CopyLineWidget, inverseField: 'OrderId' }, + } as any) + Lines!: CopyLineWidget[]; + + @Field({ + type: 'ManyToMany', + relation: { + targetModel: () => CopyTagWidget, + joinModel: () => CopyOrderTagJoin, + joinField: 'OrderId', + inverseJoinField: 'TagId', + }, + } as any) + Tags!: CopyTagWidget[]; +} + +@Model('CopyOrderTagJoin', { application: 'demo' }) +class CopyOrderTagJoin extends BaseModel { + @Field({ + type: 'ManyToOne', + relation: { targetModel: () => CopyOrderWidget }, + } as any) + OrderId!: string; + + @Field({ + type: 'ManyToOne', + relation: { targetModel: () => CopyTagWidget }, + } as any) + TagId!: string; +} + +test('BaseModel system fields are copy:false', () => { + const meta = MetadataStorage.instance.getModelMetadata(CopyScalarWidget as any); + expect(meta.fields.get('Id')?.copy).toBe(false); + expect(meta.fields.get('CreatedAt')?.copy).toBe(false); + expect(meta.fields.get('UpdatedAt')?.copy).toBe(false); + expect(meta.fields.get('DeletedAt')?.copy).toBe(false); +}); + +test('Field decorator persists copy:false and FieldsGet exposes it', async () => { + const meta = MetadataStorage.instance.getModelMetadata(CopyScalarWidget as any); + expect(meta.fields.get('Secret')?.copy).toBe(false); + expect(meta.fields.get('Name')?.copy).toBeUndefined(); + + RepositoryFactory.setRepository(CopyScalarWidget as any, { + getDenyReadFields: async () => ({ denyReadFields: [] }), + getDenyWriteFields: async () => ({ denyWriteFields: [] }), + } as any); + + const out = await CopyScalarWidget.FieldsGet(['Name', 'Secret'], ['type', 'copy']); + expect(out.Secret).toEqual({ type: 'varchar', copy: false }); + expect(out.Name?.copy).toBeUndefined(); +}); + +test('shouldSkipFieldForCopy respects copy flag and primary key', () => { + const meta = getModelRuntimeMetadata(CopyScalarWidget as any); + expect(shouldSkipFieldForCopy(meta, 'Id', meta.fields.get('Id')!)).toBe(true); + expect(shouldSkipFieldForCopy(meta, 'Secret', meta.fields.get('Secret')!)).toBe(true); + expect(shouldSkipFieldForCopy(meta, 'Name', meta.fields.get('Name')!)).toBe(false); + expect(shouldSkipFieldForCopy(meta, 'Code', meta.fields.get('Code')!)).toBe(false); +}); + +test('buildCopyValues copies scalars, skips copy:false, keeps unique fields (U1)', () => { + const values = buildCopyValues(CopyScalarWidget as any, { + Id: 'src-1', + Name: 'Alpha', + Code: 'U-1', + Secret: 'nope', + CreatedAt: new Date('2020-01-01'), + }); + + expect(values).toEqual({ Name: 'Alpha', Code: 'U-1' }); + expect(values.Id).toBeUndefined(); + expect(values.Secret).toBeUndefined(); + expect(values.CreatedAt).toBeUndefined(); +}); + +test('buildCopyValues merges defaults over copied values', () => { + const values = buildCopyValues( + CopyScalarWidget as any, + { Id: 'src-1', Name: 'Alpha', Code: 'U-1' }, + { Name: 'Beta', CompanyId: 'c-9' } + ); + expect(values).toEqual({ Name: 'Beta', Code: 'U-1', CompanyId: 'c-9' }); +}); + +test('buildCopyValues rewrites O2M to create commands and M2M to id arrays', () => { + const values = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-1', + Name: 'Order', + Lines: [ + { Id: 'line-1', Name: 'L1', OrderId: 'ord-1' }, + { Id: 'line-2', Name: 'L2', OrderId: 'ord-1' }, + ], + Tags: [{ Id: 'tag-1', Name: 'T1' }, 'tag-2', { Id: 'tag-1' }], + }); + + expect(values.Name).toBe('Order'); + expect(values.Lines).toEqual({ + create: [{ Name: 'L1' }, { Name: 'L2' }], + }); + expect(values.Tags).toEqual(['tag-1', 'tag-2']); +}); + +test('buildCopyBrowseSelection includes relations for copyable fields', () => { + const meta = getModelRuntimeMetadata(CopyOrderWidget as any); + const selection = buildCopyBrowseSelection(meta) as unknown[]; + expect(selection[0]).toBe('*'); + expect(selection.some(item => item && typeof item === 'object' && 'Lines' in (item as object))).toBe(true); + expect(selection.some(item => item && typeof item === 'object' && 'Tags' in (item as object))).toBe(true); +}); + +test('copyModel Browses then Creates with built values', async () => { + const originalBrowse = ReadOperations.Browse; + const originalCreate = CreateOperations.Create; + let browsedFields: unknown; + let createdValue: unknown; + + ReadOperations.Browse = (async (_ModelCtor: any, id: string, fields?: unknown) => { + browsedFields = fields; + expect(id).toBe('src-9'); + return { + Id: 'src-9', + Name: 'Source', + Code: 'C-9', + Secret: 'hidden', + }; + }) as any; + + CreateOperations.Create = (async (_ModelCtor: any, value: any) => { + createdValue = value; + return { Id: 'new-9', Name: value.Name, Code: value.Code } as any; + }) as any; + + try { + const neo = await copyModel(CopyScalarWidget as any, 'src-9', { Name: 'Copied' }); + expect(browsedFields).toBeTruthy(); + expect(createdValue).toEqual({ Name: 'Copied', Code: 'C-9' }); + expect((neo as any).Id).toBe('new-9'); + } finally { + ReadOperations.Browse = originalBrowse; + CreateOperations.Create = originalCreate; + } +}); + +test('Model.Copy and instance.copy delegate to copyModel', async () => { + const originalBrowse = ReadOperations.Browse; + const originalCreate = CreateOperations.Create; + + ReadOperations.Browse = (async () => ({ Id: 'src-2', Name: 'N', Code: 'C' })) as any; + CreateOperations.Create = (async (_m: any, value: any) => ({ Id: 'new-2', ...value })) as any; + + try { + const staticNeo = await CopyScalarWidget.Copy('src-2'); + expect((staticNeo as any).Id).toBe('new-2'); + + const token = (BaseModel as any).FACTORY_TOKEN as symbol; + const instance = new CopyScalarWidget(token, { Id: 'src-2', Name: 'N', Code: 'C' } as any); + const instNeo = await instance.copy({ Name: 'FromInstance' }); + expect((instNeo as any).Name).toBe('FromInstance'); + } finally { + ReadOperations.Browse = originalBrowse; + CreateOperations.Create = originalCreate; + } +}); + +test('copyModel rejects empty id and instance.copy rejects missing Id', async () => { + let emptyIdErr = ''; + try { + await copyModel(CopyScalarWidget as any, ' '); + } catch (error) { + emptyIdErr = String((error as Error).message || error); + } + expect(emptyIdErr).toContain('id is required'); + + const token = (BaseModel as any).FACTORY_TOKEN as symbol; + const instance = new CopyScalarWidget(token, { Name: 'no-id' } as any); + let missingIdErr = ''; + try { + await instance.copy(); + } catch (error) { + missingIdErr = String((error as Error).message || error); + } + expect(missingIdErr).toContain('Cannot copy an instance without Id'); +}); + +test('copyModel propagates Browse NotFound for soft-deleted or missing source', async () => { + const originalBrowse = ReadOperations.Browse; + ReadOperations.Browse = (async () => { + throw new Error('CopyOrderWidget not found'); + }) as any; + + try { + let message = ''; + try { + await CopyOrderWidget.Copy('gone'); + } catch (error) { + message = String((error as Error).message || error); + } + expect(message).toContain('not found'); + } finally { + ReadOperations.Browse = originalBrowse; + } +}); + +test('buildCopyValues skips soft-deleted-shaped missing relation rows already filtered by Browse', () => { + // Browse already excludes soft-deleted O2M/M2M; empty / absent collections stay empty. + const values = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-1', + Name: 'Order', + Lines: [], + Tags: [], + }); + expect(values.Lines).toBeUndefined(); + expect(values.Tags).toBeUndefined(); +}); diff --git a/modules/core/service/orm/model/model_copy.ts b/modules/core/service/orm/model/model_copy.ts new file mode 100644 index 000000000..b10a66165 --- /dev/null +++ b/modules/core/service/orm/model/model_copy.ts @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import type { FieldMetadata, ModelMetadata } from '../metadata'; +import { MetadataStorage } from '../metadata/storage'; +import { resolveOneToManyRelationConfig } from '../relation/types'; +import type { FieldSelection, Insertable } from '../repository/types'; +import { asObjectRecord } from '../../../utils/object'; +import type { ObjectRecord, UnknownRecord } from '../../../utils/types'; +import { createModel } from './model_create_service_facade'; +import { getModelRuntimeMetadata } from './model_runtime_service_facade'; +import { ReadOperations } from './model_read'; +import type BaseModel from './model'; +import type { RuntimeModelCtor } from './types'; + +/** Max nested OneToMany depth for Copy (design §6.1). */ +export const COPY_MAX_RELATION_DEPTH = 8; + +type CopyWalkState = { + ancestorIds: Set; + depth: number; +}; + +function isRelationType(type: unknown): boolean { + return type === 'ManyToOne' || type === 'OneToMany' || type === 'ManyToMany'; +} + +function isAttachmentType(type: unknown): boolean { + const normalized = String(type || '') + .trim() + .toLowerCase(); + return normalized === 'binary' || normalized === 'image'; +} + +function extractRelationId(value: unknown): string | null { + if (value == null) return null; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || null; + } + if (typeof value === 'number' || typeof value === 'bigint') { + return String(value); + } + const record = asObjectRecord(value); + if (!record) return null; + const id = record.Id ?? record.id; + if (typeof id === 'string' && id.trim()) return id.trim(); + if (typeof id === 'number' || typeof id === 'bigint') return String(id); + return null; +} + +/** + * Whether a field should be omitted from Copy payloads (D4/D5/D7). + * `copy === false`, primary keys, SqlCompute, non-stored compute/related are skipped. + */ +export function shouldSkipFieldForCopy(meta: ModelMetadata, fieldName: string, field: FieldMetadata): boolean { + if (field.copy === false) return true; + if (field.column?.primaryKey === true) return true; + if (meta.sqlComputeHandlers?.has(fieldName)) return true; + + const compute = meta.computeHandlers?.get(fieldName); + if (compute?.store === false) return true; + + if (field.related && field.related.store !== true) return true; + + return false; +} + +function getTargetModelMetadata(field: FieldMetadata): ModelMetadata | undefined { + const relation = asObjectRecord(field.relation); + const targetModel = relation?.targetModel; + if (typeof targetModel !== 'function') return undefined; + try { + return MetadataStorage.instance.getModelMetadata(targetModel() as RuntimeModelCtor); + } catch { + return undefined; + } +} + +/** + * Build Browse field selection covering scalars, attachments, and copyable relations. + */ +export function buildCopyBrowseSelection(meta: ModelMetadata, depth = 0): FieldSelection { + const selection: unknown[] = ['*']; + + for (const [fieldName, field] of meta.fields || []) { + if (shouldSkipFieldForCopy(meta, fieldName, field)) continue; + + if (isAttachmentType(field.type)) { + selection.push(fieldName); + continue; + } + + if (field.type === 'ManyToOne') { + // FK column + minimal nested Id for object-shaped reads. + selection.push(fieldName); + selection.push({ [fieldName]: ['Id'] }); + continue; + } + + if (field.type === 'ManyToMany') { + selection.push({ [fieldName]: ['Id'] }); + continue; + } + + if (field.type === 'OneToMany') { + if (depth >= COPY_MAX_RELATION_DEPTH) continue; + const childMeta = getTargetModelMetadata(field); + if (!childMeta) continue; + selection.push({ [fieldName]: buildCopyBrowseSelection(childMeta, depth + 1) }); + } + } + + return selection as FieldSelection; +} + +function copyScalarOrManyToOneValue(value: unknown): unknown { + if (value === undefined) return undefined; + if (value === null) return null; + const id = extractRelationId(value); + // Prefer Id when Browse nested a ManyToOne object; otherwise keep scalar as-is. + if (id != null && typeof value === 'object') return id; + return value; +} + +function copyManyToManyIds(value: unknown): string[] | undefined { + if (value == null) return undefined; + if (!Array.isArray(value)) { + const single = extractRelationId(value); + return single ? [single] : undefined; + } + const ids: string[] = []; + const seen = new Set(); + for (const item of value) { + const id = extractRelationId(item); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + +/** + * Transform a Browse row into a Create payload for Copy (design §5–§6). + */ +export function buildCopyValues( + ModelCtor: RuntimeModelCtor, + row: ObjectRecord, + defaults?: Partial>, + state?: CopyWalkState +): UnknownRecord { + const meta = getModelRuntimeMetadata(ModelCtor); + const walk: CopyWalkState = state || { + ancestorIds: new Set(), + depth: 0, + }; + + const sourceId = extractRelationId(row.Id ?? row.id); + if (sourceId) walk.ancestorIds.add(sourceId); + + const out: UnknownRecord = {}; + + for (const [fieldName, field] of meta.fields || []) { + if (shouldSkipFieldForCopy(meta, fieldName, field)) continue; + if (!(fieldName in row)) continue; + + const raw = row[fieldName]; + if (raw === undefined) continue; + + if (field.type === 'OneToMany') { + if (walk.depth >= COPY_MAX_RELATION_DEPTH) { + throw new Error(`[Copy] OneToMany depth exceeded (${COPY_MAX_RELATION_DEPTH}) at ${meta.fullModelName || meta.modelName || ModelCtor.name}.${fieldName}`); + } + if (!Array.isArray(raw) || raw.length === 0) continue; + + const o2m = resolveOneToManyRelationConfig(field.relation); + if (!o2m) continue; + const childCtor = o2m.targetModel() as RuntimeModelCtor; + const childValues: UnknownRecord[] = []; + + for (const child of raw) { + const childRow = asObjectRecord(child); + if (!childRow) continue; + const childId = extractRelationId(childRow.Id ?? childRow.id); + if (childId && walk.ancestorIds.has(childId)) { + throw new Error(`[Copy] cyclic OneToMany detected at ${fieldName} (source Id ${childId})`); + } + + const childCopied = buildCopyValues(childCtor, childRow, undefined, { + ancestorIds: new Set(walk.ancestorIds), + depth: walk.depth + 1, + }); + // Parent Create binds the new parent Id; drop inverse FK from the child payload. + delete childCopied[o2m.inverseField]; + childValues.push(childCopied); + } + + if (childValues.length) { + out[fieldName] = { create: childValues }; + } + continue; + } + + if (field.type === 'ManyToMany') { + const ids = copyManyToManyIds(raw); + if (ids && ids.length) out[fieldName] = ids; + continue; + } + + if (field.type === 'ManyToOne') { + const v = copyScalarOrManyToOneValue(raw); + if (v !== undefined) out[fieldName] = v; + continue; + } + + // Scalars, Refs, attachment binding ids, etc. + out[fieldName] = raw; + } + + if (defaults && typeof defaults === 'object') { + for (const [key, value] of Object.entries(defaults)) { + if (value === undefined) continue; + out[key] = value; + } + } + + return out; +} + +/** + * Duplicate one record via Browse → buildCopyValues → Create (design D2). + */ +export async function copyModel( + ModelCtor: RuntimeModelCtor, + id: string, + defaults?: Partial> +): Promise { + const trimmedId = String(id || '').trim(); + if (!trimmedId) { + throw new Error('[Copy] id is required'); + } + + const meta = getModelRuntimeMetadata(ModelCtor); + const fields = buildCopyBrowseSelection(meta); + const row = (await ReadOperations.Browse(ModelCtor, trimmedId, fields as FieldSelection)) as ObjectRecord; + const values = buildCopyValues(ModelCtor as RuntimeModelCtor, row, defaults, { + ancestorIds: new Set([trimmedId]), + depth: 0, + }); + + return (await createModel(ModelCtor, values as Partial>)) as T; +} diff --git a/modules/core/service/orm/model/model_fields_get_facade.ts b/modules/core/service/orm/model/model_fields_get_facade.ts index 1927d389f..da87ff884 100644 --- a/modules/core/service/orm/model/model_fields_get_facade.ts +++ b/modules/core/service/orm/model/model_fields_get_facade.ts @@ -21,6 +21,8 @@ export type FieldsGetFieldMeta = { scale?: number; isReadonly?: boolean; indexed?: boolean; + /** false when @Field({ copy: false }); omitted means default true. */ + copy?: boolean; [key: string]: unknown; }; @@ -198,6 +200,9 @@ function buildFieldMeta( if (field.translate === true) { meta.translate = true; } + if (field.copy === false) { + meta.copy = false; + } const hintSize = field.storageHints?.size; if (typeof hintSize === 'number' && Number.isInteger(hintSize) && hintSize > 0 && meta.size == null) { meta.size = hintSize; diff --git a/pkg/meta/meta_field.go b/pkg/meta/meta_field.go index 3979cc1c4..126ae6fdd 100644 --- a/pkg/meta/meta_field.go +++ b/pkg/meta/meta_field.go @@ -87,6 +87,8 @@ type IrFieldStructuralSpec struct { CheckConstraint string `json:"checkConstraint,omitempty"` // Translate marks data-i18n fields stored as JSON/JSONB lang maps (see data-i18n-design.md). Translate *bool `json:"translate,omitempty"` + // Copy is false when @Field({ copy: false }); omit/true means the field participates in Model.Copy. + Copy *bool `json:"copy,omitempty"` } type IrFieldBehaviorComputeSpec struct { From 1de33d8f831442da2ddc6275b6ed8d90fa274511 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 19:34:57 +0800 Subject: [PATCH 2/7] fix(orm): address PR 187 review nits for Copy - Remove unused isRelationType and dead undefined guard in copy helpers. - Simplify instance copy Id access and emit copy:false via non-nil template check. Co-authored-by: Cursor --- .../artifact/generate/webapistore.ts.tpl | 2 +- .../artifact/generate/webapistore_test.go | 35 +++++++++++++++---- modules/core/service/orm/model/model.ts | 2 +- modules/core/service/orm/model/model_copy.ts | 5 --- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/internal/module/artifact/generate/webapistore.ts.tpl b/internal/module/artifact/generate/webapistore.ts.tpl index 66b6081ab..c539099dc 100644 --- a/internal/module/artifact/generate/webapistore.ts.tpl +++ b/internal/module/artifact/generate/webapistore.ts.tpl @@ -48,7 +48,7 @@ export const {{.Model.Name}}FieldsMetadata = { {{- if $field.IsReadonly}}isReadonly: {{$field.IsReadonly}},{{- end}} {{- if $field.Indexed}}indexed: {{$field.Indexed}},{{- end}} {{- if $field.Translate}}translate: {{$field.Translate}},{{- end}} - {{- if $field.Copy}}copy: {{$field.Copy}},{{- end}} + {{- if ne $field.Copy nil}}copy: false,{{- end}} {{- if $field.RelationInverseField}}relationInverseField: '{{$field.RelationInverseField}}',{{- end}} {{- if $field.RelationJoinModel}}relationJoinModel: '{{$field.RelationJoinModel}}',{{- end}} {{- if $field.RelationJoinField}}relationJoinField: '{{$field.RelationJoinField}}',{{- end}} diff --git a/internal/module/artifact/generate/webapistore_test.go b/internal/module/artifact/generate/webapistore_test.go index 9e0714a32..b517507f7 100644 --- a/internal/module/artifact/generate/webapistore_test.go +++ b/internal/module/artifact/generate/webapistore_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "text/template" "github.com/choysum-dev/choysum/internal/module/artifact/staging" "github.com/choysum-dev/choysum/pkg/meta" @@ -162,13 +163,13 @@ func TestWebApiStoreGenerate(t *testing.T) { func TestWebApiStoreGenerate_DynamicSelectionOmitsInlineArray(t *testing.T) { field := &meta.IrField{ - BaseModel: meta.BaseModel{Id: sql.NullString{String: "field-dyn", Valid: true}}, - Name: "Status", - FieldType: "selection", + BaseModel: meta.BaseModel{Id: sql.NullString{String: "field-dyn", Valid: true}}, + Name: "Status", + FieldType: "selection", TsTypeAnnotation: "string", - SelectionKind: "dynamic", - SelectionMethod: "StatusOptions", - Selection: `[{"value":"should","label":"NotEmit"}]`, + SelectionKind: "dynamic", + SelectionMethod: "StatusOptions", + Selection: `[{"value":"should","label":"NotEmit"}]`, } metadata := convertFieldToMetadata(field) if metadata.SelectionKind == nil || *metadata.SelectionKind != "dynamic" { @@ -250,7 +251,7 @@ func TestStripSelectionLabelTextJSON_InvalidOrEmpty(t *testing.T) { if got := stripSelectionLabelTextJSON(" "); got != " " { t.Fatalf("whitespace input should pass through: %q", got) } - raw := `not-json`; + raw := `not-json` if got := stripSelectionLabelTextJSON(raw); got != raw { t.Fatalf("invalid json should pass through: %q", got) } @@ -397,3 +398,23 @@ func TestConvertFieldToMetadata_CopyContract(t *testing.T) { t.Fatalf("copy:true must omit Copy flag, got %#v", metadata2.Copy) } } + +func TestWebApiStoreTemplate_EmitsCopyFalse(t *testing.T) { + falseVal := false + tpl := template.Must(template.New("field").Parse(`{{- if ne .Copy nil}}copy: false,{{- end}}`)) + var buf strings.Builder + if err := tpl.Execute(&buf, FieldMetadata{Copy: &falseVal}); err != nil { + t.Fatalf("execute: %v", err) + } + if got := buf.String(); got != "copy: false," { + t.Fatalf("expected copy: false emission, got %q", got) + } + + buf.Reset() + if err := tpl.Execute(&buf, FieldMetadata{}); err != nil { + t.Fatalf("execute nil: %v", err) + } + if got := buf.String(); got != "" { + t.Fatalf("expected omit when Copy nil, got %q", got) + } +} diff --git a/modules/core/service/orm/model/model.ts b/modules/core/service/orm/model/model.ts index 60fe823e4..902d4bacc 100644 --- a/modules/core/service/orm/model/model.ts +++ b/modules/core/service/orm/model/model.ts @@ -458,7 +458,7 @@ class BaseModel { * Duplicates this instance via Model.Copy(this.Id, defaults). */ async copy(defaults?: Partial>): Promise { - const id = String((this as { Id?: string }).Id || '').trim(); + const id = String(this.Id || '').trim(); if (!id) { throw new Error('Cannot copy an instance without Id'); } diff --git a/modules/core/service/orm/model/model_copy.ts b/modules/core/service/orm/model/model_copy.ts index b10a66165..891966a67 100644 --- a/modules/core/service/orm/model/model_copy.ts +++ b/modules/core/service/orm/model/model_copy.ts @@ -21,10 +21,6 @@ type CopyWalkState = { depth: number; }; -function isRelationType(type: unknown): boolean { - return type === 'ManyToOne' || type === 'OneToMany' || type === 'ManyToMany'; -} - function isAttachmentType(type: unknown): boolean { const normalized = String(type || '') .trim() @@ -115,7 +111,6 @@ export function buildCopyBrowseSelection(meta: ModelMetadata, depth = 0): FieldS } function copyScalarOrManyToOneValue(value: unknown): unknown { - if (value === undefined) return undefined; if (value === null) return null; const id = extractRelationId(value); // Prefer Id when Browse nested a ManyToOne object; otherwise keep scalar as-is. From 652c7f3854bc2eab18296700831ae3dbd724ce29 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 19:46:17 +0800 Subject: [PATCH 3/7] fix(orm): drop dead ManyToOne undefined guard in Copy - Assign copyScalarOrManyToOneValue directly after the caller already skips undefined. Co-authored-by: Cursor --- modules/core/service/orm/model/model_copy.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/core/service/orm/model/model_copy.ts b/modules/core/service/orm/model/model_copy.ts index 891966a67..12c1dd3e7 100644 --- a/modules/core/service/orm/model/model_copy.ts +++ b/modules/core/service/orm/model/model_copy.ts @@ -203,8 +203,7 @@ export function buildCopyValues( } if (field.type === 'ManyToOne') { - const v = copyScalarOrManyToOneValue(raw); - if (v !== undefined) out[fieldName] = v; + out[fieldName] = copyScalarOrManyToOneValue(raw); continue; } From 6c32515b15a5ce059c24457ced01514e63c14e4c Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 19:47:59 +0800 Subject: [PATCH 4/7] fix(orm): resolve ManyToOne copy objects to Id or null - Avoid passing raw relation objects without Id into Create FK columns. Co-authored-by: Cursor --- .../core/service/orm/model/model_copy.test.ts | 31 +++++++++++++++++++ modules/core/service/orm/model/model_copy.ts | 9 +++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/modules/core/service/orm/model/model_copy.test.ts b/modules/core/service/orm/model/model_copy.test.ts index aa10bc19a..e20eb277a 100644 --- a/modules/core/service/orm/model/model_copy.test.ts +++ b/modules/core/service/orm/model/model_copy.test.ts @@ -269,3 +269,34 @@ test('buildCopyValues skips soft-deleted-shaped missing relation rows already fi expect(values.Lines).toBeUndefined(); expect(values.Tags).toBeUndefined(); }); + +test('buildCopyValues resolves ManyToOne objects to Id or null', () => { + const withId = buildCopyValues(CopyLineWidget as any, { + Id: 'line-1', + Name: 'L1', + OrderId: { Id: 'ord-9', Name: 'Order' }, + }); + expect(withId.OrderId).toBe('ord-9'); + + const missingId = buildCopyValues(CopyLineWidget as any, { + Id: 'line-2', + Name: 'L2', + OrderId: {}, + }); + expect(missingId.OrderId).toBeNull(); + + const asString = buildCopyValues(CopyLineWidget as any, { + Id: 'line-3', + Name: 'L3', + OrderId: 'ord-3', + }); + expect(asString.OrderId).toBe('ord-3'); + + const asNull = buildCopyValues(CopyLineWidget as any, { + Id: 'line-4', + Name: 'L4', + OrderId: null, + }); + expect(asNull.OrderId).toBeNull(); +}); + diff --git a/modules/core/service/orm/model/model_copy.ts b/modules/core/service/orm/model/model_copy.ts index 12c1dd3e7..5c7b5ffab 100644 --- a/modules/core/service/orm/model/model_copy.ts +++ b/modules/core/service/orm/model/model_copy.ts @@ -111,10 +111,11 @@ export function buildCopyBrowseSelection(meta: ModelMetadata, depth = 0): FieldS } function copyScalarOrManyToOneValue(value: unknown): unknown { - if (value === null) return null; - const id = extractRelationId(value); - // Prefer Id when Browse nested a ManyToOne object; otherwise keep scalar as-is. - if (id != null && typeof value === 'object') return id; + if (value == null) return null; + // Nested Browse objects must resolve to FK id or null — never pass raw objects into Create. + if (typeof value === 'object') { + return extractRelationId(value); + } return value; } From 85412b54327644ef564483553c755a4407bacdcb Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 19:59:07 +0800 Subject: [PATCH 5/7] test(orm): raise Model.Copy patch coverage toward 100% - Cover copy:true metadata, skip helpers, relation edge cases, and branch partials. Co-authored-by: Cursor --- .../core/service/orm/decorator/field.test.ts | 8 +- .../core/service/orm/model/model_copy.test.ts | 306 ++++++++++++++++++ 2 files changed, 312 insertions(+), 2 deletions(-) diff --git a/modules/core/service/orm/decorator/field.test.ts b/modules/core/service/orm/decorator/field.test.ts index 6bd1cd92e..6d6a7df2b 100644 --- a/modules/core/service/orm/decorator/field.test.ts +++ b/modules/core/service/orm/decorator/field.test.ts @@ -734,9 +734,13 @@ test('Field decorator accepts copy:false and rejects non-boolean copy', () => { class CopyFlagModel extends BaseModel { @Field({ type: 'varchar', size: 32, copy: false } as any) Code!: string; + + @Field({ type: 'varchar', size: 32, copy: true } as any) + Name!: string; } - const meta = MetadataStorage.instance.getModelMetadata(CopyFlagModel as any).fields.get('Code') as any; - expect(meta.copy).toBe(false); + const fields = MetadataStorage.instance.getModelMetadata(CopyFlagModel as any).fields; + expect(fields.get('Code')?.copy).toBe(false); + expect(fields.get('Name')?.copy).toBe(true); expect(() => { class BadCopy extends BaseModel { diff --git a/modules/core/service/orm/model/model_copy.test.ts b/modules/core/service/orm/model/model_copy.test.ts index e20eb277a..91ce25884 100644 --- a/modules/core/service/orm/model/model_copy.test.ts +++ b/modules/core/service/orm/model/model_copy.test.ts @@ -1,12 +1,15 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 +import { Compute } from '../decorator/compute'; import { Field } from '../decorator/field'; import { Model } from '../decorator/model'; +import { SqlCompute } from '../decorator/sqlcompute'; import { MetadataStorage } from '../metadata/storage'; import { RepositoryFactory } from '../repository/repository_factory'; import BaseModel from './model'; import { + COPY_MAX_RELATION_DEPTH, buildCopyBrowseSelection, buildCopyValues, copyModel, @@ -300,3 +303,306 @@ test('buildCopyValues resolves ManyToOne objects to Id or null', () => { expect(asNull.OrderId).toBeNull(); }); + + +@Model('CopySkipMetaWidget', { application: 'demo' }) +class CopySkipMetaWidget extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + Name!: string; + + @Field({ type: 'varchar', size: 64, related: { path: 'PartnerId.Name' } } as any) + RelatedName!: string; + + @Field({ type: 'varchar', size: 64 }) + VirtualName!: string; + + @Compute('VirtualName', { deps: ['Name'], store: false }) + computeVirtualName() { + return this.Name; + } + + @Field({ type: 'varchar', size: 64 }) + SqlName!: string; + + @SqlCompute('SqlName') + sqlSqlName() { + return this.$sql.field('Name'); + } +} + +@Model('CopyAttachWidget', { application: 'demo' }) +class CopyAttachWidget extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + Name!: string; + + @Field({ type: 'image' } as any) + Avatar!: string; + + @Field({ type: 'binary' } as any) + Resume!: string; +} + +@Model('CopyBrokenO2MWidget', { application: 'demo' }) +class CopyBrokenO2MWidget extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + Name!: string; + + @Field({ + type: 'OneToMany', + relation: { targetModel: 'not-a-function', inverseField: 'ParentId' }, + } as any) + BadLines!: any[]; + + @Field({ + type: 'OneToMany', + relation: { targetModel: () => CopyLineWidget }, + } as any) + Incomplete!: any[]; +} + +test('coverage: shouldSkipFieldForCopy skips sqlCompute, virtual compute, and non-stored related', () => { + const meta = getModelRuntimeMetadata(CopySkipMetaWidget as any); + expect(shouldSkipFieldForCopy(meta, 'RelatedName', meta.fields.get('RelatedName')!)).toBe(true); + expect(shouldSkipFieldForCopy(meta, 'VirtualName', meta.fields.get('VirtualName')!)).toBe(true); + expect(shouldSkipFieldForCopy(meta, 'SqlName', meta.fields.get('SqlName')!)).toBe(true); + expect(shouldSkipFieldForCopy(meta, 'Name', meta.fields.get('Name')!)).toBe(false); +}); + +test('coverage: buildCopyBrowseSelection includes attachments and skips non-function O2M target', () => { + const attachSel = buildCopyBrowseSelection(getModelRuntimeMetadata(CopyAttachWidget as any)) as unknown[]; + expect(attachSel).toContain('Avatar'); + expect(attachSel).toContain('Resume'); + + const brokenSel = buildCopyBrowseSelection(getModelRuntimeMetadata(CopyBrokenO2MWidget as any)) as unknown[]; + // targetModel not a function → skipped + expect(brokenSel.some(item => item && typeof item === 'object' && 'BadLines' in (item as object))).toBe(false); + // targetModel callable but missing inverseField still resolves target meta for Browse nesting + expect(brokenSel.some(item => item && typeof item === 'object' && 'Incomplete' in (item as object))).toBe(true); +}); + +test('coverage: buildCopyBrowseSelection stops OneToMany nesting at max depth', () => { + const meta = getModelRuntimeMetadata(CopyOrderWidget as any); + const selection = buildCopyBrowseSelection(meta, COPY_MAX_RELATION_DEPTH) as unknown[]; + expect(selection.some(item => item && typeof item === 'object' && 'Lines' in (item as object))).toBe(false); +}); + +test('coverage: buildCopyValues handles M2M edge shapes and extractRelationId variants', () => { + const single = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-1', + Name: 'Order', + Tags: 'tag-solo', + }); + expect(single.Tags).toEqual(['tag-solo']); + + const numericScalar = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-num', + Name: 'Order', + Tags: 99, + }); + expect(numericScalar.Tags).toEqual(['99']); + + const numericIds = buildCopyValues(CopyOrderWidget as any, { + id: 42, + Name: 'Order', + Tags: [{ Id: 7 }, { id: 8 }, '', null, { Id: ' ' }, 'tag-a', 'tag-a'], + }); + expect(numericIds.Tags).toEqual(['7', '8', 'tag-a']); + + const emptyM2M = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-2', + Name: 'Order', + Tags: null, + }); + expect(emptyM2M.Tags).toBeUndefined(); + + const badSingle = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-3', + Name: 'Order', + Tags: {}, + }); + expect(badSingle.Tags).toBeUndefined(); +}); + +test('coverage: buildCopyValues skips incomplete O2M config and non-object children', () => { + const values = buildCopyValues(CopyBrokenO2MWidget as any, { + Id: 'p1', + Name: 'Parent', + BadLines: [{ Id: 'x', Name: 'x' }], + Incomplete: [{ Id: 'y', Name: 'y' }], + }); + expect(values.BadLines).toBeUndefined(); + expect(values.Incomplete).toBeUndefined(); + + const skipNonObject = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-4', + Name: 'Order', + Lines: ['not-an-object', null, { Id: 'line-ok', Name: 'OK', OrderId: 'ord-4' }], + }); + expect(skipNonObject.Lines).toEqual({ create: [{ Name: 'OK' }] }); +}); + +test('coverage: buildCopyValues throws on cyclic OneToMany and depth overflow', () => { + let cycleMsg = ''; + try { + buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-cycle', + Name: 'Order', + Lines: [{ Id: 'ord-cycle', Name: 'self', OrderId: 'ord-cycle' }], + }); + } catch (error) { + cycleMsg = String((error as Error).message || error); + } + expect(cycleMsg).toContain('cyclic OneToMany'); + + let depthMsg = ''; + try { + buildCopyValues( + CopyOrderWidget as any, + { + Id: 'ord-depth', + Name: 'Order', + Lines: [{ Id: 'line-d', Name: 'L', OrderId: 'ord-depth' }], + }, + undefined, + { ancestorIds: new Set(['ord-depth']), depth: COPY_MAX_RELATION_DEPTH } + ); + } catch (error) { + depthMsg = String((error as Error).message || error); + } + expect(depthMsg).toContain('depth exceeded'); +}); + +test('coverage: buildCopyValues skips undefined default overrides and copies attachment scalars', () => { + const values = buildCopyValues( + CopyAttachWidget as any, + { + Id: 'a1', + Name: 'Doc', + Avatar: 'att-avatar', + Resume: 'att-resume', + }, + { Name: 'Renamed', Avatar: undefined } + ); + expect(values).toEqual({ + Name: 'Renamed', + Avatar: 'att-avatar', + Resume: 'att-resume', + }); +}); + +test('coverage: getTargetModelMetadata catch path when metadata lookup throws', () => { + const meta = getModelRuntimeMetadata(CopyOrderWidget as any); + const lines = meta.fields.get('Lines')!; + const original = lines.relation; + (lines as any).relation = { + targetModel: () => { + throw new Error('boom'); + }, + inverseField: 'OrderId', + }; + try { + const selection = buildCopyBrowseSelection(meta) as unknown[]; + expect(selection.some(item => item && typeof item === 'object' && 'Lines' in (item as object))).toBe(false); + } finally { + (lines as any).relation = original; + } +}); + + +test('coverage: remaining branch partials in helpers', async () => { + // extractRelationId: non-object-like after scalar checks → !record + const boolTag = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-b', + Name: 'Order', + Tags: true as any, + }); + expect(boolTag.Tags).toBeUndefined(); + + // primaryKey skip without copy:false (Id is copy:false first; mutate a normal field) + const meta = getModelRuntimeMetadata(CopyScalarWidget as any); + const nameField = meta.fields.get('Name')!; + const prevColumn = nameField.column; + (nameField as any).column = { ...(prevColumn || {}), primaryKey: true }; + try { + expect(shouldSkipFieldForCopy(meta, 'Name', nameField)).toBe(true); + } finally { + (nameField as any).column = prevColumn; + } + + // field present with explicit undefined value + const undefName = buildCopyValues(CopyScalarWidget as any, { + Id: 's1', + Name: undefined, + Code: 'C', + }); + expect(undefName.Name).toBeUndefined(); + expect(undefName.Code).toBe('C'); + + // child lowercase id + depth error fallbacks when fullModelName empty + const orderMeta = getModelRuntimeMetadata(CopyOrderWidget as any); + const prevFull = orderMeta.fullModelName; + const prevModel = orderMeta.modelName; + (orderMeta as any).fullModelName = ''; + (orderMeta as any).modelName = ''; + try { + let depthMsg = ''; + try { + buildCopyValues( + CopyOrderWidget as any, + { + Id: 'ord-d2', + Name: 'Order', + Lines: [{ id: 'line-lower', Name: 'L', OrderId: 'ord-d2' }], + }, + undefined, + { ancestorIds: new Set(['ord-d2']), depth: COPY_MAX_RELATION_DEPTH } + ); + } catch (error) { + depthMsg = String((error as Error).message || error); + } + expect(depthMsg).toContain('depth exceeded'); + } finally { + (orderMeta as any).fullModelName = prevFull; + (orderMeta as any).modelName = prevModel; + } + + // lowercase child id copies when depth allows + const lower = buildCopyValues(CopyOrderWidget as any, { + Id: 'ord-lower', + Name: 'Order', + Lines: [{ id: 'line-lower-2', Name: 'L2', OrderId: 'ord-lower' }], + }); + expect(lower.Lines).toEqual({ create: [{ Name: 'L2' }] }); + + // copyModel id nullish → empty string path + let nullIdErr = ''; + try { + await copyModel(CopyScalarWidget as any, null as any); + } catch (error) { + nullIdErr = String((error as Error).message || error); + } + expect(nullIdErr).toContain('id is required'); + + // empty fields map fallback for browse selection + buildCopyValues + const attachMeta = getModelRuntimeMetadata(CopyAttachWidget as any); + const prevFields = attachMeta.fields; + (attachMeta as any).fields = null; + try { + expect(buildCopyBrowseSelection(attachMeta)).toEqual(['*']); + expect(buildCopyValues(CopyAttachWidget as any, { Id: 'empty-fields' })).toEqual({}); + } finally { + (attachMeta as any).fields = prevFields; + } + + // falsy field.type hits isAttachmentType empty-string branch without classifying as attachment + const avatar = prevFields.get('Avatar')!; + const prevType = avatar.type; + (avatar as any).type = ''; + try { + const sel = buildCopyBrowseSelection(getModelRuntimeMetadata(CopyAttachWidget as any)) as unknown[]; + expect(sel).not.toContain('Avatar'); + } finally { + (avatar as any).type = prevType; + } +}); + From 2e6bc3e24f8ede531bf5701cf5affc7a06a07046 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 20:01:02 +0800 Subject: [PATCH 6/7] fix(orm): reject non-object source rows in buildCopyValues - Guard with asObjectRecord and throw instead of silently copying defaults. Co-authored-by: Cursor --- .../core/service/orm/model/model_copy.test.ts | 18 ++++++++++++++++++ modules/core/service/orm/model/model_copy.ts | 11 ++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/modules/core/service/orm/model/model_copy.test.ts b/modules/core/service/orm/model/model_copy.test.ts index 91ce25884..bf1928735 100644 --- a/modules/core/service/orm/model/model_copy.test.ts +++ b/modules/core/service/orm/model/model_copy.test.ts @@ -606,3 +606,21 @@ test('coverage: remaining branch partials in helpers', async () => { } }); +test('buildCopyValues rejects null or non-object source rows', () => { + let nullMsg = ''; + try { + buildCopyValues(CopyScalarWidget as any, null as any); + } catch (error) { + nullMsg = String((error as Error).message || error); + } + expect(nullMsg).toContain('source row must be a non-null object'); + + let badMsg = ''; + try { + buildCopyValues(CopyScalarWidget as any, 'not-a-row' as any); + } catch (error) { + badMsg = String((error as Error).message || error); + } + expect(badMsg).toContain('source row must be a non-null object'); +}); + diff --git a/modules/core/service/orm/model/model_copy.ts b/modules/core/service/orm/model/model_copy.ts index 5c7b5ffab..22211b400 100644 --- a/modules/core/service/orm/model/model_copy.ts +++ b/modules/core/service/orm/model/model_copy.ts @@ -145,22 +145,27 @@ export function buildCopyValues( defaults?: Partial>, state?: CopyWalkState ): UnknownRecord { + const sourceRecord = asObjectRecord(row); + if (!sourceRecord) { + throw new Error('[Copy] source row must be a non-null object'); + } + const meta = getModelRuntimeMetadata(ModelCtor); const walk: CopyWalkState = state || { ancestorIds: new Set(), depth: 0, }; - const sourceId = extractRelationId(row.Id ?? row.id); + const sourceId = extractRelationId(sourceRecord.Id ?? sourceRecord.id); if (sourceId) walk.ancestorIds.add(sourceId); const out: UnknownRecord = {}; for (const [fieldName, field] of meta.fields || []) { if (shouldSkipFieldForCopy(meta, fieldName, field)) continue; - if (!(fieldName in row)) continue; + if (!(fieldName in sourceRecord)) continue; - const raw = row[fieldName]; + const raw = sourceRecord[fieldName]; if (raw === undefined) continue; if (field.type === 'OneToMany') { From be8a9e85a55e66310ee72ffaa45d867f8994de8e Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 20:25:48 +0800 Subject: [PATCH 7/7] fix(orm): mark P0 sensitive fields copy:false - Skip auth password, token/session identity and lifecycle, and user token/session collections on Copy. - Skip sequence watermark/idempotency keys and document mutation/upload request ids. Co-authored-by: Cursor --- modules/auth/service/models/session.ts | 4 ++++ modules/auth/service/models/token.ts | 6 ++++++ modules/auth/service/models/user.ts | 3 +++ modules/base/service/models/sequence.ts | 1 + modules/base/service/models/sequence_idempotency.ts | 5 +++++ .../document/service/models/attachment_mutation_ledger.ts | 1 + modules/document/service/models/upload_session.ts | 1 + 7 files changed, 21 insertions(+) diff --git a/modules/auth/service/models/session.ts b/modules/auth/service/models/session.ts index aa83ccf61..d9aec2703 100644 --- a/modules/auth/service/models/session.ts +++ b/modules/auth/service/models/session.ts @@ -28,6 +28,7 @@ export default class Session extends BaseModel { type: 'varchar', size: 36, index: true, + copy: false, string: _lt('Access Token ID', { scope: 'auth.model.Session.fields' }), }) AccessTokenId: string; @@ -58,6 +59,7 @@ export default class Session extends BaseModel { type: 'datetime', notNull: true, index: true, + copy: false, string: _lt('Expires At', { scope: 'auth.model.Session.fields' }), }) ExpiresAt: Date; @@ -68,6 +70,7 @@ export default class Session extends BaseModel { @Field({ type: 'datetime', index: true, + copy: false, string: _lt('Last Activity', { scope: 'auth.model.Session.fields' }), }) LastActivityAt: Date; @@ -80,6 +83,7 @@ export default class Session extends BaseModel { size: 20, default: () => 'active', index: true, + copy: false, string: _lt('Status', { scope: 'auth.model.Session.fields' }), }) Status: string; diff --git a/modules/auth/service/models/token.ts b/modules/auth/service/models/token.ts index cdfb9ddb6..6bf383171 100644 --- a/modules/auth/service/models/token.ts +++ b/modules/auth/service/models/token.ts @@ -44,6 +44,7 @@ export default class Token extends BaseModel { size: 36, notNull: true, unique: true, + copy: false, string: _lt('Token ID', { scope: 'auth.model.Token.fields' }), }) TokenId: string; @@ -67,6 +68,7 @@ export default class Token extends BaseModel { type: 'datetime', notNull: true, index: true, + copy: false, string: _lt('Expires At', { scope: 'auth.model.Token.fields' }), }) ExpiresAt: Date; @@ -78,6 +80,7 @@ export default class Token extends BaseModel { type: 'boolean', default: () => false, index: true, + copy: false, string: _lt('Revoked', { scope: 'auth.model.Token.fields' }), }) Revoked: boolean; @@ -88,6 +91,7 @@ export default class Token extends BaseModel { @Field({ type: 'datetime', index: true, + copy: false, string: _lt('Revoked At', { scope: 'auth.model.Token.fields' }), }) RevokedAt: Date; @@ -98,6 +102,7 @@ export default class Token extends BaseModel { @Field({ type: 'varchar', size: 255, + copy: false, string: _lt('Revocation Reason', { scope: 'auth.model.Token.fields' }), }) RevocationReason: string; @@ -107,6 +112,7 @@ export default class Token extends BaseModel { */ @Field({ type: 'jsonobject', + copy: false, string: _lt('Metadata', { scope: 'auth.model.Token.fields' }), }) Metadata: Record; diff --git a/modules/auth/service/models/user.ts b/modules/auth/service/models/user.ts index 7b035b56e..979dc706d 100644 --- a/modules/auth/service/models/user.ts +++ b/modules/auth/service/models/user.ts @@ -95,6 +95,7 @@ export default class User extends BaseModel { type: 'varchar', size: 255, notNull: true, + copy: false, string: _lt('Password Hash', { scope: 'auth.model.User.fields' }), }) PasswordHash: string; @@ -251,6 +252,7 @@ export default class User extends BaseModel { @Field({ type: 'OneToMany', relation: { targetModel: () => Session, inverseField: 'UserId' }, + copy: false, string: _lt('Sessions', { scope: 'auth.model.User.fields' }), }) Sessions: Session[]; @@ -261,6 +263,7 @@ export default class User extends BaseModel { @Field({ type: 'OneToMany', relation: { targetModel: () => Token, inverseField: 'UserId' }, + copy: false, string: _lt('Tokens', { scope: 'auth.model.User.fields' }), }) Tokens: Token[]; diff --git a/modules/base/service/models/sequence.ts b/modules/base/service/models/sequence.ts index b12023390..10521b1d6 100644 --- a/modules/base/service/models/sequence.ts +++ b/modules/base/service/models/sequence.ts @@ -96,6 +96,7 @@ export default class Sequence extends BaseModel { type: 'bigint', notNull: true, default: () => 1, + copy: false, string: _lt('Next Number', { scope: 'base.model.Sequence.fields' }), }) NextNumber: bigint; diff --git a/modules/base/service/models/sequence_idempotency.ts b/modules/base/service/models/sequence_idempotency.ts index f2c5dbcb7..c848f38f1 100644 --- a/modules/base/service/models/sequence_idempotency.ts +++ b/modules/base/service/models/sequence_idempotency.ts @@ -51,6 +51,7 @@ export default class SequenceIdempotency extends BaseModel { notNull: true, uniqueIndex: 'uidx_base_sequence_idem_seq_key', index: true, + copy: false, string: _lt('Idempotency Key', { scope: 'base.model.SequenceIdempotency.fields' }), }) IdempotencyKey: string; @@ -73,6 +74,7 @@ export default class SequenceIdempotency extends BaseModel { @Field({ type: 'bigint', notNull: true, + copy: false, string: _lt('Start', { scope: 'base.model.SequenceIdempotency.fields' }), }) RangeStart: bigint; @@ -80,6 +82,7 @@ export default class SequenceIdempotency extends BaseModel { @Field({ type: 'bigint', notNull: true, + copy: false, string: _lt('End', { scope: 'base.model.SequenceIdempotency.fields' }), }) RangeEnd: bigint; @@ -87,6 +90,7 @@ export default class SequenceIdempotency extends BaseModel { @Field({ type: 'varchar', size: 128, + copy: false, string: _lt('Request Hash', { scope: 'base.model.SequenceIdempotency.fields' }), }) RequestHash?: string; @@ -95,6 +99,7 @@ export default class SequenceIdempotency extends BaseModel { type: 'datetime', notNull: true, index: true, + copy: false, string: _lt('Expires At', { scope: 'base.model.SequenceIdempotency.fields' }), }) ExpiresAt: Date; diff --git a/modules/document/service/models/attachment_mutation_ledger.ts b/modules/document/service/models/attachment_mutation_ledger.ts index 225dcc947..0ca49014a 100644 --- a/modules/document/service/models/attachment_mutation_ledger.ts +++ b/modules/document/service/models/attachment_mutation_ledger.ts @@ -41,6 +41,7 @@ export default class AttachmentMutationLedger extends BaseModel { size: 100, notNull: true, uniqueIndex: 'uidx_document_mutation_company_action_id', + copy: false, string: _lt('Mutation', { scope: 'document.model.AttachmentMutationLedger.fields' }), }) MutationId: string; diff --git a/modules/document/service/models/upload_session.ts b/modules/document/service/models/upload_session.ts index fce1d2798..e5e30afbd 100644 --- a/modules/document/service/models/upload_session.ts +++ b/modules/document/service/models/upload_session.ts @@ -89,6 +89,7 @@ export default class AttachmentUploadSession extends BaseModel { notNull: true, index: true, uniqueIndex: 'uidx_document_upload_business_request', + copy: false, string: _lt('Business Request', { scope: 'document.model.AttachmentUploadSession.fields' }), }) BusinessRequestId: string;