Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/module/artifact/generate/webapistore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions internal/module/artifact/generate/webapistore.ts.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 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}}
Expand Down
69 changes: 62 additions & 7 deletions internal/module/artifact/generate/webapistore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -363,3 +364,57 @@ 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)
}
}

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)
}
}
3 changes: 3 additions & 0 deletions internal/parser/backendtsparser/field_resolved.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions internal/parser/backendtsparser/parser_migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 4 additions & 0 deletions modules/auth/service/models/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions modules/auth/service/models/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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<string, any>;
Expand Down
3 changes: 3 additions & 0 deletions modules/auth/service/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];
Expand All @@ -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[];
Expand Down
1 change: 1 addition & 0 deletions modules/base/service/models/sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions modules/base/service/models/sequence_idempotency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -73,20 +74,23 @@ export default class SequenceIdempotency extends BaseModel {
@Field({
type: 'bigint',
notNull: true,
copy: false,
string: _lt('Start', { scope: 'base.model.SequenceIdempotency.fields' }),
})
RangeStart: bigint;

@Field({
type: 'bigint',
notNull: true,
copy: false,
string: _lt('End', { scope: 'base.model.SequenceIdempotency.fields' }),
})
RangeEnd: bigint;

@Field({
type: 'varchar',
size: 128,
copy: false,
string: _lt('Request Hash', { scope: 'base.model.SequenceIdempotency.fields' }),
})
RequestHash?: string;
Expand All @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions modules/core/service/orm/decorator/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,3 +729,25 @@ 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;

@Field({ type: 'varchar', size: 32, copy: true } as any)
Name!: string;
}
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 {
@Field({ type: 'varchar', copy: 'no' as any } as any)
Name!: string;
}
return BadCopy;
}).toThrow('copy must be a boolean');
});

8 changes: 8 additions & 0 deletions modules/core/service/orm/decorator/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } {
Expand Down Expand Up @@ -117,6 +119,10 @@ export function Field<T extends BaseModel, R extends keyof T = keyof T, TJoin ex
throw new Error(`@Field(${name}) translate must be a boolean`);
}
const translate = optionBag.translate === true;
if (optionBag.copy !== undefined && typeof optionBag.copy !== 'boolean') {
throw new Error(`@Field(${name}) copy must be a boolean`);
}
const copyFlag = optionBag.copy === false ? false : optionBag.copy === true ? true : undefined;
if (translate) {
if (type !== 'char' && type !== 'varchar' && type !== 'text') {
throw new Error(`@Field(${name}) translate is only supported on char/varchar/text fields`);
Expand Down Expand Up @@ -446,6 +452,8 @@ export function Field<T extends BaseModel, R extends keyof T = keyof T, TJoin ex
if (normalizedRelated) meta.related = normalizedRelated;
if (normalizedStorageHints) meta.storageHints = normalizedStorageHints;
if (translate) meta.translate = true;
if (copyFlag === false) meta.copy = false;
else if (copyFlag === true) meta.copy = true;

// Write metadata
const ctor = target.constructor as ModelCtor<BaseModel> & typeof BaseModel;
Expand Down
Loading