@memberjunction/codegen-lib 6.1.3 → 6.2.0-edge.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.
- package/dist/Database/codeGenDatabaseProvider.d.ts +24 -0
- package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
- package/dist/Database/codeGenDatabaseProvider.js +30 -0
- package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
- package/dist/Database/manage-metadata.d.ts +108 -1
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +364 -30
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +18 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +46 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +9 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +14 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
- package/dist/Database/search-guardrails.d.ts +10 -0
- package/dist/Database/search-guardrails.d.ts.map +1 -1
- package/dist/Database/search-guardrails.js +20 -1
- package/dist/Database/search-guardrails.js.map +1 -1
- package/dist/Misc/graphql_server_codegen.d.ts +4 -0
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +5 -1
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/dist/Misc/sql_logging.d.ts +13 -2
- package/dist/Misc/sql_logging.d.ts.map +1 -1
- package/dist/Misc/sql_logging.js +44 -7
- package/dist/Misc/sql_logging.js.map +1 -1
- package/dist/Misc/sql_text.d.ts +18 -0
- package/dist/Misc/sql_text.d.ts.map +1 -0
- package/dist/Misc/sql_text.js +38 -0
- package/dist/Misc/sql_text.js.map +1 -0
- package/package.json +29 -29
|
@@ -19,7 +19,7 @@ import { applyIncludeSchemaScope } from "./schema-scope.js";
|
|
|
19
19
|
import { buildHealSchemaRoutineParams, getAuthoredExcludeSchemas, snapshotAuthoredExcludeSchemas } from "./heal-schema-params.js";
|
|
20
20
|
import { AdvancedGeneration, isPlausibleEntityName } from "../Misc/advanced_generation.js";
|
|
21
21
|
import { CodeGenReporter } from "../Misc/codegen-reporter.js";
|
|
22
|
-
import { applySearchableFieldsCap, entityLevelEnableBlockedReason, isNarrativeFieldName, normalizePredicate, normalizeSmartFieldResultShape, } from "./search-guardrails.js";
|
|
22
|
+
import { applySearchableFieldsCap, defaultPredicateFor, entityLevelEnableBlockedReason, isNarrativeFieldName, MAX_SEARCHABLE_FIELDS_PER_ENTITY, NAME_LIKE_FIELD_NAMES, normalizePredicate, normalizeSmartFieldResultShape, } from "./search-guardrails.js";
|
|
23
23
|
import { mapExternalNativeTypeToMJ } from "../Misc/externalTypeMapping.js";
|
|
24
24
|
import { SQLParser } from "@memberjunction/sql-parser";
|
|
25
25
|
import { createDisplayName, generatePluralName, MJGlobal, ResolveSingleEntityResourceTarget, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
|
|
@@ -27,6 +27,7 @@ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
|
|
|
27
27
|
import * as fs from 'fs';
|
|
28
28
|
import path from 'path';
|
|
29
29
|
import { canonicalJSONStringify, deepEqualJSON } from "../Misc/util.js";
|
|
30
|
+
import { trimTrailingStatementTerminators } from "../Misc/sql_text.js";
|
|
30
31
|
import { SQLLogging } from "../Misc/sql_logging.js";
|
|
31
32
|
import { computeFieldMetadataUpdate } from "./field-metadata-lock.js";
|
|
32
33
|
import { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
|
|
@@ -668,35 +669,47 @@ export class ManageMetadataBase {
|
|
|
668
669
|
async processOrganicKeyConfig(pool) {
|
|
669
670
|
const config = ManageMetadataBase.getSoftPKFKConfig();
|
|
670
671
|
if (!config)
|
|
671
|
-
return { success: true, createdCount: 0, updatedCount: 0 };
|
|
672
|
+
return { success: true, createdCount: 0, updatedCount: 0, failedCount: 0 };
|
|
672
673
|
const allOrganicKeys = this.extractOrganicKeysFromConfig(config);
|
|
673
674
|
if (allOrganicKeys.length === 0)
|
|
674
|
-
return { success: true, createdCount: 0, updatedCount: 0 };
|
|
675
|
+
return { success: true, createdCount: 0, updatedCount: 0, failedCount: 0 };
|
|
675
676
|
const schema = mj_core_schema();
|
|
676
677
|
let createdCount = 0;
|
|
677
678
|
let updatedCount = 0;
|
|
679
|
+
let failedCount = 0;
|
|
678
680
|
for (const tableConfig of allOrganicKeys) {
|
|
679
681
|
// Resolve the owning entity
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (ownerResult.recordset.length === 0) {
|
|
686
|
-
logError(` > Organic keys config: entity "${tableConfig.SchemaName}.${tableConfig.TableName}" not found — skipping`);
|
|
682
|
+
const owner = await this.findOrganicKeyEntity(pool, tableConfig.SchemaName, tableConfig.TableName);
|
|
683
|
+
if (!owner) {
|
|
684
|
+
// Every key declared on this table goes unapplied, so each one counts as a failure.
|
|
685
|
+
failedCount += tableConfig.OrganicKeys.length;
|
|
686
|
+
logError(` > Organic keys config: entity "${tableConfig.SchemaName}.${tableConfig.TableName}" not found — skipping its ${tableConfig.OrganicKeys.length} organic key(s)`);
|
|
687
687
|
continue;
|
|
688
688
|
}
|
|
689
|
-
const ownerEntityId =
|
|
690
|
-
const ownerEntityName =
|
|
689
|
+
const ownerEntityId = owner.ID;
|
|
690
|
+
const ownerEntityName = owner.Name;
|
|
691
691
|
for (const okConfig of tableConfig.OrganicKeys) {
|
|
692
692
|
try {
|
|
693
|
+
// Resolve every related entity BEFORE writing anything for this key. Step 1 creates
|
|
694
|
+
// bridge views (in the database and the migration log) and step 2 records the key, so
|
|
695
|
+
// discovering a missing related entity in step 3 would leave an orphan view and a key
|
|
696
|
+
// with a mapping missing.
|
|
697
|
+
const relatedEntities = await this.resolveOrganicKeyRelatedEntities(pool, okConfig);
|
|
698
|
+
if (!relatedEntities) {
|
|
699
|
+
failedCount++;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
693
702
|
// Step 1: Create transitive views if defined
|
|
694
703
|
for (const re of okConfig.RelatedEntities) {
|
|
695
704
|
if (re.TransitiveView) {
|
|
696
705
|
const viewSchema = re.TransitiveView.SchemaName || re.SchemaName;
|
|
697
706
|
const viewFullName = `${viewSchema}.${re.TransitiveView.Name}`;
|
|
698
|
-
const viewSQL =
|
|
699
|
-
|
|
707
|
+
const viewSQL = this.dbProvider.generateCreateOrReplaceViewSQL(viewSchema, re.TransitiveView.Name, re.TransitiveView.SQL);
|
|
708
|
+
// T-SQL requires CREATE [OR ALTER] VIEW to be the ONLY statement in its batch — first as
|
|
709
|
+
// well as last — and the metadata DML logged just before it (entity-config UPDATEs, the
|
|
710
|
+
// previous key's INSERTs) has no trailing GO. requiresOwnBatch puts the provider's
|
|
711
|
+
// separator on both sides in the migration file ('' on PostgreSQL: nothing is added).
|
|
712
|
+
await this.LogSQLAndExecute(pool, viewSQL, `Create transitive bridge view ${viewFullName} for organic key "${okConfig.Name}" on ${ownerEntityName}`, false, true, this.dbProvider.BatchSeparator, true);
|
|
700
713
|
// Auto-populate TransitiveObject from the view definition
|
|
701
714
|
re.TransitiveObject = viewFullName;
|
|
702
715
|
}
|
|
@@ -734,18 +747,9 @@ export class ManageMetadataBase {
|
|
|
734
747
|
organicKeyId = newKey.recordset[0].ID;
|
|
735
748
|
}
|
|
736
749
|
// Step 3: Upsert EntityOrganicKeyRelatedEntity for each related entity
|
|
737
|
-
for (const reConfig of
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
|
|
741
|
-
OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
|
|
742
|
-
`, { 'TableName': reConfig.TableName, 'SchemaName': reConfig.SchemaName });
|
|
743
|
-
if (relResult.recordset.length === 0) {
|
|
744
|
-
logError(` > Organic key "${okConfig.Name}": related entity "${reConfig.SchemaName}.${reConfig.TableName}" not found — skipping`);
|
|
745
|
-
continue;
|
|
746
|
-
}
|
|
747
|
-
const relEntityId = relResult.recordset[0].ID;
|
|
748
|
-
const relEntityName = relResult.recordset[0].Name;
|
|
750
|
+
for (const { config: reConfig, entity: relEntity } of relatedEntities) {
|
|
751
|
+
const relEntityId = relEntity.ID;
|
|
752
|
+
const relEntityName = relEntity.Name;
|
|
749
753
|
// Check if this related entity mapping already exists
|
|
750
754
|
const existingRel = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(schema, 'EntityOrganicKeyRelatedEntity')} WHERE EntityOrganicKeyID = @KeyID AND RelatedEntityID = @RelEntityID`, { 'KeyID': organicKeyId, 'RelEntityID': relEntityId });
|
|
751
755
|
// Build field values
|
|
@@ -791,12 +795,49 @@ export class ManageMetadataBase {
|
|
|
791
795
|
}
|
|
792
796
|
}
|
|
793
797
|
catch (err) {
|
|
798
|
+
// Keep going so one bad key doesn't block the others — but count it: a key that failed
|
|
799
|
+
// here (bridge-view DDL the database rejected, a refused view drop, …) is missing from
|
|
800
|
+
// the database, and the run must not report success for it.
|
|
801
|
+
failedCount++;
|
|
794
802
|
const errMessage = err instanceof Error ? err.message : String(err);
|
|
795
803
|
logError(` > Organic key config: Failed to process "${okConfig.Name}" on ${ownerEntityName}: ${errMessage}`);
|
|
796
804
|
}
|
|
797
805
|
}
|
|
798
806
|
}
|
|
799
|
-
return { success:
|
|
807
|
+
return { success: failedCount === 0, createdCount, updatedCount, failedCount };
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Finds the entity an organic-key config names: by base table + schema, else by entity name.
|
|
811
|
+
* Returns `null` when neither matches.
|
|
812
|
+
*/
|
|
813
|
+
async findOrganicKeyEntity(pool, schemaName, tableName) {
|
|
814
|
+
const result = await this.runQueryWithParams(pool, `
|
|
815
|
+
${this.selectTop(1, 'ID, Name', `FROM ${this.qs(mj_core_schema(), 'vwEntities')}
|
|
816
|
+
WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
|
|
817
|
+
OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
|
|
818
|
+
`, { 'TableName': tableName, 'SchemaName': schemaName });
|
|
819
|
+
const row = result.recordset[0];
|
|
820
|
+
return row ? { ID: row.ID, Name: row.Name } : null;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Resolves every related entity of an organic key, in config order. Logs each one that doesn't
|
|
824
|
+
* resolve and returns `null` if any is missing, so the caller can skip the key before writing any
|
|
825
|
+
* of it.
|
|
826
|
+
*/
|
|
827
|
+
async resolveOrganicKeyRelatedEntities(pool, okConfig) {
|
|
828
|
+
const resolved = [];
|
|
829
|
+
let missing = 0;
|
|
830
|
+
for (const reConfig of okConfig.RelatedEntities) {
|
|
831
|
+
const entity = await this.findOrganicKeyEntity(pool, reConfig.SchemaName, reConfig.TableName);
|
|
832
|
+
if (entity) {
|
|
833
|
+
resolved.push({ config: reConfig, entity });
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
missing++;
|
|
837
|
+
logError(` > Organic key "${okConfig.Name}": related entity "${reConfig.SchemaName}.${reConfig.TableName}" not found — the key is not applied`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return missing === 0 ? resolved : null;
|
|
800
841
|
}
|
|
801
842
|
/**
|
|
802
843
|
* Builds the entity lookup behind {@link processISARelationshipConfig}: match on Name first, else
|
|
@@ -2062,6 +2103,10 @@ export class ManageMetadataBase {
|
|
|
2062
2103
|
if (organicKeyResult.createdCount > 0 || organicKeyResult.updatedCount > 0) {
|
|
2063
2104
|
logStatus(` > Organic keys: ${organicKeyResult.createdCount} created, ${organicKeyResult.updatedCount} updated from config`);
|
|
2064
2105
|
}
|
|
2106
|
+
if (!organicKeyResult.success) {
|
|
2107
|
+
logError(` Error processing organic keys: ${organicKeyResult.failedCount} key(s) failed and were not applied — see the errors above`);
|
|
2108
|
+
bSuccess = false;
|
|
2109
|
+
}
|
|
2065
2110
|
// Config-driven base-view materialization — emit the physical table + wrapper view and
|
|
2066
2111
|
// attach an "MJ: Materialized Results" row to the existing entity. Runs AFTER entity fields
|
|
2067
2112
|
// are managed so the snapshot shape reflects the current base view (plan §4.1).
|
|
@@ -3657,6 +3702,22 @@ export class ManageMetadataBase {
|
|
|
3657
3702
|
const step7Elapsed = ((new Date().getTime() - step7StartTime.getTime()) / 1000).toFixed(1);
|
|
3658
3703
|
succeedSpinner(`Advanced generation completed (${step7Elapsed}s)`);
|
|
3659
3704
|
}
|
|
3705
|
+
// Deterministic search-flag hygiene. Deliberately OUTSIDE the `skipAdvancedGeneration`
|
|
3706
|
+
// guard above: advanced generation is off by default, and an entity created under that
|
|
3707
|
+
// default is precisely the one that ends up flagged searchable with nothing searchable on
|
|
3708
|
+
// it. Ordered after it so the model's choices, where it did run, are already applied and
|
|
3709
|
+
// the seed only fills a genuine gap. See buildSearchFlagHygieneSQL.
|
|
3710
|
+
if (!await this.applySearchFlagHygiene(pool, excludeSchemas)) {
|
|
3711
|
+
logError('Error applying search flag hygiene');
|
|
3712
|
+
// FATAL, unlike advanced generation. This pass compares before it writes, so a failure
|
|
3713
|
+
// means the probes could not run — and because nothing is written in that case, the pass
|
|
3714
|
+
// failing is indistinguishable from the pass having nothing to do: no SQL, no artifact,
|
|
3715
|
+
// every gate green. Advanced generation can degrade quietly because it only ever ADDS
|
|
3716
|
+
// model-suggested metadata; this one turns search OFF on entities and is the only thing
|
|
3717
|
+
// keeping AllowUserSearchAPI honest, so a run where it did not execute must not be
|
|
3718
|
+
// reported as a clean run.
|
|
3719
|
+
bSuccess = false;
|
|
3720
|
+
}
|
|
3660
3721
|
logStatus(` Total time to manage entity fields: ${(new Date().getTime() - startTime.getTime()) / 1000} seconds`);
|
|
3661
3722
|
return bSuccess;
|
|
3662
3723
|
}
|
|
@@ -6643,6 +6704,279 @@ export class ManageMetadataBase {
|
|
|
6643
6704
|
}
|
|
6644
6705
|
}
|
|
6645
6706
|
}
|
|
6707
|
+
/**
|
|
6708
|
+
* Build the two deterministic search-flag hygiene statements.
|
|
6709
|
+
*
|
|
6710
|
+
* These exist because the LLM-driven smart-field pipeline cannot be relied on to run at all:
|
|
6711
|
+
* `AdvancedGeneration.enabled` reads `enableAdvancedGeneration ?? false`, so on a default
|
|
6712
|
+
* configuration none of `applySearchableFieldUpdates` / `applyEntitySearchConfig` ever
|
|
6713
|
+
* executes — while every new entity is still INSERTed with `AllowUserSearchAPI = 1`
|
|
6714
|
+
* (see `createNewEntityInsertSQL`). The result is an entity flagged searchable with nothing
|
|
6715
|
+
* searchable on it, which is not a harmless default: `UserSearchString` against such an entity
|
|
6716
|
+
* is a documented no-op (MJ#4581/#4582), so the data provider ignores the term and returns the
|
|
6717
|
+
* UNFILTERED table. Global search then fans out to it on every keystroke and discards every row.
|
|
6718
|
+
*
|
|
6719
|
+
* Both statements are deterministic, need no model, and are safe to run on every pass:
|
|
6720
|
+
*
|
|
6721
|
+
* 1. **Seed** — an entity with NO searchable field gets its name-like columns flagged
|
|
6722
|
+
* (`NAME_LIKE_FIELD_NAMES`: Name, Title, FirstName, LastName, …). Those are what a person
|
|
6723
|
+
* types into a search box, so they are the one defensible default. Only fires when the
|
|
6724
|
+
* entity has nothing flagged at all, so it fills a gap rather than overriding anyone —
|
|
6725
|
+
* including the LLM, which has already run by this point when it is enabled.
|
|
6726
|
+
*
|
|
6727
|
+
* Note this keys on the field's NAME, not on `IsNameField`. That flag looks like the
|
|
6728
|
+
* obvious source and is the wrong one: measured against a real database, seeding from it
|
|
6729
|
+
* would flag 54 fields of which 41 are VIRTUAL — the denormalized FK display columns
|
|
6730
|
+
* CodeGen puts on views (`Action`, `Agent`, `Artifact`). Those are computed by JOIN, so a
|
|
6731
|
+
* LIKE against them cannot seek any index, and flagging them would push 49 junction
|
|
6732
|
+
* entities into the global-search fan-out with unindexable predicates — the exact cost
|
|
6733
|
+
* `search-guardrails.ts` exists to prevent. It also picked up identifiers (`RecordID`,
|
|
6734
|
+
* `Token`, `ExternalSystemRecordID`) and a `Description`, which `isNarrativeFieldName`
|
|
6735
|
+
* rejects on the LLM path. And it would still not have fixed `MJ: Employees`, whose only
|
|
6736
|
+
* `IsNameField` is the virtual `FirstLast`; keying on the name reaches its real
|
|
6737
|
+
* `FirstName` / `LastName` columns, which is what the reported bug needed.
|
|
6738
|
+
* 2. **Clear** — an entity STILL left with no searchable field has `AllowUserSearchAPI` turned
|
|
6739
|
+
* off, so it drops out of the search fan-out instead of contributing noise.
|
|
6740
|
+
*
|
|
6741
|
+
* Both honor the `AutoUpdate*` opt-outs, which is how an operator pins a hand-made decision —
|
|
6742
|
+
* and is exactly how the curated entries in `metadata/entities/.entity-search-exclusions.json`
|
|
6743
|
+
* protect themselves (they set `AutoUpdateAllowUserSearchAPI` to false alongside the flag).
|
|
6744
|
+
*
|
|
6745
|
+
* Full-text-search entities are exempt from both. An FTS entity is searchable through its
|
|
6746
|
+
* INDEX: `createViewUserSearchSQL` takes the full-text branch before it ever reads
|
|
6747
|
+
* `IncludeInUserSearchAPI`, so those flags are dead metadata there and the entity is a
|
|
6748
|
+
* perfectly valid search target without them.
|
|
6749
|
+
*
|
|
6750
|
+
* Written as `UPDATE ... WHERE <key> IN (subquery)` rather than `UPDATE ... FROM ... JOIN`,
|
|
6751
|
+
* which is T-SQL-only. The subquery form is ANSI and runs unchanged on both platforms.
|
|
6752
|
+
*/
|
|
6753
|
+
buildSearchFlagHygieneSQL(excludeSchemas) {
|
|
6754
|
+
const coreSchema = mj_core_schema();
|
|
6755
|
+
const entity = this.qs(coreSchema, 'Entity');
|
|
6756
|
+
const entityField = this.qs(coreSchema, 'EntityField');
|
|
6757
|
+
const yes = this.boolLit(true);
|
|
6758
|
+
const no = this.boolLit(false);
|
|
6759
|
+
// Same set the LLM path's isNameLikeFieldName() tests, lowered for a case-insensitive
|
|
6760
|
+
// comparison that does not depend on the database collation.
|
|
6761
|
+
const nameLikeList = NAME_LIKE_FIELD_NAMES.map(n => `'${n.toLowerCase()}'`).join(',');
|
|
6762
|
+
// Every name in NAME_LIKE_FIELD_NAMES resolves to the same predicate, so one literal covers
|
|
6763
|
+
// the whole seed. This MUST be set explicitly: `EntityField.UserSearchPredicateAPI` defaults
|
|
6764
|
+
// to 'Contains' in the database, which is `LIKE '%term%'` — the unindexable scan the
|
|
6765
|
+
// guardrails exist to prevent. Seeding the flag without the predicate would have made every
|
|
6766
|
+
// seeded entity a full scan on every keystroke.
|
|
6767
|
+
const seedPredicate = defaultPredicateFor(NAME_LIKE_FIELD_NAMES[0]);
|
|
6768
|
+
// The entity-shape guardrails the LLM path applies (`entityLevelEnableBlockedReason`).
|
|
6769
|
+
// A log / audit / run-history table grows without bound and a detail / line-item child is
|
|
6770
|
+
// reached through its parent; neither is a global-search target, whichever columns it has.
|
|
6771
|
+
// Expressed as SQL rather than reusing the regex helpers because this runs in the database.
|
|
6772
|
+
const shapeSuffixes = [
|
|
6773
|
+
'Logs', 'Log', 'Runs', 'Run', 'Run History', 'Run Steps', 'Run Messages', 'Execution Logs',
|
|
6774
|
+
'Details', 'Detail', 'Lines', 'Line', 'Items', 'Item', 'Steps', 'Step',
|
|
6775
|
+
'Params', 'Param', 'Mappings', 'Mapping',
|
|
6776
|
+
];
|
|
6777
|
+
// WORD boundaries, not raw suffixes. `LIKE '%Lines'` is a case-insensitive endsWith under
|
|
6778
|
+
// the default collation, so it matched `Pipelines`, `Guidelines`, `Timelines`, `Airlines`,
|
|
6779
|
+
// `Deadlines` and `Baselines`; `LIKE '%Logs'` matched `Catalogs`, `Dialogs` and `Blogs`;
|
|
6780
|
+
// `LIKE '%Audit%'` matched `Auditors`. MJ's own metadata was not exempt — `MJ: ML Training
|
|
6781
|
+
// Pipelines` was caught by `'%Lines'`. Those entities were then excluded from the seed and
|
|
6782
|
+
// swept up by the clear (which carries no shape filter, deliberately — see below), so a
|
|
6783
|
+
// guardrail meaning "do not bother seeding these" silently turned search OFF on them.
|
|
6784
|
+
//
|
|
6785
|
+
// Matching the final WORD keeps every intended shape — `Order Details`, `Audit Logs`,
|
|
6786
|
+
// `MJ: AI Agent Runs` — while `Catalogs`, `Pipelines` and `Auditors` fall through to the
|
|
6787
|
+
// seed as ordinary entities.
|
|
6788
|
+
const nameEndsWithWord = (word) => `(e.${this.qi('Name')} = '${word}' OR e.${this.qi('Name')} LIKE '% ${word}')`;
|
|
6789
|
+
const nameContainsWord = (word) => `(e.${this.qi('Name')} = '${word}'`
|
|
6790
|
+
+ ` OR e.${this.qi('Name')} LIKE '${word} %'`
|
|
6791
|
+
+ ` OR e.${this.qi('Name')} LIKE '% ${word}'`
|
|
6792
|
+
+ ` OR e.${this.qi('Name')} LIKE '% ${word} %')`;
|
|
6793
|
+
const shapeClauses = shapeSuffixes
|
|
6794
|
+
.map(sfx => nameEndsWithWord(sfx))
|
|
6795
|
+
.concat([nameContainsWord('Audit'), nameContainsWord('Record Change')])
|
|
6796
|
+
.join(' OR ');
|
|
6797
|
+
const entityShapeFilter = `AND NOT (${shapeClauses})`;
|
|
6798
|
+
// Schema names are configuration, not literals we control: double any apostrophe rather
|
|
6799
|
+
// than interpolating it straight into the statement.
|
|
6800
|
+
const schemaFilter = excludeSchemas.length > 0
|
|
6801
|
+
? `AND e.${this.qi('SchemaName')} NOT IN (${excludeSchemas.map(sc => `'${sc.replace(/'/g, "''")}'`).join(',')})`
|
|
6802
|
+
: '';
|
|
6803
|
+
// "This entity has nothing a user search can match." Mirrors
|
|
6804
|
+
// `EntityInfo.HasSearchFields` and the runtime screen in EntitySearchProvider.
|
|
6805
|
+
const noSearchableField = `NOT EXISTS (
|
|
6806
|
+
SELECT 1 FROM ${entityField} f2
|
|
6807
|
+
WHERE f2.${this.qi('EntityID')} = e.${this.qi('ID')}
|
|
6808
|
+
AND f2.${this.qi('IncludeInUserSearchAPI')} = ${yes}
|
|
6809
|
+
)`;
|
|
6810
|
+
// An FTS entity is searchable through its index, with no per-field flags involved.
|
|
6811
|
+
const notFullText = `AND ${this.coalesce(`e.${this.qi('FullTextSearchEnabled')}`, no)} = ${no}`;
|
|
6812
|
+
// The eligibility predicate here mirrors `isFieldEligibleForUserSearch` (and the runtime
|
|
6813
|
+
// `isTextSearchableType` it was written against): not the primary key, a bounded text
|
|
6814
|
+
// column. A name field that is neither is left alone rather than flagged uselessly.
|
|
6815
|
+
// The candidate SELECTs are built once and used twice: once as the UPDATE's subquery, once
|
|
6816
|
+
// as the probe `applySearchFlagHygiene` runs to decide whether to emit the UPDATE at all.
|
|
6817
|
+
// Sharing the text is the point — a probe whose predicates could drift from the statement
|
|
6818
|
+
// it guards would either suppress a needed write or emit a no-op one.
|
|
6819
|
+
const seedCandidateSQL = `
|
|
6820
|
+
SELECT ranked.${this.qi('ID')} FROM (
|
|
6821
|
+
SELECT f.${this.qi('ID')},
|
|
6822
|
+
ROW_NUMBER() OVER (
|
|
6823
|
+
PARTITION BY f.${this.qi('EntityID')}
|
|
6824
|
+
ORDER BY f.${this.qi('Sequence')}, f.${this.qi('Name')}
|
|
6825
|
+
) AS rn
|
|
6826
|
+
FROM ${entityField} f
|
|
6827
|
+
INNER JOIN ${entity} e ON e.${this.qi('ID')} = f.${this.qi('EntityID')}
|
|
6828
|
+
WHERE LOWER(f.${this.qi('Name')}) IN (${nameLikeList})
|
|
6829
|
+
AND f.${this.qi('AutoUpdateIncludeInUserSearchAPI')} = ${yes}
|
|
6830
|
+
AND f.${this.qi('IncludeInUserSearchAPI')} = ${no}
|
|
6831
|
+
AND ${this.coalesce(`f.${this.qi('IsPrimaryKey')}`, no)} = ${no}
|
|
6832
|
+
AND ${this.coalesce(`f.${this.qi('IsVirtual')}`, no)} = ${no}
|
|
6833
|
+
AND LOWER(f.${this.qi('Type')}) IN ('nvarchar','varchar','char','nchar')
|
|
6834
|
+
AND ${this.coalesce(`f.${this.qi('Length')}`, '0')} <> -1
|
|
6835
|
+
AND e.${this.qi('VirtualEntity')} = ${no}
|
|
6836
|
+
AND e.${this.qi('AllowUserSearchAPI')} = ${yes}
|
|
6837
|
+
${notFullText}
|
|
6838
|
+
${entityShapeFilter}
|
|
6839
|
+
${schemaFilter}
|
|
6840
|
+
AND ${noSearchableField}
|
|
6841
|
+
) ranked
|
|
6842
|
+
WHERE ranked.rn <= ${MAX_SEARCHABLE_FIELDS_PER_ENTITY}`;
|
|
6843
|
+
// The clear carries NO entity-shape filter, and that is deliberate rather than an
|
|
6844
|
+
// oversight: a log / run / detail table is exactly what should drop out of the search
|
|
6845
|
+
// fan-out, so the shapes the seed refuses to touch are meant to fall through to here.
|
|
6846
|
+
//
|
|
6847
|
+
// That composition is load-bearing and worth stating, because it means the shape list
|
|
6848
|
+
// above does not merely withhold help — it DECIDES which entities get search turned off.
|
|
6849
|
+
// A name wrongly matched there is not "left alone", it is disabled. Which is why the
|
|
6850
|
+
// matching is word-boundary (see nameEndsWithWord) and why widening that list is a
|
|
6851
|
+
// destructive change, not a conservative one.
|
|
6852
|
+
const clearWhere = `e.${this.qi('AllowUserSearchAPI')} = ${yes}
|
|
6853
|
+
AND e.${this.qi('AutoUpdateAllowUserSearchAPI')} = ${yes}
|
|
6854
|
+
AND e.${this.qi('VirtualEntity')} = ${no}
|
|
6855
|
+
${notFullText}
|
|
6856
|
+
${schemaFilter}
|
|
6857
|
+
AND ${noSearchableField}`;
|
|
6858
|
+
const clearCandidateSQL = `
|
|
6859
|
+
SELECT e.${this.qi('ID')}
|
|
6860
|
+
FROM ${entity} e
|
|
6861
|
+
WHERE ${clearWhere}`;
|
|
6862
|
+
const seedSQL = `
|
|
6863
|
+
UPDATE ${entityField}
|
|
6864
|
+
SET ${this.qi('IncludeInUserSearchAPI')} = ${yes},
|
|
6865
|
+
${this.qi('UserSearchPredicateAPI')} = '${seedPredicate}'
|
|
6866
|
+
WHERE ${this.qi('ID')} IN (${seedCandidateSQL}
|
|
6867
|
+
)`;
|
|
6868
|
+
const clearSQL = `
|
|
6869
|
+
UPDATE ${entity}
|
|
6870
|
+
SET ${this.qi('AllowUserSearchAPI')} = ${no}
|
|
6871
|
+
WHERE ${this.qi('ID')} IN (${clearCandidateSQL}
|
|
6872
|
+
)`;
|
|
6873
|
+
// Derived-table alias is required by T-SQL and accepted by PostgreSQL, so one form serves
|
|
6874
|
+
// both. COUNT(*) rather than EXISTS because the count also feeds the status line.
|
|
6875
|
+
const seedProbeSQL = `SELECT COUNT(*) AS ${this.qi('Cnt')} FROM (${seedCandidateSQL}
|
|
6876
|
+
) probe`;
|
|
6877
|
+
// The clear probe returns NAMES, not a count. Disabling search is the destructive half of
|
|
6878
|
+
// this pass and it is effectively one-way: getting it back means flagging a field by hand
|
|
6879
|
+
// AND pinning AutoUpdateAllowUserSearchAPI = 0, or the next run undoes the repair. An
|
|
6880
|
+
// operator told "cleared 14 entities" has no way to learn which 14 without going to the
|
|
6881
|
+
// database; naming them in the run output is the difference between an auditable change
|
|
6882
|
+
// and a silent one. Shares `clearWhere` with the UPDATE so the two cannot disagree.
|
|
6883
|
+
const clearProbeSQL = `
|
|
6884
|
+
SELECT e.${this.qi('Name')} AS ${this.qi('Name')}
|
|
6885
|
+
FROM ${entity} e
|
|
6886
|
+
WHERE ${clearWhere}
|
|
6887
|
+
ORDER BY e.${this.qi('Name')}`;
|
|
6888
|
+
return { seedSQL, clearSQL, seedProbeSQL, clearProbeSQL };
|
|
6889
|
+
}
|
|
6890
|
+
/**
|
|
6891
|
+
* Run the deterministic search-flag hygiene pass (see {@link buildSearchFlagHygieneSQL}).
|
|
6892
|
+
*
|
|
6893
|
+
* Runs OUTSIDE `applyAdvancedGeneration` and therefore regardless of whether advanced
|
|
6894
|
+
* generation is enabled — which is the whole point, since it is off by default and an entity
|
|
6895
|
+
* created under that default is exactly the one that needs this. Ordered after it so the
|
|
6896
|
+
* model's choices, when it did run, are already in place and the seed only fills a real gap.
|
|
6897
|
+
*
|
|
6898
|
+
* COMPARE FIRST, then write — the T20 contract every-run config writers are held to (see
|
|
6899
|
+
* `__tests__/idempotency/config-writers-compare-first.test.ts`). The two UPDATEs converge on
|
|
6900
|
+
* their own, because the first pass makes `noSearchableField` false for every row it touches,
|
|
6901
|
+
* so re-running them changes nothing. That is not sufficient: `LogSQLBatchAndExecute` writes
|
|
6902
|
+
* whatever it is handed into the run's `CodeGen_Run_*.sql` capture whether or not a row moves,
|
|
6903
|
+
* and the drift gate's warm-twice stage fails on ANY capture surviving a second run. Emitting
|
|
6904
|
+
* unconditionally therefore reddened the gate on every PR while the data was perfectly
|
|
6905
|
+
* idempotent — the data converged, the log did not. So each statement is emitted only when its
|
|
6906
|
+
* own candidate probe finds work.
|
|
6907
|
+
*/
|
|
6908
|
+
async applySearchFlagHygiene(pool, excludeSchemas) {
|
|
6909
|
+
try {
|
|
6910
|
+
const { seedSQL, clearSQL, seedProbeSQL, clearProbeSQL } = this.buildSearchFlagHygieneSQL(excludeSchemas);
|
|
6911
|
+
// Order matters: seeding first means an entity whose name field was just flagged is no
|
|
6912
|
+
// longer a candidate for having its AllowUserSearchAPI cleared. Reversed, the pass would
|
|
6913
|
+
// disable search on an entity it was about to make searchable.
|
|
6914
|
+
//
|
|
6915
|
+
// 4th arg is `isRecurringScript`, NOT a throw flag — passing `false` here is the default
|
|
6916
|
+
// and is spelled out only to make the intent explicit. Errors are caught below.
|
|
6917
|
+
const seedCount = await this.searchFlagHygieneCandidateCount(pool, seedProbeSQL);
|
|
6918
|
+
if (seedCount > 0) {
|
|
6919
|
+
logStatus(` Search-flag hygiene: seeding ${seedCount} name field(s)`);
|
|
6920
|
+
await this.LogSQLBatchAndExecute(pool, [seedSQL], 'Deterministic search-flag hygiene — seed name fields', false);
|
|
6921
|
+
}
|
|
6922
|
+
// Probed AFTER the seed has run, not alongside it. Seeding changes which entities still
|
|
6923
|
+
// have nothing searchable, so a clear probe taken before it would count entities the seed
|
|
6924
|
+
// was about to repair and emit a statement that then matched nothing — reintroducing the
|
|
6925
|
+
// stray capture file this is here to avoid.
|
|
6926
|
+
const clearNames = await this.searchFlagHygieneClearCandidates(pool, clearProbeSQL);
|
|
6927
|
+
if (clearNames.length > 0) {
|
|
6928
|
+
// NAME them. This is the destructive half and it is effectively one-way for an
|
|
6929
|
+
// operator who does not know it happened.
|
|
6930
|
+
const shown = clearNames.slice(0, 25).join(', ');
|
|
6931
|
+
const more = clearNames.length > 25 ? `, ... and ${clearNames.length - 25} more` : '';
|
|
6932
|
+
logStatus(` Search-flag hygiene: turning AllowUserSearchAPI OFF on ${clearNames.length} entity(ies): ${shown}${more}`);
|
|
6933
|
+
await this.LogSQLBatchAndExecute(pool, [clearSQL], 'Deterministic search-flag hygiene — clear AllowUserSearchAPI', false);
|
|
6934
|
+
}
|
|
6935
|
+
return true;
|
|
6936
|
+
}
|
|
6937
|
+
catch (ex) {
|
|
6938
|
+
// A probe that cannot run is NOT the same as "nothing to do", and must not be allowed to
|
|
6939
|
+
// look like it. Before this pass compared first, a malformed statement was appended to
|
|
6940
|
+
// the CodeGen_Run capture by SQLLogging BEFORE it was executed, so a broken pass left a
|
|
6941
|
+
// surviving artifact and reddened the drift gate. Probing first removes that signal: the
|
|
6942
|
+
// throw now happens before anything is written, so without a loud failure here the pass
|
|
6943
|
+
// would silently do nothing while every gate stayed green — the exact silent no-op this
|
|
6944
|
+
// whole change exists to eliminate, one level up in the fixer. The caller fails the run.
|
|
6945
|
+
logError('Search-flag hygiene FAILED — search flags were NOT reconciled on this run', ex);
|
|
6946
|
+
return false;
|
|
6947
|
+
}
|
|
6948
|
+
}
|
|
6949
|
+
/**
|
|
6950
|
+
* Row count for one of the search-flag hygiene probes (see {@link applySearchFlagHygiene}).
|
|
6951
|
+
*
|
|
6952
|
+
* Reads the first column of the first row positionally rather than by name, because the alias
|
|
6953
|
+
* comes back cased differently across drivers. A probe that returns nothing is treated as no
|
|
6954
|
+
* work, which is the safe direction: the statement is skipped rather than emitted blind.
|
|
6955
|
+
*/
|
|
6956
|
+
async searchFlagHygieneCandidateCount(pool, probeSQL) {
|
|
6957
|
+
const result = await this.runQuery(pool, probeSQL);
|
|
6958
|
+
const row = result?.recordset?.[0];
|
|
6959
|
+
if (!row) {
|
|
6960
|
+
return 0;
|
|
6961
|
+
}
|
|
6962
|
+
const count = Number(Object.values(row)[0]);
|
|
6963
|
+
return Number.isFinite(count) ? count : 0;
|
|
6964
|
+
}
|
|
6965
|
+
/**
|
|
6966
|
+
* Names of the entities the clear would disable (see {@link applySearchFlagHygiene}).
|
|
6967
|
+
*
|
|
6968
|
+
* Valid only AFTER the seed has run, since seeding changes which entities still have nothing
|
|
6969
|
+
* searchable. Names rather than a count because this is the destructive half: the run output
|
|
6970
|
+
* is the only place an operator can see which entities lost search, and getting it back is
|
|
6971
|
+
* manual.
|
|
6972
|
+
*/
|
|
6973
|
+
async searchFlagHygieneClearCandidates(pool, probeSQL) {
|
|
6974
|
+
const result = await this.runQuery(pool, probeSQL);
|
|
6975
|
+
const rows = result?.recordset ?? [];
|
|
6976
|
+
return rows
|
|
6977
|
+
.map(r => String(Object.values(r)[0] ?? '').trim())
|
|
6978
|
+
.filter(n => n.length > 0);
|
|
6979
|
+
}
|
|
6646
6980
|
/**
|
|
6647
6981
|
* Returns true if the field is a sensible target for LIKE-based user search.
|
|
6648
6982
|
* Mirrors the runtime guard in GenericDatabaseProvider.isTextSearchableType /
|
|
@@ -7140,8 +7474,8 @@ WHERE
|
|
|
7140
7474
|
* @param isRecurringScript - if set to true tells the logger that the provided SQL represents a recurring script meaning it is something that is executed, generally, for all CodeGen runs. In these cases, the Config settings can result in omitting these recurring scripts from being logged because the configuration environment may have those recurring scripts already set to run after all run-specific migrations get run.
|
|
7141
7475
|
* @returns - The result of the query execution.
|
|
7142
7476
|
*/
|
|
7143
|
-
async LogSQLAndExecute(pool, query, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO') {
|
|
7144
|
-
return await SQLLogging.LogSQLAndExecute(pool, this.qsql(query), description, isRecurringScript, includeBatchSeparator, batchSeparator);
|
|
7477
|
+
async LogSQLAndExecute(pool, query, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO', requiresOwnBatch = false) {
|
|
7478
|
+
return await SQLLogging.LogSQLAndExecute(pool, this.qsql(query), description, isRecurringScript, includeBatchSeparator, batchSeparator, requiresOwnBatch);
|
|
7145
7479
|
}
|
|
7146
7480
|
/**
|
|
7147
7481
|
* Logs and executes a sequence of SQL statements as a single batch.
|
|
@@ -7162,7 +7496,7 @@ WHERE
|
|
|
7162
7496
|
async LogSQLBatchAndExecute(pool, statements, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO') {
|
|
7163
7497
|
const terminated = [];
|
|
7164
7498
|
for (const s of statements) {
|
|
7165
|
-
const trimmed = (s ?? '')
|
|
7499
|
+
const trimmed = trimTrailingStatementTerminators(s ?? '');
|
|
7166
7500
|
if (trimmed.length === 0)
|
|
7167
7501
|
continue;
|
|
7168
7502
|
terminated.push(`${trimmed};`);
|