Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit d9cdd9c

Browse files
authored
fix(introspection): preserve data validation attributes, enum ordering, and comments during db pull (#672)
* fix(introspection): preserve schema integrity during db pull - Retain data validation attributes (e.g., @email) on fields after introspection (#670) - Preserve original declaration order of enums instead of moving them to the end of the schema file (#669) - Preserve triple-slash comments above enum declarations (#669) Fixes #669, fixes #670 * fix: address PR comments * fix(cli): improve db pull for relations and defaults Prevents field name collisions during introspection by refining the naming strategy for self-referencing relations with multiple foreign keys. Extends support for JSON and Bytes default values across MySQL, PostgreSQL, and SQLite providers to ensure consistent schema restoration. Adds test cases for self-referencing models to verify the avoidance of duplicate fields. * fix: address PR comments
1 parent f3a24dd commit d9cdd9c

File tree

9 files changed

+424
-27
lines changed

9 files changed

+424
-27
lines changed

packages/cli/src/actions/db.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {
1212
loadSchemaDocument,
1313
requireDataSourceUrl,
1414
} from './action-utils';
15-
import { syncEnums, syncRelation, syncTable, type Relation } from './pull';
15+
import { consolidateEnums, syncEnums, syncRelation, syncTable, type Relation } from './pull';
1616
import { providers as pullProviders } from './pull/provider';
17-
import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName } from './pull/utils';
17+
import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, isDatabaseManagedAttribute } from './pull/utils';
1818
import type { DataSourceProviderType } from '@zenstackhq/schema';
1919
import { CliError } from '../cli-error';
2020

@@ -173,6 +173,10 @@ async function runPull(options: PullOptions) {
173173
});
174174
}
175175

176+
// Consolidate per-column enums (e.g., MySQL's synthetic UserStatus/GroupStatus)
177+
// back to shared enums from the original schema (e.g., Status)
178+
consolidateEnums({ newModel, oldModel: model });
179+
176180
console.log(colors.blue('Schema synced'));
177181

178182
const baseDir = path.dirname(path.resolve(schemaFile));
@@ -457,12 +461,13 @@ async function runPull(options: PullOptions) {
457461
}
458462
return;
459463
}
464+
460465
// Track deleted attributes (in original but not in new)
461466
originalField.attributes
462467
.filter(
463468
(attr) =>
464-
!f.attributes.find((d) => d.decl.$refText === attr.decl.$refText) &&
465-
!['@map', '@@map', '@default', '@updatedAt'].includes(attr.decl.$refText),
469+
!f.attributes.find((d) => d.decl.$refText === attr.decl.$refText) &&
470+
isDatabaseManagedAttribute(attr.decl.$refText),
466471
)
467472
.forEach((attr) => {
468473
const field = attr.$container;
@@ -478,7 +483,7 @@ async function runPull(options: PullOptions) {
478483
.filter(
479484
(attr) =>
480485
!originalField.attributes.find((d) => d.decl.$refText === attr.decl.$refText) &&
481-
!['@map', '@@map', '@default', '@updatedAt'].includes(attr.decl.$refText),
486+
isDatabaseManagedAttribute(attr.decl.$refText),
482487
)
483488
.forEach((attr) => {
484489
// attach the new attribute to the original field
@@ -619,8 +624,8 @@ async function runPull(options: PullOptions) {
619624
}
620625

621626
const generator = new ZModelCodeGenerator({
622-
quote: options.quote,
623-
indent: options.indent,
627+
quote: options.quote ?? 'single',
628+
indent: options.indent ?? 4,
624629
});
625630

626631
if (options.output) {

packages/cli/src/actions/pull/index.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,13 +533,20 @@ export function syncRelation({
533533
sourceModel.fields.splice(firstSourceFieldId, 0, sourceFieldFactory.node); // Insert the relation field before the first FK scalar field
534534

535535
const oppositeFieldPrefix = /[0-9]/g.test(targetModel.name.charAt(0)) ? '_' : '';
536-
const { name: oppositeFieldName } = resolveNameCasing(
536+
let { name: oppositeFieldName } = resolveNameCasing(
537537
options.fieldCasing,
538538
similarRelations > 0
539539
? `${oppositeFieldPrefix}${lowerCaseFirst(sourceModel.name)}_${firstColumn}`
540540
: `${lowerCaseFirst(resolveNameCasing(options.fieldCasing, sourceModel.name).name)}${relation.references.type === 'many'? 's' : ''}`,
541541
);
542542

543+
if (targetModel.fields.find((f) => f.name === oppositeFieldName)) {
544+
({ name: oppositeFieldName } = resolveNameCasing(
545+
options.fieldCasing,
546+
`${lowerCaseFirst(sourceModel.name)}_${firstColumn}To${relation.references.table}_${relation.references.columns[0]}`,
547+
));
548+
}
549+
543550
const targetFieldFactory = new DataFieldFactory()
544551
.setContainer(targetModel)
545552
.setName(oppositeFieldName)
@@ -556,3 +563,116 @@ export function syncRelation({
556563

557564
targetModel.fields.push(targetFieldFactory.node);
558565
}
566+
567+
/**
568+
* Consolidates per-column enums back to shared enums when possible.
569+
*
570+
* MySQL doesn't have named enum types — each column gets a synthetic enum
571+
* (e.g., `UserStatus`, `GroupStatus`). When the original schema used a shared
572+
* enum (e.g., `Status`) across multiple fields, this function detects the
573+
* mapping via field references and consolidates the synthetic enums back into
574+
* the original shared enum so the merge phase can match them correctly.
575+
*/
576+
export function consolidateEnums({
577+
newModel,
578+
oldModel,
579+
}: {
580+
newModel: Model;
581+
oldModel: Model;
582+
}) {
583+
const newEnums = newModel.declarations.filter((d) => isEnum(d)) as Enum[];
584+
const newDataModels = newModel.declarations.filter((d) => d.$type === 'DataModel') as DataModel[];
585+
const oldDataModels = oldModel.declarations.filter((d) => d.$type === 'DataModel') as DataModel[];
586+
587+
// For each new enum, find which old enum it corresponds to (via field references)
588+
const enumMapping = new Map<Enum, Enum>(); // newEnum -> oldEnum
589+
590+
for (const newEnum of newEnums) {
591+
for (const newDM of newDataModels) {
592+
for (const field of newDM.fields) {
593+
if (field.$type !== 'DataField' || field.type.reference?.ref !== newEnum) continue;
594+
595+
// Find matching model in old model by db name
596+
const oldDM = oldDataModels.find((d) => getDbName(d) === getDbName(newDM));
597+
if (!oldDM) continue;
598+
599+
// Find matching field in old model by db name
600+
const oldField = oldDM.fields.find((f) => getDbName(f) === getDbName(field));
601+
if (!oldField || oldField.$type !== 'DataField' || !oldField.type.reference?.ref) continue;
602+
603+
const oldEnum = oldField.type.reference.ref;
604+
if (!isEnum(oldEnum)) continue;
605+
606+
enumMapping.set(newEnum, oldEnum as Enum);
607+
break;
608+
}
609+
if (enumMapping.has(newEnum)) break;
610+
}
611+
}
612+
613+
// Group by old enum: oldEnum -> [newEnum1, newEnum2, ...]
614+
const reverseMapping = new Map<Enum, Enum[]>();
615+
for (const [newEnum, oldEnum] of enumMapping) {
616+
if (!reverseMapping.has(oldEnum)) {
617+
reverseMapping.set(oldEnum, []);
618+
}
619+
reverseMapping.get(oldEnum)!.push(newEnum);
620+
}
621+
622+
// Consolidate: when new enums map to the same old enum with matching values
623+
for (const [oldEnum, newEnumsGroup] of reverseMapping) {
624+
const keepEnum = newEnumsGroup[0]!;
625+
626+
// Skip if already correct (single enum with matching name)
627+
if (newEnumsGroup.length === 1 && keepEnum.name === oldEnum.name) continue;
628+
629+
// Check that all new enums have the same values as the old enum
630+
const oldValues = new Set(oldEnum.fields.map((f) => getDbName(f)));
631+
const allMatch = newEnumsGroup.every((ne) => {
632+
const newValues = new Set(ne.fields.map((f) => getDbName(f)));
633+
return oldValues.size === newValues.size && [...oldValues].every((v) => newValues.has(v));
634+
});
635+
636+
if (!allMatch) continue;
637+
638+
// Rename the kept enum to match the old shared name
639+
keepEnum.name = oldEnum.name;
640+
641+
// Replace keepEnum's attributes with those from the old enum so that
642+
// any synthetic @@map added by syncEnums is removed and getDbName(keepEnum)
643+
// reflects the consolidated name rather than the stale per-column name.
644+
// Shallow-copy and re-parent so AST $container pointers reference keepEnum.
645+
keepEnum.attributes = oldEnum.attributes.map((attr) => {
646+
const copy = { ...attr, $container: keepEnum };
647+
return copy;
648+
});
649+
650+
// Remove duplicate enums from newModel
651+
for (let i = 1; i < newEnumsGroup.length; i++) {
652+
const idx = newModel.declarations.indexOf(newEnumsGroup[i]!);
653+
if (idx >= 0) {
654+
newModel.declarations.splice(idx, 1);
655+
}
656+
}
657+
658+
// Update all field references in newModel to point to the kept enum
659+
for (const newDM of newDataModels) {
660+
for (const field of newDM.fields) {
661+
if (field.$type !== 'DataField') continue;
662+
const ref = field.type.reference?.ref;
663+
if (ref && newEnumsGroup.includes(ref as Enum)) {
664+
(field.type as any).reference = {
665+
ref: keepEnum,
666+
$refText: keepEnum.name,
667+
};
668+
}
669+
}
670+
}
671+
672+
console.log(
673+
colors.gray(
674+
`Consolidated enum${newEnumsGroup.length > 1 ? 's' : ''} ${newEnumsGroup.map((e) => e.name).join(', ')}${oldEnum.name}`,
675+
),
676+
);
677+
}
678+
}

packages/cli/src/actions/pull/provider/mysql.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ export const mysql: IntrospectionProvider = {
266266
return (ab) => ab.InvocationExpr.setFunction(getFunctionRef('uuid', services));
267267
}
268268
return (ab) => ab.StringLiteral.setValue(val);
269+
case 'Json':
270+
return (ab) => ab.StringLiteral.setValue(val);
271+
case 'Bytes':
272+
return (ab) => ab.StringLiteral.setValue(val);
269273
}
270274

271275
// Handle function calls (e.g., uuid(), now())

packages/cli/src/actions/pull/provider/postgresql.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,16 @@ export const postgresql: IntrospectionProvider = {
284284
return (ab) => ab.StringLiteral.setValue(val.slice(1, -1).replace(/''/g, "'"));
285285
}
286286
return (ab) => ab.StringLiteral.setValue(val);
287+
case 'Json':
288+
if (val.includes('::')) {
289+
return typeCastingConvert({defaultValue,enums,val,services});
290+
}
291+
return (ab) => ab.StringLiteral.setValue(val);
292+
case 'Bytes':
293+
if (val.includes('::')) {
294+
return typeCastingConvert({defaultValue,enums,val,services});
295+
}
296+
return (ab) => ab.StringLiteral.setValue(val);
287297
}
288298

289299
if (val.includes('(') && val.includes(')')) {

packages/cli/src/actions/pull/provider/sqlite.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,10 @@ export const sqlite: IntrospectionProvider = {
394394
return (ab) => ab.StringLiteral.setValue(strippedName);
395395
}
396396
return (ab) => ab.StringLiteral.setValue(val);
397+
case 'Json':
398+
return (ab) => ab.StringLiteral.setValue(val);
399+
case 'Bytes':
400+
return (ab) => ab.StringLiteral.setValue(val);
397401
}
398402

399403
console.warn(`Unsupported default value type: "${defaultValue}" for field type "${fieldType}". Skipping default value.`);

packages/cli/src/actions/pull/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export function getAttribute(model: Model, attrName: string) {
2828
| undefined;
2929
}
3030

31+
export function isDatabaseManagedAttribute(name: string) {
32+
return ['@relation', '@id', '@unique'].includes(name) || name.startsWith('@db.');
33+
}
34+
3135
export function getDatasource(model: Model) {
3236
const datasource = model.declarations.find((d) => d.$type === 'DataSource');
3337
if (!datasource) {

packages/cli/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ function createProgram() {
170170
.addOption(
171171
new Option('--quote <double|single>', 'set the quote style of generated schema files').default('single'),
172172
)
173-
.addOption(new Option('--indent <number>', 'set the indentation of the generated schema files').default(4).argParser(parseInt))
173+
.addOption(new Option('--indent <number>', 'set the indentation of the generated schema files').default(4))
174174
.action((options) => dbAction('pull', options));
175175

176176
dbCommand

0 commit comments

Comments
 (0)