@memberjunction/codegen-lib 5.24.0 → 5.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { CodeGenDatabaseProvider } from './codeGenDatabaseProvider.js';
2
2
  import { SQLServerCodeGenProvider } from './providers/sqlserver/SQLServerCodeGenProvider.js';
3
3
  import { configInfo, currentWorkingDirectory, dbType, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
4
4
  import { EntityInfo, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
5
- import { logError, logMessage, logStatus } from "../Misc/status_logging.js";
5
+ import { logError, logMessage, logStatus, startSpinner, updateSpinner, succeedSpinner } from "../Misc/status_logging.js";
6
6
  import { SQLUtilityBase } from "./sql.js";
7
7
  import { AdvancedGeneration } from "../Misc/advanced_generation.js";
8
8
  import { SQLParser } from "@memberjunction/sql-parser";
@@ -186,6 +186,20 @@ export class ManageMetadataBase {
186
186
  static get modifiedEntityList() {
187
187
  return this._modifiedEntityList;
188
188
  }
189
+ static { this._entitiesRequiringViewRegen = []; }
190
+ /**
191
+ * Entities that had late-phase changes requiring their base views to be
192
+ * regenerated after the main SQL generation pass. Each entry includes
193
+ * the reason for regen so downstream logic can apply reason-specific fixups.
194
+ */
195
+ static get EntitiesRequiringViewRegen() {
196
+ return this._entitiesRequiringViewRegen;
197
+ }
198
+ static AddEntityRequiringViewRegen(entityName, reason) {
199
+ if (!this._entitiesRequiringViewRegen.some(e => e.EntityName === entityName && e.Reason === reason)) {
200
+ this._entitiesRequiringViewRegen.push({ EntityName: entityName, Reason: reason });
201
+ }
202
+ }
189
203
  static { this._generatedValidators = []; }
190
204
  /**
191
205
  * Globally scoped list of validators that have been generated during the metadata management process.
@@ -1260,7 +1274,8 @@ export class ManageMetadataBase {
1260
1274
  * Valid values for EntityField.ExtendedType, plus common LLM aliases mapped to valid values.
1261
1275
  */
1262
1276
  static { this.VALID_EXTENDED_TYPES = new Set([
1263
- 'Code', 'Email', 'FaceTime', 'Geo', 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg'
1277
+ 'Code', 'Email', 'FaceTime', 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
1278
+ 'GeoCity', 'GeoPostalCode', 'GeoAddress', 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg'
1264
1279
  ]); }
1265
1280
  static { this.EXTENDED_TYPE_ALIASES = {
1266
1281
  'phone': 'Tel',
@@ -1273,6 +1288,19 @@ export class ManageMetadataBase {
1273
1288
  'text': 'SMS',
1274
1289
  'location': 'Geo',
1275
1290
  'address': 'Geo',
1291
+ 'latitude': 'GeoLatitude',
1292
+ 'lat': 'GeoLatitude',
1293
+ 'longitude': 'GeoLongitude',
1294
+ 'lng': 'GeoLongitude',
1295
+ 'lon': 'GeoLongitude',
1296
+ 'country': 'GeoCountry',
1297
+ 'state': 'GeoStateProvince',
1298
+ 'province': 'GeoStateProvince',
1299
+ 'city': 'GeoCity',
1300
+ 'postalcode': 'GeoPostalCode',
1301
+ 'zipcode': 'GeoPostalCode',
1302
+ 'zip': 'GeoPostalCode',
1303
+ 'streetaddress': 'GeoAddress',
1276
1304
  'teams': 'MSTeams',
1277
1305
  'facetime': 'FaceTime',
1278
1306
  'zoom': 'ZoomMtg',
@@ -1866,11 +1894,13 @@ export class ManageMetadataBase {
1866
1894
  // Advanced Generation - Smart field identification and form layout
1867
1895
  if (!skipAdvancedGeneration) {
1868
1896
  const step7StartTime = new Date();
1897
+ startSpinner('Applying AI-powered advanced generation (smart fields, form layout)...');
1869
1898
  if (!await this.applyAdvancedGeneration(pool, excludeSchemas, currentUser)) {
1870
1899
  logError('Error applying advanced generation features');
1871
1900
  // Don't fail the entire process - advanced generation is optional
1872
1901
  }
1873
- logStatus(` Applied advanced generation features in ${(new Date().getTime() - step7StartTime.getTime()) / 1000} seconds`);
1902
+ const step7Elapsed = ((new Date().getTime() - step7StartTime.getTime()) / 1000).toFixed(1);
1903
+ succeedSpinner(`Advanced generation completed (${step7Elapsed}s)`);
1874
1904
  }
1875
1905
  logStatus(` Total time to manage entity fields: ${(new Date().getTime() - startTime.getTime()) / 1000} seconds`);
1876
1906
  return bSuccess;
@@ -3296,6 +3326,8 @@ export class ManageMetadataBase {
3296
3326
  VALUES ('${appID}', '${appName}', 'Generated for schema', '${schemaName}', '${path}', 1)`;
3297
3327
  await this.LogSQLAndExecute(pool, sSQL, `SQL generated to create new application ${appName}`);
3298
3328
  LogStatus(`Created new application ${appName} with Path: ${path}`);
3329
+ // Auto-assign default roles to the new application
3330
+ await this.addDefaultRolesForApplication(pool, appID, appName);
3299
3331
  return appID;
3300
3332
  }
3301
3333
  catch (e) {
@@ -3303,6 +3335,29 @@ export class ManageMetadataBase {
3303
3335
  return null;
3304
3336
  }
3305
3337
  }
3338
+ /**
3339
+ * Adds default ApplicationRole records for a newly created application based on config settings.
3340
+ * This grants configured roles access to the new application automatically.
3341
+ */
3342
+ async addDefaultRolesForApplication(pool, appId, appName) {
3343
+ const defaults = configInfo.newSchemaDefaults.ApplicationRoleDefaults;
3344
+ if (!defaults?.AutoAddRolesForNewApplications) {
3345
+ return;
3346
+ }
3347
+ const md = new Metadata();
3348
+ for (const roleDef of defaults.Roles) {
3349
+ const role = md.Roles.find(r => r.Name.trim().toLowerCase() === roleDef.RoleName.trim().toLowerCase());
3350
+ if (role) {
3351
+ const sSQLInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'ApplicationRole')}
3352
+ (${this.qi('ApplicationID')}, ${this.qi('RoleID')}, ${this.qi('CanAccess')}, ${this.qi('CanAdmin')}) VALUES
3353
+ ('${appId}', '${role.ID}', ${roleDef.CanAccess ? 1 : 0}, ${roleDef.CanAdmin ? 1 : 0})`;
3354
+ await this.LogSQLAndExecute(pool, sSQLInsert, `Adding role ${roleDef.RoleName} to application ${appName}`);
3355
+ }
3356
+ else {
3357
+ LogError(`Unable to find Role '${roleDef.RoleName}' for application ${appName}`);
3358
+ }
3359
+ }
3360
+ }
3306
3361
  async applicationExists(pool, applicationName) {
3307
3362
  const sSQL = `SELECT ID FROM ${this.qs(mj_core_schema(), 'Application')} WHERE Name = '${applicationName}'`;
3308
3363
  const resultResult = await this.runQuery(pool, sSQL);
@@ -3390,6 +3445,7 @@ export class ManageMetadataBase {
3390
3445
  createNewEntityInsertSQL(newEntityUUID, newEntityName, newEntity, newEntitySuffix, newEntityDisplayName) {
3391
3446
  const newEntityDefaults = configInfo.newEntityDefaults;
3392
3447
  const newEntityDescriptionEscaped = newEntity.EntityDescription ? `'${newEntity.EntityDescription.replace(/'/g, "''")}'` : null;
3448
+ const allowCaching = this.resolveAllowCachingForSchema(newEntity.SchemaName);
3393
3449
  const q = (name) => this.qi(name);
3394
3450
  const sSQLInsert = `
3395
3451
  INSERT INTO ${this.qs(mj_core_schema(), 'Entity')} (
@@ -3402,7 +3458,8 @@ export class ManageMetadataBase {
3402
3458
  ${q('BaseView')},
3403
3459
  ${q('SchemaName')},
3404
3460
  ${q('IncludeInAPI')},
3405
- ${q('AllowUserSearchAPI')}
3461
+ ${q('AllowUserSearchAPI')},
3462
+ ${q('AllowCaching')}
3406
3463
  ${newEntityDefaults.TrackRecordChanges === undefined ? '' : ', ' + q('TrackRecordChanges')}
3407
3464
  ${newEntityDefaults.AuditRecordAccess === undefined ? '' : ', ' + q('AuditRecordAccess')}
3408
3465
  ${newEntityDefaults.AuditViewRuns === undefined ? '' : ', ' + q('AuditViewRuns')}
@@ -3424,7 +3481,8 @@ export class ManageMetadataBase {
3424
3481
  'vw${generatePluralName(newEntity.TableName, { capitalizeFirstLetterOnly: true }) + (newEntitySuffix && newEntitySuffix.length > 0 ? newEntitySuffix : '')}',
3425
3482
  '${newEntity.SchemaName}',
3426
3483
  1,
3427
- ${newEntityDefaults.AllowUserSearchAPI === undefined ? 1 : newEntityDefaults.AllowUserSearchAPI ? 1 : 0}
3484
+ ${newEntityDefaults.AllowUserSearchAPI === undefined ? 1 : newEntityDefaults.AllowUserSearchAPI ? 1 : 0},
3485
+ ${allowCaching ? 1 : 0}
3428
3486
  ${newEntityDefaults.TrackRecordChanges === undefined ? '' : ', ' + (newEntityDefaults.TrackRecordChanges ? '1' : '0')}
3429
3487
  ${newEntityDefaults.AuditRecordAccess === undefined ? '' : ', ' + (newEntityDefaults.AuditRecordAccess ? '1' : '0')}
3430
3488
  ${newEntityDefaults.AuditViewRuns === undefined ? '' : ', ' + (newEntityDefaults.AuditViewRuns ? '1' : '0')}
@@ -3439,6 +3497,26 @@ export class ManageMetadataBase {
3439
3497
  `;
3440
3498
  return sSQLInsert;
3441
3499
  }
3500
+ /**
3501
+ * Resolves the AllowCaching default for a new entity in the given schema.
3502
+ * AllowCachingBySchema entries override the global AllowCaching default. The
3503
+ * `${mj_core_schema}` placeholder is expanded so core-schema rules apply
3504
+ * regardless of how the core schema is named in this deployment.
3505
+ */
3506
+ resolveAllowCachingForSchema(schemaName) {
3507
+ const defaults = configInfo.newEntityDefaults;
3508
+ const overrides = defaults.AllowCachingBySchema ?? [];
3509
+ const match = overrides.find(entry => {
3510
+ let candidate = entry.SchemaName;
3511
+ if (candidate?.trim().toLowerCase() === '${mj_core_schema}') {
3512
+ candidate = mj_core_schema();
3513
+ }
3514
+ return candidate.trim().toLowerCase() === schemaName.trim().toLowerCase();
3515
+ });
3516
+ if (match)
3517
+ return match.AllowCaching;
3518
+ return defaults.AllowCaching ?? false;
3519
+ }
3442
3520
  /**
3443
3521
  * Apply Advanced Generation features - Smart Field Identification and Form Layout Generation
3444
3522
  */
@@ -3486,7 +3564,11 @@ export class ManageMetadataBase {
3486
3564
  e.Description,
3487
3565
  e.SchemaName,
3488
3566
  e.BaseTable,
3489
- e.ParentID
3567
+ e.ParentID,
3568
+ e.AllowUserSearchAPI,
3569
+ e.AutoUpdateAllowUserSearchAPI,
3570
+ e.FullTextSearchEnabled,
3571
+ e.AutoUpdateFullTextSearch
3490
3572
  FROM
3491
3573
  ${this.qs(mj_core_schema(), 'vwEntities')} e
3492
3574
  WHERE
@@ -3524,7 +3606,12 @@ export class ManageMetadataBase {
3524
3606
  ef.AllowUpdateAPI,
3525
3607
  ef.IsNameField,
3526
3608
  ef.DefaultInView,
3527
- ef.IncludeInUserSearchAPI
3609
+ ef.IncludeInUserSearchAPI,
3610
+ ef.UserSearchPredicateAPI,
3611
+ ef.AutoUpdateUserSearchPredicate,
3612
+ ef.FullTextSearchEnabled,
3613
+ ef.AutoUpdateFullTextSearch,
3614
+ ef.Length as MaxLength
3528
3615
  FROM
3529
3616
  ${this.qs(mj_core_schema(), 'vwEntityFields')} ef
3530
3617
  WHERE
@@ -3570,19 +3657,16 @@ export class ManageMetadataBase {
3570
3657
  }
3571
3658
  }
3572
3659
  /**
3573
- * Process entities in batches with parallel execution
3574
- * @param pool Database connection pool
3575
- * @param entities Entities to process
3576
- * @param allFields All fields for all entities (will be filtered per entity)
3577
- * @param ag AdvancedGeneration instance
3578
- * @param currentUser User context
3579
- * @param batchSize Number of entities to process in parallel (default 5)
3660
+ * Process entities in batches with parallel execution.
3661
+ * Batch size is configurable via advancedGeneration.batchSize in mj.config.cjs (default: 5).
3580
3662
  */
3581
- async processEntitiesBatched(pool, entities, allFields, ag, currentUser, batchSize = 5) {
3663
+ async processEntitiesBatched(pool, entities, allFields, ag, currentUser) {
3664
+ const batchSize = configInfo.advancedGeneration?.batchSize ?? 5;
3582
3665
  let processedCount = 0;
3583
3666
  let errorCount = 0;
3667
+ const total = entities.length;
3584
3668
  // Process in batches
3585
- for (let i = 0; i < entities.length; i += batchSize) {
3669
+ for (let i = 0; i < total; i += batchSize) {
3586
3670
  const batch = entities.slice(i, i + batchSize);
3587
3671
  // Process batch in parallel
3588
3672
  const batchResults = await Promise.allSettled(batch.map(entity => this.processEntityAdvancedGeneration(pool, entity, allFields, ag, currentUser)));
@@ -3596,9 +3680,9 @@ export class ManageMetadataBase {
3596
3680
  logError(` Error processing entity: ${result.reason}`);
3597
3681
  }
3598
3682
  }
3599
- logStatus(` Progress: ${processedCount}/${entities.length} entities processed`);
3683
+ const pct = Math.round((processedCount / total) * 100);
3684
+ updateSpinner(`Advanced generation: ${processedCount}/${total} entities (${pct}%)${errorCount > 0 ? ` — ${errorCount} error(s)` : ''}`);
3600
3685
  }
3601
- logStatus(` Advanced Generation complete: ${processedCount} entities processed, ${errorCount} errors`);
3602
3686
  return errorCount === 0;
3603
3687
  }
3604
3688
  /**
@@ -3616,15 +3700,26 @@ export class ManageMetadataBase {
3616
3700
  // Determine if this is a new entity (for DefaultForNewUser decision)
3617
3701
  const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
3618
3702
  // Smart Field Identification
3619
- // Only run if at least one field allows auto-update for any of the smart field properties
3620
- if (fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI)) {
3703
+ // Only run if at least one field allows auto-update for any of the smart field properties,
3704
+ // or if entity-level search/FTS auto-update flags are set
3705
+ const needsFieldAnalysis = fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
3706
+ // entity-level auto-update flags come from the raw SQL query, not EntityInfo
3707
+ const entityRecord = entity;
3708
+ const needsEntitySearchConfig = entityRecord.AutoUpdateAllowUserSearchAPI || entityRecord.AutoUpdateFullTextSearch;
3709
+ if (needsFieldAnalysis || needsEntitySearchConfig) {
3621
3710
  const fieldAnalysis = await ag.identifyFields({
3622
3711
  Name: entity.Name,
3623
3712
  Description: entity.Description,
3624
3713
  Fields: fields
3625
3714
  }, currentUser);
3626
3715
  if (fieldAnalysis) {
3627
- await this.applySmartFieldIdentification(pool, entity.ID, fields, fieldAnalysis);
3716
+ await this.applySmartFieldIdentification(pool, {
3717
+ ID: entity.ID,
3718
+ AllowUserSearchAPI: entityRecord.AllowUserSearchAPI,
3719
+ AutoUpdateAllowUserSearchAPI: entityRecord.AutoUpdateAllowUserSearchAPI,
3720
+ FullTextSearchEnabled: entityRecord.FullTextSearchEnabled,
3721
+ AutoUpdateFullTextSearch: entityRecord.AutoUpdateFullTextSearch,
3722
+ }, fields, fieldAnalysis);
3628
3723
  }
3629
3724
  }
3630
3725
  // Form Layout Generation
@@ -3713,14 +3808,36 @@ export class ManageMetadataBase {
3713
3808
  return null;
3714
3809
  }
3715
3810
  /**
3716
- * Apply smart field identification results to entity fields
3811
+ * Apply smart field identification results to entity fields and entity-level search configuration
3717
3812
  */
3718
- async applySmartFieldIdentification(pool, entityId, fields, result) {
3813
+ async applySmartFieldIdentification(pool, entity, fields, result) {
3719
3814
  const sqlStatements = [];
3720
- // Find the name field(s) — one or more fields that together form the display name
3815
+ this.applyNameFieldUpdates(sqlStatements, fields, result);
3816
+ this.applyDefaultInViewUpdates(sqlStatements, fields, result);
3817
+ this.applySearchableFieldUpdates(sqlStatements, fields, result);
3818
+ this.applySearchPredicateUpdates(sqlStatements, fields, result);
3819
+ this.applyEntitySearchConfig(sqlStatements, entity, result);
3820
+ if (configInfo.advancedGeneration?.allowFullTextSearchAutoUpdate) {
3821
+ this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result);
3822
+ }
3823
+ // Execute all updates in one batch
3824
+ if (sqlStatements.length > 0) {
3825
+ const combinedSQL = sqlStatements.join('\n');
3826
+ try {
3827
+ await this.LogSQLAndExecute(pool, combinedSQL, `Set field properties for entity`, false);
3828
+ }
3829
+ catch (ex) {
3830
+ logError('Error executing combined smart field SQL: ', ex);
3831
+ }
3832
+ }
3833
+ }
3834
+ /**
3835
+ * Generate SQL UPDATEs for IsNameField on the identified name fields
3836
+ */
3837
+ applyNameFieldUpdates(sqlStatements, fields, result) {
3721
3838
  const nameFieldNames = result.nameFields ?? [];
3722
3839
  for (const nfName of nameFieldNames) {
3723
- const nameField = fields.find((f) => f.Name === nfName);
3840
+ const nameField = fields.find(f => f.Name === nfName);
3724
3841
  if (nameField && nameField.AutoUpdateIsNameField && nameField.ID && !nameField.IsNameField) {
3725
3842
  sqlStatements.push(`
3726
3843
  UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
@@ -3733,17 +3850,18 @@ export class ManageMetadataBase {
3733
3850
  logError(`Smart field identification returned invalid nameField: '${nfName}' not found in entity fields`);
3734
3851
  }
3735
3852
  }
3736
- // Find all default in view fields (one or more)
3853
+ }
3854
+ /**
3855
+ * Generate SQL UPDATEs for DefaultInView on the identified default view fields
3856
+ */
3857
+ applyDefaultInViewUpdates(sqlStatements, fields, result) {
3737
3858
  const defaultInViewFields = fields.filter(f => result.defaultInView.includes(f.Name) && f.AutoUpdateDefaultInView && f.ID);
3738
- // Warn about any fields that weren't found
3739
3859
  const missingFields = result.defaultInView.filter(name => !fields.some(f => f.Name === name));
3740
3860
  if (missingFields.length > 0) {
3741
3861
  logError(`Smart field identification returned invalid defaultInView fields: ${missingFields.join(', ')} not found in entity`);
3742
3862
  }
3743
- // Build update statements for all default in view fields
3744
3863
  for (const field of defaultInViewFields) {
3745
3864
  if (!field.DefaultInView) {
3746
- // only set these when DefaultInView not already on, otherwise wasteful
3747
3865
  sqlStatements.push(`
3748
3866
  UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3749
3867
  SET DefaultInView = 1
@@ -3752,35 +3870,112 @@ export class ManageMetadataBase {
3752
3870
  `);
3753
3871
  }
3754
3872
  }
3755
- // Find all searchable fields (one or more) - for IncludeInUserSearchAPI
3756
- if (result.searchableFields && result.searchableFields.length > 0) {
3757
- const searchableFields = fields.filter(f => result.searchableFields.includes(f.Name) && f.AutoUpdateIncludeInUserSearchAPI && f.ID);
3758
- // Warn about any fields that weren't found
3759
- const missingSearchableFields = result.searchableFields.filter(name => !fields.some(f => f.Name === name));
3760
- if (missingSearchableFields.length > 0) {
3761
- logError(`Smart field identification returned invalid searchableFields: ${missingSearchableFields.join(', ')} not found in entity`);
3873
+ }
3874
+ /**
3875
+ * Generate SQL UPDATEs for IncludeInUserSearchAPI on the identified searchable fields
3876
+ */
3877
+ applySearchableFieldUpdates(sqlStatements, fields, result) {
3878
+ if (!result.searchableFields || result.searchableFields.length === 0) {
3879
+ return;
3880
+ }
3881
+ const searchableFields = fields.filter(f => result.searchableFields.includes(f.Name) && f.AutoUpdateIncludeInUserSearchAPI && f.ID);
3882
+ const missingSearchableFields = result.searchableFields.filter(name => !fields.some(f => f.Name === name));
3883
+ if (missingSearchableFields.length > 0) {
3884
+ logError(`Smart field identification returned invalid searchableFields: ${missingSearchableFields.join(', ')} not found in entity`);
3885
+ }
3886
+ for (const field of searchableFields) {
3887
+ if (!field.IncludeInUserSearchAPI) {
3888
+ sqlStatements.push(`
3889
+ UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3890
+ SET IncludeInUserSearchAPI = 1
3891
+ WHERE ID = '${field.ID}'
3892
+ AND AutoUpdateIncludeInUserSearchAPI = 1
3893
+ `);
3762
3894
  }
3763
- // Build update statements for all searchable fields
3764
- for (const field of searchableFields) {
3765
- if (!field.IncludeInUserSearchAPI) {
3766
- // only set this if IncludeInUserSearchAPI isn't already set
3767
- sqlStatements.push(`
3768
- UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3769
- SET IncludeInUserSearchAPI = 1
3770
- WHERE ID = '${field.ID}'
3771
- AND AutoUpdateIncludeInUserSearchAPI = 1
3772
- `);
3773
- }
3895
+ }
3896
+ }
3897
+ /**
3898
+ * Generate SQL UPDATEs for UserSearchPredicateAPI on each searchable field
3899
+ */
3900
+ applySearchPredicateUpdates(sqlStatements, fields, result) {
3901
+ if (!result.searchPredicates || result.searchPredicates.length === 0) {
3902
+ return;
3903
+ }
3904
+ for (const sp of result.searchPredicates) {
3905
+ const field = fields.find(f => f.Name === sp.field);
3906
+ if (!field) {
3907
+ logError(`Smart field identification returned invalid searchPredicate field: '${sp.field}' not found in entity fields`);
3908
+ continue;
3909
+ }
3910
+ if (!field.AutoUpdateUserSearchPredicate || !field.ID) {
3911
+ continue;
3912
+ }
3913
+ // Only update if the current value differs from the recommended predicate
3914
+ if (field.UserSearchPredicateAPI !== sp.predicate) {
3915
+ sqlStatements.push(`
3916
+ UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3917
+ SET UserSearchPredicateAPI = '${sp.predicate}'
3918
+ WHERE ID = '${field.ID}'
3919
+ AND AutoUpdateUserSearchPredicate = 1
3920
+ `);
3774
3921
  }
3775
3922
  }
3776
- // Execute all updates in one batch
3777
- if (sqlStatements.length > 0) {
3778
- const combinedSQL = sqlStatements.join('\n');
3779
- try {
3780
- await this.LogSQLAndExecute(pool, combinedSQL, `Set field properties for entity`, false);
3923
+ }
3924
+ /**
3925
+ * Generate SQL UPDATEs for entity-level AllowUserSearchAPI
3926
+ */
3927
+ applyEntitySearchConfig(sqlStatements, entity, result) {
3928
+ if (result.allowUserSearch == null || !entity.AutoUpdateAllowUserSearchAPI) {
3929
+ return;
3930
+ }
3931
+ const newValue = result.allowUserSearch ? 1 : 0;
3932
+ const currentValue = entity.AllowUserSearchAPI ? 1 : 0;
3933
+ if (newValue !== currentValue) {
3934
+ sqlStatements.push(`
3935
+ UPDATE ${this.qs(mj_core_schema(), 'Entity')}
3936
+ SET AllowUserSearchAPI = ${newValue}
3937
+ WHERE ID = '${entity.ID}'
3938
+ AND AutoUpdateAllowUserSearchAPI = 1
3939
+ `);
3940
+ }
3941
+ }
3942
+ /**
3943
+ * Generate SQL UPDATEs for entity-level and field-level FullTextSearch configuration
3944
+ */
3945
+ applyFullTextSearchUpdates(sqlStatements, entity, fields, result) {
3946
+ // Entity-level FullTextSearchEnabled
3947
+ if (result.enableFullTextSearch != null && entity.AutoUpdateFullTextSearch) {
3948
+ const newValue = result.enableFullTextSearch ? 1 : 0;
3949
+ const currentValue = entity.FullTextSearchEnabled ? 1 : 0;
3950
+ if (newValue !== currentValue) {
3951
+ sqlStatements.push(`
3952
+ UPDATE ${this.qs(mj_core_schema(), 'Entity')}
3953
+ SET FullTextSearchEnabled = ${newValue}
3954
+ WHERE ID = '${entity.ID}'
3955
+ AND AutoUpdateFullTextSearch = 1
3956
+ `);
3781
3957
  }
3782
- catch (ex) {
3783
- logError('Error executing combined smart field SQL: ', ex);
3958
+ }
3959
+ // Field-level FullTextSearchEnabled
3960
+ if (!result.fullTextSearchFields || result.fullTextSearchFields.length === 0) {
3961
+ return;
3962
+ }
3963
+ for (const ftsFieldName of result.fullTextSearchFields) {
3964
+ const field = fields.find(f => f.Name === ftsFieldName);
3965
+ if (!field) {
3966
+ logError(`Smart field identification returned invalid fullTextSearchField: '${ftsFieldName}' not found in entity fields`);
3967
+ continue;
3968
+ }
3969
+ if (!field.AutoUpdateFullTextSearch || !field.ID) {
3970
+ continue;
3971
+ }
3972
+ if (!field.FullTextSearchEnabled) {
3973
+ sqlStatements.push(`
3974
+ UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3975
+ SET FullTextSearchEnabled = 1
3976
+ WHERE ID = '${field.ID}'
3977
+ AND AutoUpdateFullTextSearch = 1
3978
+ `);
3784
3979
  }
3785
3980
  }
3786
3981
  }
@@ -3796,6 +3991,8 @@ export class ManageMetadataBase {
3796
3991
  async applyFormLayout(pool, entity, fields, result, isNewEntity = false) {
3797
3992
  const existingCategories = this.buildExistingCategorySet(fields);
3798
3993
  await this.applyFieldCategories(pool, entity, fields, result.fieldCategories, existingCategories);
3994
+ // Auto-detect geo-capable entities and set SupportsGeoCoding
3995
+ await this.detectAndSetGeoCodingSupport(pool, entity, result.fieldCategories);
3799
3996
  if (result.entityIcon) {
3800
3997
  await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
3801
3998
  }
@@ -3810,6 +4007,52 @@ export class ManageMetadataBase {
3810
4007
  await this.applyEntityImportance(pool, entity.ID, result.entityImportance);
3811
4008
  }
3812
4009
  }
4010
+ /**
4011
+ * Detect if an entity has geo-capable fields based on LLM-assigned ExtendedType values.
4012
+ * If any field has a Geo* ExtendedType, set Entity.SupportsGeoCoding = 1.
4013
+ * Respects the AutoUpdateSupportsGeoCoding flag — if 0, the value is locked.
4014
+ */
4015
+ async detectAndSetGeoCodingSupport(pool, entity, fieldCategories) {
4016
+ const schema = mj_core_schema();
4017
+ // Check if the entity's AutoUpdateSupportsGeoCoding flag allows us to modify it
4018
+ const autoUpdateResult = await pool.query(`
4019
+ SELECT ${this.qi('AutoUpdateSupportsGeoCoding')}, ${this.qi('SupportsGeoCoding')}
4020
+ FROM ${this.qs(schema, 'Entity')}
4021
+ WHERE ${this.qi('ID')} = '${entity.ID}'
4022
+ `);
4023
+ const row = autoUpdateResult?.recordset?.[0];
4024
+ if (!row || !row.AutoUpdateSupportsGeoCoding) {
4025
+ return; // Flag is locked, don't modify
4026
+ }
4027
+ // Detect geo-capable fields from the LLM results
4028
+ const geoExtendedTypes = new Set([
4029
+ 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
4030
+ 'GeoCity', 'GeoPostalCode', 'GeoAddress'
4031
+ ]);
4032
+ const hasGeoFields = fieldCategories.some(fc => fc.extendedType != null && geoExtendedTypes.has(fc.extendedType));
4033
+ // Also check existing fields in the database for Geo* ExtendedTypes
4034
+ // (in case the LLM didn't re-assign them this run but they were previously set)
4035
+ const existingGeoResult = await pool.query(`
4036
+ SELECT COUNT(*) AS ${this.qi('GeoFieldCount')}
4037
+ FROM ${this.qs(schema, 'EntityField')}
4038
+ WHERE ${this.qi('EntityID')} = '${entity.ID}'
4039
+ AND ${this.qi('ExtendedType')} IN ('Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince', 'GeoCity', 'GeoPostalCode', 'GeoAddress')
4040
+ `);
4041
+ const existingGeoCount = existingGeoResult?.recordset?.[0]?.GeoFieldCount ?? 0;
4042
+ const shouldSupportGeo = hasGeoFields || existingGeoCount > 0;
4043
+ const currentValue = row.SupportsGeoCoding ? true : false;
4044
+ if (shouldSupportGeo !== currentValue) {
4045
+ await this.LogSQLAndExecute(pool, `
4046
+ UPDATE ${this.qs(schema, 'Entity')}
4047
+ SET ${this.qi('SupportsGeoCoding')} = ${shouldSupportGeo ? 1 : 0}
4048
+ WHERE ${this.qi('ID')} = '${entity.ID}' AND ${this.qi('AutoUpdateSupportsGeoCoding')} = 1
4049
+ `, `Set SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} for ${entity.Name}`);
4050
+ logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from ${hasGeoFields ? 'LLM' : 'existing'} geo fields)`);
4051
+ // Queue for late-phase view regeneration — the view was already generated
4052
+ // before this flag was set, so it needs to be regenerated with the geo JOIN
4053
+ ManageMetadataBase.AddEntityRequiringViewRegen(entity.Name, 'Geocoding');
4054
+ }
4055
+ }
3813
4056
  // ─────────────────────────────────────────────────────────────────
3814
4057
  // Shared category / icon / settings persistence methods
3815
4058
  // Used by both the regular entity pipeline and VE decoration pipeline