joist-core 2.4.0-next.1 → 2.4.0-next.3
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/build/IndexManager.cjs +2 -0
- package/build/IndexManager.cjs.map +1 -1
- package/build/IndexManager.d.cts.map +1 -1
- package/build/IndexManager.d.mts.map +1 -1
- package/build/IndexManager.js +2 -0
- package/build/IndexManager.js.map +1 -1
- package/build/queries/sql/execute.cjs +61 -70
- package/build/queries/sql/execute.cjs.map +1 -1
- package/build/queries/sql/execute.d.cts +4 -4
- package/build/queries/sql/execute.d.cts.map +1 -1
- package/build/queries/sql/execute.d.mts +4 -4
- package/build/queries/sql/execute.d.mts.map +1 -1
- package/build/queries/sql/execute.js +62 -71
- package/build/queries/sql/execute.js.map +1 -1
- package/package.json +2 -2
package/build/IndexManager.cjs
CHANGED
|
@@ -86,6 +86,7 @@ var IndexManager = class {
|
|
|
86
86
|
indexes.set(fieldName, entry);
|
|
87
87
|
}
|
|
88
88
|
for (const entity of entities) {
|
|
89
|
+
if (entity.isDeletedEntity) continue;
|
|
89
90
|
const value = getFieldValue(entity, fieldName, entry.field);
|
|
90
91
|
entry.index.add(value, entity);
|
|
91
92
|
}
|
|
@@ -101,6 +102,7 @@ var IndexManager = class {
|
|
|
101
102
|
if (entry === void 0 || entry.indexedCount === entities.length) return;
|
|
102
103
|
for (let i = entry.indexedCount; i < entities.length; i++) {
|
|
103
104
|
const entity = entities[i];
|
|
105
|
+
if (entity.isDeletedEntity) continue;
|
|
104
106
|
const value = getFieldValue(entity, fieldName, entry.field);
|
|
105
107
|
entry.index.add(value, entity);
|
|
106
108
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IndexManager.cjs","names":["#indexes","groupBy","getMetadata","isLoadedReference","#valueToEntities","isEntity"],"sources":["../src/IndexManager.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityMetadata, type Field, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { groupBy } from \"src/utils.ts\";\n\ntype FieldValue = any;\ntype FieldName = string;\ntype EntityTag = string;\ntype FieldIndexEntry = {\n field: Field;\n index: FieldIndex;\n /** Tracks how far into the EM's list-per-tag array this field index has been lazily populated. */\n indexedCount: number;\n};\nconst emptySet = new Set<Entity>();\n\n// The test reproducing a n^2 with n=500 went from 100ms to 50ms if indexed\nexport const indexThreshold = 500;\n\n/**\n * IndexManager provides field-based indexing for entity queries to avoid O(n) linear scans of `em.entities`.\n *\n * Key features:\n * - Only indexes entity types with >1000 entities to avoid overhead for small datasets\n * - Supports dual indexing for m2o/poly fields (by ID for saved, by instance for unsaved)\n * - Automatically maintains indexes when fields are updated via setField()\n */\nexport class IndexManager {\n readonly #indexes: Map<EntityTag, Map<FieldName, FieldIndexEntry>> = new Map();\n\n /** @return if we should index entities of this type/count. */\n shouldIndexType(entityCount: number): boolean {\n return entityCount >= indexThreshold;\n }\n\n /** Visible for testing. */\n isIndexed(tagName: string): boolean {\n return this.#indexes.has(tagName);\n }\n\n /** Enables indexing for an entity type and builds indexes for the requested fields. */\n enableIndexingForType<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: object): void {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n this.#indexes.set(tagName, new Map());\n }\n\n const fieldNames = Object.keys(where);\n const fieldIndexes = this.#indexes.get(tagName)!;\n const missingFieldNames = fieldNames.filter((fieldName) => !fieldIndexes.has(fieldName));\n if (missingFieldNames.length === 0) return;\n\n // If subtypes are involved, group by each subtype\n if (meta.baseType || meta.subTypes.length > 0) {\n [...groupBy(entities, (e) => getMetadata(e)).entries()].forEach(([meta, entities]) => {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n });\n } else {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n }\n }\n\n /** Updates indexes when a field value changes. */\n maybeUpdateFieldIndex(entity: Entity, fieldName: string, oldValue: any, newValue: any): void {\n // Fast return for the common case, which is no indexing\n if (this.#indexes.size === 0) return;\n\n const meta = getMetadata(entity);\n const fieldIndexes = this.#indexes.get(meta.tagName);\n if (!fieldIndexes) return; // Type not indexed\n\n const fieldIndex = fieldIndexes.get(fieldName)?.index;\n if (!fieldIndex) return; // Field is not indexed\n\n fieldIndex.remove(oldValue, entity);\n fieldIndex.add(newValue, entity);\n }\n\n /**\n * Finds entities matching the given where clause using indexes.\n *\n * Returns the raw candidate set (possibly a live internal index set, so do not mutate it);\n * the caller applies its own subtype/deleted filtering while building the final array.\n */\n findMatching<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: any): ReadonlySet<T> {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n throw new Error(`${meta.type} is not indexed`);\n }\n const fieldIndexes = this.#indexes.get(tagName)!;\n // Start with all entities of the 1st condition, then intersect (AND) each subsequent field in `where`\n let candidates: Set<Entity> | undefined;\n for (const [fieldName, value] of Object.entries(where)) {\n this.indexUnindexedEntities(entities, fieldName);\n const fieldCandidates = fieldIndexes.get(fieldName)?.index.get(value);\n if (!candidates) {\n candidates = fieldCandidates ?? emptySet; // This is the 1st clause\n } else {\n candidates = fieldCandidates ? intersectSets(candidates, fieldCandidates) : emptySet;\n }\n // Early exit if no candidates remain\n if (candidates.size === 0) break;\n }\n if (candidates === undefined) {\n throw new Error(`Expected where clause with at least one condition`);\n }\n return candidates as unknown as ReadonlySet<T>;\n }\n\n // `entities` should all be of the exact same subtype\n private addEntitiesToIndex(meta: EntityMetadata, entities: Entity[], fieldNames: string[]): void {\n const { tagName } = meta;\n const indexes = this.#indexes.get(tagName)!;\n // Iterate over each field so we can do a shared fieldIndex lookup\n for (const fieldName of fieldNames) {\n const field = meta.allFields[fieldName];\n if (!field) continue;\n let entry = indexes.get(fieldName);\n if (!entry) {\n entry = { field, index: new FieldIndex(), indexedCount: 0 };\n indexes.set(fieldName, entry);\n }\n for (const entity of entities) {\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n }\n\n /** Lazily indexes newly-registered entities only for fields that are queried. */\n private indexUnindexedEntities<T extends Entity>(entities: T[], fieldName: string): void {\n const first = entities[0];\n if (first === undefined) return;\n\n const meta = getMetadata(first);\n const entry = this.#indexes.get(meta.tagName)?.get(fieldName);\n if (entry === undefined || entry.indexedCount === entities.length) return;\n\n for (let i = entry.indexedCount; i < entities.length; i++) {\n const entity = entities[i];\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n}\n\n// Use the same field access pattern as entityMatches\nfunction getFieldValue(entity: Entity, fieldName: string, field: Field): any {\n const fn = fieldName as keyof Entity;\n switch (field.kind) {\n case \"primaryKey\":\n return entity.idTaggedMaybe;\n case \"enum\":\n case \"primitive\":\n return (entity as any)[fn];\n case \"m2o\":\n case \"poly\":\n const relation = (entity as any)[fn] as\n | ManyToOneReference<Entity, any, any>\n | PolymorphicReference<Entity, any, any>;\n if (isLoadedReference(relation)) {\n return relation.get;\n } else if (relation.isSet) {\n return relation.id;\n } else {\n return undefined;\n }\n default:\n return undefined;\n }\n}\n\n/**\n * For a given field, like `firstName`, holds the reverse index of value `bob` -> entities `[a1, a2]`.\n *\n * This also handles indexes `Entity` values as both instances & IDs, to handle both:\n * - new/unpersisted entities not yet having IDs, and\n * - relations not having loaded their instances yet\n */\nclass FieldIndex {\n readonly #valueToEntities = new Map<FieldValue, Set<Entity>>();\n\n /** @return entities that have `value` as their current value for this field. */\n get(value: any): Set<Entity> | undefined {\n if (isEntity(value) && value.idTaggedMaybe) {\n // If `value` is a persisted entity like `a:1`, and entities could have indexed their `author_id=a:1` field\n // values as the unloaded \"just an a:1 string id\" value, before `a:1` was loaded into memory.\n const matchId = this.#valueToEntities.get(value.idTaggedMaybe);\n // But other entities could also have indexes `a:1` as an instance, so go ahead and check both\n const matchInstance = this.#valueToEntities.get(value);\n if (matchId && matchInstance) {\n return new Set([...matchId, ...matchInstance]);\n }\n return matchId ?? matchInstance;\n }\n // Treat null and undefined as equivalent for unset relations\n if (value === null) value = undefined;\n return this.#valueToEntities.get(value);\n }\n\n add(value: any, entity: Entity): void {\n // If this is an entity, store both the entity itself & its ID\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doAdd(value.idTaggedMaybe, entity);\n }\n this.doAdd(value, entity);\n }\n\n remove(value: any, entity: Entity): void {\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doRemove(value.idTaggedMaybe, entity);\n }\n this.doRemove(value, entity);\n }\n\n private doAdd(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value) ?? new Set();\n if (set.size === 0) this.#valueToEntities.set(value, set);\n set.add(entity);\n }\n\n private doRemove(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value);\n if (set) {\n set.delete(entity);\n if (set.size === 0) {\n this.#valueToEntities.delete(value);\n }\n }\n }\n}\n\nfunction intersectSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {\n const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];\n const result = new Set<T>();\n for (const item of smaller) {\n if (larger.has(item)) {\n result.add(item);\n }\n }\n return result;\n}\n"],"mappings":";;;;;;;AAcA,MAAM,2BAAW,IAAI,IAAY;AAGjC,MAAa,iBAAiB;;;;;;;;;AAU9B,IAAa,eAAb,MAA0B;CACxB,2BAAqE,IAAI,IAAI;;CAG7E,gBAAgB,aAA8B;EAC5C,OAAO,eAAA;CACT;;CAGA,UAAU,SAA0B;EAClC,OAAO,KAAKA,SAAS,IAAI,OAAO;CAClC;;CAGA,sBAAwC,MAAyB,UAAe,OAAqB;EACnG,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,KAAKA,SAAS,IAAI,OAAO,GAC5B,KAAKA,SAAS,IAAI,yBAAS,IAAI,IAAI,CAAC;EAGtC,MAAM,aAAa,OAAO,KAAK,KAAK;EACpC,MAAM,eAAe,KAAKA,SAAS,IAAI,OAAO;EAC9C,MAAM,oBAAoB,WAAW,QAAQ,cAAc,CAAC,aAAa,IAAI,SAAS,CAAC;EACvF,IAAI,kBAAkB,WAAW,GAAG;EAGpC,IAAI,KAAK,YAAY,KAAK,SAAS,SAAS,GAC1C,CAAC,GAAGC,cAAAA,QAAQ,WAAW,MAAMC,uBAAAA,YAAY,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,cAAc;GACpF,KAAK,mBAAmB,MAAM,UAAU,iBAAiB;EAC3D,CAAC;OAED,KAAK,mBAAmB,MAAM,UAAU,iBAAiB;CAE7D;;CAGA,sBAAsB,QAAgB,WAAmB,UAAe,UAAqB;EAE3F,IAAI,KAAKF,SAAS,SAAS,GAAG;EAE9B,MAAM,OAAOE,uBAAAA,YAAY,MAAM;EAC/B,MAAM,eAAe,KAAKF,SAAS,IAAI,KAAK,OAAO;EACnD,IAAI,CAAC,cAAc;EAEnB,MAAM,aAAa,aAAa,IAAI,SAAS,CAAC,EAAE;EAChD,IAAI,CAAC,YAAY;EAEjB,WAAW,OAAO,UAAU,MAAM;EAClC,WAAW,IAAI,UAAU,MAAM;CACjC;;;;;;;CAQA,aAA+B,MAAyB,UAAe,OAA4B;EACjG,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,KAAKA,SAAS,IAAI,OAAO,GAC5B,MAAM,IAAI,MAAM,GAAG,KAAK,KAAK,gBAAgB;EAE/C,MAAM,eAAe,KAAKA,SAAS,IAAI,OAAO;EAE9C,IAAI;EACJ,KAAK,MAAM,CAAC,WAAW,UAAU,OAAO,QAAQ,KAAK,GAAG;GACtD,KAAK,uBAAuB,UAAU,SAAS;GAC/C,MAAM,kBAAkB,aAAa,IAAI,SAAS,CAAC,EAAE,MAAM,IAAI,KAAK;GACpE,IAAI,CAAC,YACH,aAAa,mBAAmB;QAEhC,aAAa,kBAAkB,cAAc,YAAY,eAAe,IAAI;GAG9E,IAAI,WAAW,SAAS,GAAG;EAC7B;EACA,IAAI,eAAe,KAAA,GACjB,MAAM,IAAI,MAAM,mDAAmD;EAErE,OAAO;CACT;CAGA,mBAA2B,MAAsB,UAAoB,YAA4B;EAC/F,MAAM,EAAE,YAAY;EACpB,MAAM,UAAU,KAAKA,SAAS,IAAI,OAAO;EAEzC,KAAK,MAAM,aAAa,YAAY;GAClC,MAAM,QAAQ,KAAK,UAAU;GAC7B,IAAI,CAAC,OAAO;GACZ,IAAI,QAAQ,QAAQ,IAAI,SAAS;GACjC,IAAI,CAAC,OAAO;IACV,QAAQ;KAAE;KAAO,OAAO,IAAI,WAAW;KAAG,cAAc;IAAE;IAC1D,QAAQ,IAAI,WAAW,KAAK;GAC9B;GACA,KAAK,MAAM,UAAU,UAAU;IAC7B,MAAM,QAAQ,cAAc,QAAQ,WAAW,MAAM,KAAK;IAC1D,MAAM,MAAM,IAAI,OAAO,MAAM;GAC/B;GACA,MAAM,eAAe,SAAS;EAChC;CACF;;CAGA,uBAAiD,UAAe,WAAyB;EACvF,MAAM,QAAQ,SAAS;EACvB,IAAI,UAAU,KAAA,GAAW;EAEzB,MAAM,OAAOE,uBAAAA,YAAY,KAAK;EAC9B,MAAM,QAAQ,KAAKF,SAAS,IAAI,KAAK,OAAO,CAAC,EAAE,IAAI,SAAS;EAC5D,IAAI,UAAU,KAAA,KAAa,MAAM,iBAAiB,SAAS,QAAQ;EAEnE,KAAK,IAAI,IAAI,MAAM,cAAc,IAAI,SAAS,QAAQ,KAAK;GACzD,MAAM,SAAS,SAAS;GACxB,MAAM,QAAQ,cAAc,QAAQ,WAAW,MAAM,KAAK;GAC1D,MAAM,MAAM,IAAI,OAAO,MAAM;EAC/B;EACA,MAAM,eAAe,SAAS;CAChC;AACF;AAGA,SAAS,cAAc,QAAgB,WAAmB,OAAmB;CAC3E,MAAM,KAAK;CACX,QAAQ,MAAM,MAAd;EACE,KAAK,cACH,OAAO,OAAO;EAChB,KAAK;EACL,KAAK,aACH,OAAQ,OAAe;EACzB,KAAK;EACL,KAAK;GACH,MAAM,WAAY,OAAe;GAGjC,IAAIG,4BAAAA,kBAAkB,QAAQ,GAC5B,OAAO,SAAS;QACX,IAAI,SAAS,OAClB,OAAO,SAAS;QAEhB;EAEJ,SACE;CACJ;AACF;;;;;;;;AASA,IAAM,aAAN,MAAiB;CACf,mCAA4B,IAAI,IAA6B;;CAG7D,IAAI,OAAqC;EACvC,IAAIE,eAAAA,SAAS,KAAK,KAAK,MAAM,eAAe;GAG1C,MAAM,UAAU,KAAKD,iBAAiB,IAAI,MAAM,aAAa;GAE7D,MAAM,gBAAgB,KAAKA,iBAAiB,IAAI,KAAK;GACrD,IAAI,WAAW,eACb,uBAAO,IAAI,IAAI,CAAC,GAAG,SAAS,GAAG,aAAa,CAAC;GAE/C,OAAO,WAAW;EACpB;EAEA,IAAI,UAAU,MAAM,QAAQ,KAAA;EAC5B,OAAO,KAAKA,iBAAiB,IAAI,KAAK;CACxC;CAEA,IAAI,OAAY,QAAsB;EAEpC,IAAIC,eAAAA,SAAS,KAAK,KAAK,MAAM,eAC3B,KAAK,MAAM,MAAM,eAAe,MAAM;EAExC,KAAK,MAAM,OAAO,MAAM;CAC1B;CAEA,OAAO,OAAY,QAAsB;EACvC,IAAIA,eAAAA,SAAS,KAAK,KAAK,MAAM,eAC3B,KAAK,SAAS,MAAM,eAAe,MAAM;EAE3C,KAAK,SAAS,OAAO,MAAM;CAC7B;CAEA,MAAc,OAAY,QAAsB;EAC9C,MAAM,MAAM,KAAKD,iBAAiB,IAAI,KAAK,qBAAK,IAAI,IAAI;EACxD,IAAI,IAAI,SAAS,GAAG,KAAKA,iBAAiB,IAAI,OAAO,GAAG;EACxD,IAAI,IAAI,MAAM;CAChB;CAEA,SAAiB,OAAY,QAAsB;EACjD,MAAM,MAAM,KAAKA,iBAAiB,IAAI,KAAK;EAC3C,IAAI,KAAK;GACP,IAAI,OAAO,MAAM;GACjB,IAAI,IAAI,SAAS,GACf,KAAKA,iBAAiB,OAAO,KAAK;EAEtC;CACF;AACF;AAEA,SAAS,cAAiB,MAAc,MAAsB;CAC5D,MAAM,CAAC,SAAS,UAAU,KAAK,QAAQ,KAAK,OAAO,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,IAAI;CAC7E,MAAM,yBAAS,IAAI,IAAO;CAC1B,KAAK,MAAM,QAAQ,SACjB,IAAI,OAAO,IAAI,IAAI,GACjB,OAAO,IAAI,IAAI;CAGnB,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"IndexManager.cjs","names":["#indexes","groupBy","getMetadata","isLoadedReference","#valueToEntities","isEntity"],"sources":["../src/IndexManager.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityMetadata, type Field, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { groupBy } from \"src/utils.ts\";\n\ntype FieldValue = any;\ntype FieldName = string;\ntype EntityTag = string;\ntype FieldIndexEntry = {\n field: Field;\n index: FieldIndex;\n /** Tracks how far into the EM's list-per-tag array this field index has been lazily populated. */\n indexedCount: number;\n};\nconst emptySet = new Set<Entity>();\n\n// The test reproducing a n^2 with n=500 went from 100ms to 50ms if indexed\nexport const indexThreshold = 500;\n\n/**\n * IndexManager provides field-based indexing for entity queries to avoid O(n) linear scans of `em.entities`.\n *\n * Key features:\n * - Only indexes entity types with >1000 entities to avoid overhead for small datasets\n * - Supports dual indexing for m2o/poly fields (by ID for saved, by instance for unsaved)\n * - Automatically maintains indexes when fields are updated via setField()\n */\nexport class IndexManager {\n readonly #indexes: Map<EntityTag, Map<FieldName, FieldIndexEntry>> = new Map();\n\n /** @return if we should index entities of this type/count. */\n shouldIndexType(entityCount: number): boolean {\n return entityCount >= indexThreshold;\n }\n\n /** Visible for testing. */\n isIndexed(tagName: string): boolean {\n return this.#indexes.has(tagName);\n }\n\n /** Enables indexing for an entity type and builds indexes for the requested fields. */\n enableIndexingForType<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: object): void {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n this.#indexes.set(tagName, new Map());\n }\n\n const fieldNames = Object.keys(where);\n const fieldIndexes = this.#indexes.get(tagName)!;\n const missingFieldNames = fieldNames.filter((fieldName) => !fieldIndexes.has(fieldName));\n if (missingFieldNames.length === 0) return;\n\n // If subtypes are involved, group by each subtype\n if (meta.baseType || meta.subTypes.length > 0) {\n [...groupBy(entities, (e) => getMetadata(e)).entries()].forEach(([meta, entities]) => {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n });\n } else {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n }\n }\n\n /** Updates indexes when a field value changes. */\n maybeUpdateFieldIndex(entity: Entity, fieldName: string, oldValue: any, newValue: any): void {\n // Fast return for the common case, which is no indexing\n if (this.#indexes.size === 0) return;\n\n const meta = getMetadata(entity);\n const fieldIndexes = this.#indexes.get(meta.tagName);\n if (!fieldIndexes) return; // Type not indexed\n\n const fieldIndex = fieldIndexes.get(fieldName)?.index;\n if (!fieldIndex) return; // Field is not indexed\n\n fieldIndex.remove(oldValue, entity);\n fieldIndex.add(newValue, entity);\n }\n\n /**\n * Finds entities matching the given where clause using indexes.\n *\n * Returns the raw candidate set (possibly a live internal index set, so do not mutate it);\n * the caller applies its own subtype/deleted filtering while building the final array.\n */\n findMatching<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: any): ReadonlySet<T> {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n throw new Error(`${meta.type} is not indexed`);\n }\n const fieldIndexes = this.#indexes.get(tagName)!;\n // Start with all entities of the 1st condition, then intersect (AND) each subsequent field in `where`\n let candidates: Set<Entity> | undefined;\n for (const [fieldName, value] of Object.entries(where)) {\n this.indexUnindexedEntities(entities, fieldName);\n const fieldCandidates = fieldIndexes.get(fieldName)?.index.get(value);\n if (!candidates) {\n candidates = fieldCandidates ?? emptySet; // This is the 1st clause\n } else {\n candidates = fieldCandidates ? intersectSets(candidates, fieldCandidates) : emptySet;\n }\n // Early exit if no candidates remain\n if (candidates.size === 0) break;\n }\n if (candidates === undefined) {\n throw new Error(`Expected where clause with at least one condition`);\n }\n return candidates as unknown as ReadonlySet<T>;\n }\n\n // `entities` should all be of the exact same subtype\n private addEntitiesToIndex(meta: EntityMetadata, entities: Entity[], fieldNames: string[]): void {\n const { tagName } = meta;\n const indexes = this.#indexes.get(tagName)!;\n // Iterate over each field so we can do a shared fieldIndex lookup\n for (const fieldName of fieldNames) {\n const field = meta.allFields[fieldName];\n if (!field) continue;\n let entry = indexes.get(fieldName);\n if (!entry) {\n entry = { field, index: new FieldIndex(), indexedCount: 0 };\n indexes.set(fieldName, entry);\n }\n for (const entity of entities) {\n // Deleted entities never match, and reading a flushed one's relations throws\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n }\n\n /** Lazily indexes newly-registered entities only for fields that are queried. */\n private indexUnindexedEntities<T extends Entity>(entities: T[], fieldName: string): void {\n const first = entities[0];\n if (first === undefined) return;\n\n const meta = getMetadata(first);\n const entry = this.#indexes.get(meta.tagName)?.get(fieldName);\n if (entry === undefined || entry.indexedCount === entities.length) return;\n\n for (let i = entry.indexedCount; i < entities.length; i++) {\n const entity = entities[i];\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n}\n\n// Use the same field access pattern as entityMatches\nfunction getFieldValue(entity: Entity, fieldName: string, field: Field): any {\n const fn = fieldName as keyof Entity;\n switch (field.kind) {\n case \"primaryKey\":\n return entity.idTaggedMaybe;\n case \"enum\":\n case \"primitive\":\n return (entity as any)[fn];\n case \"m2o\":\n case \"poly\":\n const relation = (entity as any)[fn] as\n | ManyToOneReference<Entity, any, any>\n | PolymorphicReference<Entity, any, any>;\n if (isLoadedReference(relation)) {\n return relation.get;\n } else if (relation.isSet) {\n return relation.id;\n } else {\n return undefined;\n }\n default:\n return undefined;\n }\n}\n\n/**\n * For a given field, like `firstName`, holds the reverse index of value `bob` -> entities `[a1, a2]`.\n *\n * This also handles indexes `Entity` values as both instances & IDs, to handle both:\n * - new/unpersisted entities not yet having IDs, and\n * - relations not having loaded their instances yet\n */\nclass FieldIndex {\n readonly #valueToEntities = new Map<FieldValue, Set<Entity>>();\n\n /** @return entities that have `value` as their current value for this field. */\n get(value: any): Set<Entity> | undefined {\n if (isEntity(value) && value.idTaggedMaybe) {\n // If `value` is a persisted entity like `a:1`, and entities could have indexed their `author_id=a:1` field\n // values as the unloaded \"just an a:1 string id\" value, before `a:1` was loaded into memory.\n const matchId = this.#valueToEntities.get(value.idTaggedMaybe);\n // But other entities could also have indexes `a:1` as an instance, so go ahead and check both\n const matchInstance = this.#valueToEntities.get(value);\n if (matchId && matchInstance) {\n return new Set([...matchId, ...matchInstance]);\n }\n return matchId ?? matchInstance;\n }\n // Treat null and undefined as equivalent for unset relations\n if (value === null) value = undefined;\n return this.#valueToEntities.get(value);\n }\n\n add(value: any, entity: Entity): void {\n // If this is an entity, store both the entity itself & its ID\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doAdd(value.idTaggedMaybe, entity);\n }\n this.doAdd(value, entity);\n }\n\n remove(value: any, entity: Entity): void {\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doRemove(value.idTaggedMaybe, entity);\n }\n this.doRemove(value, entity);\n }\n\n private doAdd(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value) ?? new Set();\n if (set.size === 0) this.#valueToEntities.set(value, set);\n set.add(entity);\n }\n\n private doRemove(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value);\n if (set) {\n set.delete(entity);\n if (set.size === 0) {\n this.#valueToEntities.delete(value);\n }\n }\n }\n}\n\nfunction intersectSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {\n const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];\n const result = new Set<T>();\n for (const item of smaller) {\n if (larger.has(item)) {\n result.add(item);\n }\n }\n return result;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IndexManager.d.cts","names":[],"sources":["../src/IndexManager.ts"],"mappings":";;;cAiBa;;;;;;;;;cAUA;;;EAIX,gBAAgB;;EAKhB,UAAU;;EAKV,sBAAsB,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK;;EAsBhF,sBAAsB,QAAQ,QAAQ,mBAAmB,eAAe;;;;;;;EAqBxE,aAAa,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK,aAAa,YAAY;UA0BxF;;
|
|
1
|
+
{"version":3,"file":"IndexManager.d.cts","names":[],"sources":["../src/IndexManager.ts"],"mappings":";;;cAiBa;;;;;;;;;cAUA;;;EAIX,gBAAgB;;EAKhB,UAAU;;EAKV,sBAAsB,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK;;EAsBhF,sBAAsB,QAAQ,QAAQ,mBAAmB,eAAe;;;;;;;EAqBxE,aAAa,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK,aAAa,YAAY;UA0BxF;;UAuBA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IndexManager.d.mts","names":[],"sources":["../src/IndexManager.ts"],"mappings":";;;cAiBa;;;;;;;;;cAUA;;;EAIX,gBAAgB;;EAKhB,UAAU;;EAKV,sBAAsB,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK;;EAsBhF,sBAAsB,QAAQ,QAAQ,mBAAmB,eAAe;;;;;;;EAqBxE,aAAa,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK,aAAa,YAAY;UA0BxF;;
|
|
1
|
+
{"version":3,"file":"IndexManager.d.mts","names":[],"sources":["../src/IndexManager.ts"],"mappings":";;;cAiBa;;;;;;;;;cAUA;;;EAIX,gBAAgB;;EAKhB,UAAU;;EAKV,sBAAsB,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK;;EAsBhF,sBAAsB,QAAQ,QAAQ,mBAAmB,eAAe;;;;;;;EAqBxE,aAAa,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK,aAAa,YAAY;UA0BxF;;UAuBA"}
|
package/build/IndexManager.js
CHANGED
|
@@ -85,6 +85,7 @@ var IndexManager = class {
|
|
|
85
85
|
indexes.set(fieldName, entry);
|
|
86
86
|
}
|
|
87
87
|
for (const entity of entities) {
|
|
88
|
+
if (entity.isDeletedEntity) continue;
|
|
88
89
|
const value = getFieldValue(entity, fieldName, entry.field);
|
|
89
90
|
entry.index.add(value, entity);
|
|
90
91
|
}
|
|
@@ -100,6 +101,7 @@ var IndexManager = class {
|
|
|
100
101
|
if (entry === void 0 || entry.indexedCount === entities.length) return;
|
|
101
102
|
for (let i = entry.indexedCount; i < entities.length; i++) {
|
|
102
103
|
const entity = entities[i];
|
|
104
|
+
if (entity.isDeletedEntity) continue;
|
|
103
105
|
const value = getFieldValue(entity, fieldName, entry.field);
|
|
104
106
|
entry.index.add(value, entity);
|
|
105
107
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IndexManager.js","names":["#indexes","#valueToEntities"],"sources":["../src/IndexManager.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityMetadata, type Field, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { groupBy } from \"src/utils.ts\";\n\ntype FieldValue = any;\ntype FieldName = string;\ntype EntityTag = string;\ntype FieldIndexEntry = {\n field: Field;\n index: FieldIndex;\n /** Tracks how far into the EM's list-per-tag array this field index has been lazily populated. */\n indexedCount: number;\n};\nconst emptySet = new Set<Entity>();\n\n// The test reproducing a n^2 with n=500 went from 100ms to 50ms if indexed\nexport const indexThreshold = 500;\n\n/**\n * IndexManager provides field-based indexing for entity queries to avoid O(n) linear scans of `em.entities`.\n *\n * Key features:\n * - Only indexes entity types with >1000 entities to avoid overhead for small datasets\n * - Supports dual indexing for m2o/poly fields (by ID for saved, by instance for unsaved)\n * - Automatically maintains indexes when fields are updated via setField()\n */\nexport class IndexManager {\n readonly #indexes: Map<EntityTag, Map<FieldName, FieldIndexEntry>> = new Map();\n\n /** @return if we should index entities of this type/count. */\n shouldIndexType(entityCount: number): boolean {\n return entityCount >= indexThreshold;\n }\n\n /** Visible for testing. */\n isIndexed(tagName: string): boolean {\n return this.#indexes.has(tagName);\n }\n\n /** Enables indexing for an entity type and builds indexes for the requested fields. */\n enableIndexingForType<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: object): void {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n this.#indexes.set(tagName, new Map());\n }\n\n const fieldNames = Object.keys(where);\n const fieldIndexes = this.#indexes.get(tagName)!;\n const missingFieldNames = fieldNames.filter((fieldName) => !fieldIndexes.has(fieldName));\n if (missingFieldNames.length === 0) return;\n\n // If subtypes are involved, group by each subtype\n if (meta.baseType || meta.subTypes.length > 0) {\n [...groupBy(entities, (e) => getMetadata(e)).entries()].forEach(([meta, entities]) => {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n });\n } else {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n }\n }\n\n /** Updates indexes when a field value changes. */\n maybeUpdateFieldIndex(entity: Entity, fieldName: string, oldValue: any, newValue: any): void {\n // Fast return for the common case, which is no indexing\n if (this.#indexes.size === 0) return;\n\n const meta = getMetadata(entity);\n const fieldIndexes = this.#indexes.get(meta.tagName);\n if (!fieldIndexes) return; // Type not indexed\n\n const fieldIndex = fieldIndexes.get(fieldName)?.index;\n if (!fieldIndex) return; // Field is not indexed\n\n fieldIndex.remove(oldValue, entity);\n fieldIndex.add(newValue, entity);\n }\n\n /**\n * Finds entities matching the given where clause using indexes.\n *\n * Returns the raw candidate set (possibly a live internal index set, so do not mutate it);\n * the caller applies its own subtype/deleted filtering while building the final array.\n */\n findMatching<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: any): ReadonlySet<T> {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n throw new Error(`${meta.type} is not indexed`);\n }\n const fieldIndexes = this.#indexes.get(tagName)!;\n // Start with all entities of the 1st condition, then intersect (AND) each subsequent field in `where`\n let candidates: Set<Entity> | undefined;\n for (const [fieldName, value] of Object.entries(where)) {\n this.indexUnindexedEntities(entities, fieldName);\n const fieldCandidates = fieldIndexes.get(fieldName)?.index.get(value);\n if (!candidates) {\n candidates = fieldCandidates ?? emptySet; // This is the 1st clause\n } else {\n candidates = fieldCandidates ? intersectSets(candidates, fieldCandidates) : emptySet;\n }\n // Early exit if no candidates remain\n if (candidates.size === 0) break;\n }\n if (candidates === undefined) {\n throw new Error(`Expected where clause with at least one condition`);\n }\n return candidates as unknown as ReadonlySet<T>;\n }\n\n // `entities` should all be of the exact same subtype\n private addEntitiesToIndex(meta: EntityMetadata, entities: Entity[], fieldNames: string[]): void {\n const { tagName } = meta;\n const indexes = this.#indexes.get(tagName)!;\n // Iterate over each field so we can do a shared fieldIndex lookup\n for (const fieldName of fieldNames) {\n const field = meta.allFields[fieldName];\n if (!field) continue;\n let entry = indexes.get(fieldName);\n if (!entry) {\n entry = { field, index: new FieldIndex(), indexedCount: 0 };\n indexes.set(fieldName, entry);\n }\n for (const entity of entities) {\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n }\n\n /** Lazily indexes newly-registered entities only for fields that are queried. */\n private indexUnindexedEntities<T extends Entity>(entities: T[], fieldName: string): void {\n const first = entities[0];\n if (first === undefined) return;\n\n const meta = getMetadata(first);\n const entry = this.#indexes.get(meta.tagName)?.get(fieldName);\n if (entry === undefined || entry.indexedCount === entities.length) return;\n\n for (let i = entry.indexedCount; i < entities.length; i++) {\n const entity = entities[i];\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n}\n\n// Use the same field access pattern as entityMatches\nfunction getFieldValue(entity: Entity, fieldName: string, field: Field): any {\n const fn = fieldName as keyof Entity;\n switch (field.kind) {\n case \"primaryKey\":\n return entity.idTaggedMaybe;\n case \"enum\":\n case \"primitive\":\n return (entity as any)[fn];\n case \"m2o\":\n case \"poly\":\n const relation = (entity as any)[fn] as\n | ManyToOneReference<Entity, any, any>\n | PolymorphicReference<Entity, any, any>;\n if (isLoadedReference(relation)) {\n return relation.get;\n } else if (relation.isSet) {\n return relation.id;\n } else {\n return undefined;\n }\n default:\n return undefined;\n }\n}\n\n/**\n * For a given field, like `firstName`, holds the reverse index of value `bob` -> entities `[a1, a2]`.\n *\n * This also handles indexes `Entity` values as both instances & IDs, to handle both:\n * - new/unpersisted entities not yet having IDs, and\n * - relations not having loaded their instances yet\n */\nclass FieldIndex {\n readonly #valueToEntities = new Map<FieldValue, Set<Entity>>();\n\n /** @return entities that have `value` as their current value for this field. */\n get(value: any): Set<Entity> | undefined {\n if (isEntity(value) && value.idTaggedMaybe) {\n // If `value` is a persisted entity like `a:1`, and entities could have indexed their `author_id=a:1` field\n // values as the unloaded \"just an a:1 string id\" value, before `a:1` was loaded into memory.\n const matchId = this.#valueToEntities.get(value.idTaggedMaybe);\n // But other entities could also have indexes `a:1` as an instance, so go ahead and check both\n const matchInstance = this.#valueToEntities.get(value);\n if (matchId && matchInstance) {\n return new Set([...matchId, ...matchInstance]);\n }\n return matchId ?? matchInstance;\n }\n // Treat null and undefined as equivalent for unset relations\n if (value === null) value = undefined;\n return this.#valueToEntities.get(value);\n }\n\n add(value: any, entity: Entity): void {\n // If this is an entity, store both the entity itself & its ID\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doAdd(value.idTaggedMaybe, entity);\n }\n this.doAdd(value, entity);\n }\n\n remove(value: any, entity: Entity): void {\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doRemove(value.idTaggedMaybe, entity);\n }\n this.doRemove(value, entity);\n }\n\n private doAdd(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value) ?? new Set();\n if (set.size === 0) this.#valueToEntities.set(value, set);\n set.add(entity);\n }\n\n private doRemove(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value);\n if (set) {\n set.delete(entity);\n if (set.size === 0) {\n this.#valueToEntities.delete(value);\n }\n }\n }\n}\n\nfunction intersectSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {\n const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];\n const result = new Set<T>();\n for (const item of smaller) {\n if (larger.has(item)) {\n result.add(item);\n }\n }\n return result;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"IndexManager.js","names":["#indexes","#valueToEntities"],"sources":["../src/IndexManager.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityMetadata, type Field, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { groupBy } from \"src/utils.ts\";\n\ntype FieldValue = any;\ntype FieldName = string;\ntype EntityTag = string;\ntype FieldIndexEntry = {\n field: Field;\n index: FieldIndex;\n /** Tracks how far into the EM's list-per-tag array this field index has been lazily populated. */\n indexedCount: number;\n};\nconst emptySet = new Set<Entity>();\n\n// The test reproducing a n^2 with n=500 went from 100ms to 50ms if indexed\nexport const indexThreshold = 500;\n\n/**\n * IndexManager provides field-based indexing for entity queries to avoid O(n) linear scans of `em.entities`.\n *\n * Key features:\n * - Only indexes entity types with >1000 entities to avoid overhead for small datasets\n * - Supports dual indexing for m2o/poly fields (by ID for saved, by instance for unsaved)\n * - Automatically maintains indexes when fields are updated via setField()\n */\nexport class IndexManager {\n readonly #indexes: Map<EntityTag, Map<FieldName, FieldIndexEntry>> = new Map();\n\n /** @return if we should index entities of this type/count. */\n shouldIndexType(entityCount: number): boolean {\n return entityCount >= indexThreshold;\n }\n\n /** Visible for testing. */\n isIndexed(tagName: string): boolean {\n return this.#indexes.has(tagName);\n }\n\n /** Enables indexing for an entity type and builds indexes for the requested fields. */\n enableIndexingForType<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: object): void {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n this.#indexes.set(tagName, new Map());\n }\n\n const fieldNames = Object.keys(where);\n const fieldIndexes = this.#indexes.get(tagName)!;\n const missingFieldNames = fieldNames.filter((fieldName) => !fieldIndexes.has(fieldName));\n if (missingFieldNames.length === 0) return;\n\n // If subtypes are involved, group by each subtype\n if (meta.baseType || meta.subTypes.length > 0) {\n [...groupBy(entities, (e) => getMetadata(e)).entries()].forEach(([meta, entities]) => {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n });\n } else {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n }\n }\n\n /** Updates indexes when a field value changes. */\n maybeUpdateFieldIndex(entity: Entity, fieldName: string, oldValue: any, newValue: any): void {\n // Fast return for the common case, which is no indexing\n if (this.#indexes.size === 0) return;\n\n const meta = getMetadata(entity);\n const fieldIndexes = this.#indexes.get(meta.tagName);\n if (!fieldIndexes) return; // Type not indexed\n\n const fieldIndex = fieldIndexes.get(fieldName)?.index;\n if (!fieldIndex) return; // Field is not indexed\n\n fieldIndex.remove(oldValue, entity);\n fieldIndex.add(newValue, entity);\n }\n\n /**\n * Finds entities matching the given where clause using indexes.\n *\n * Returns the raw candidate set (possibly a live internal index set, so do not mutate it);\n * the caller applies its own subtype/deleted filtering while building the final array.\n */\n findMatching<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: any): ReadonlySet<T> {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n throw new Error(`${meta.type} is not indexed`);\n }\n const fieldIndexes = this.#indexes.get(tagName)!;\n // Start with all entities of the 1st condition, then intersect (AND) each subsequent field in `where`\n let candidates: Set<Entity> | undefined;\n for (const [fieldName, value] of Object.entries(where)) {\n this.indexUnindexedEntities(entities, fieldName);\n const fieldCandidates = fieldIndexes.get(fieldName)?.index.get(value);\n if (!candidates) {\n candidates = fieldCandidates ?? emptySet; // This is the 1st clause\n } else {\n candidates = fieldCandidates ? intersectSets(candidates, fieldCandidates) : emptySet;\n }\n // Early exit if no candidates remain\n if (candidates.size === 0) break;\n }\n if (candidates === undefined) {\n throw new Error(`Expected where clause with at least one condition`);\n }\n return candidates as unknown as ReadonlySet<T>;\n }\n\n // `entities` should all be of the exact same subtype\n private addEntitiesToIndex(meta: EntityMetadata, entities: Entity[], fieldNames: string[]): void {\n const { tagName } = meta;\n const indexes = this.#indexes.get(tagName)!;\n // Iterate over each field so we can do a shared fieldIndex lookup\n for (const fieldName of fieldNames) {\n const field = meta.allFields[fieldName];\n if (!field) continue;\n let entry = indexes.get(fieldName);\n if (!entry) {\n entry = { field, index: new FieldIndex(), indexedCount: 0 };\n indexes.set(fieldName, entry);\n }\n for (const entity of entities) {\n // Deleted entities never match, and reading a flushed one's relations throws\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n }\n\n /** Lazily indexes newly-registered entities only for fields that are queried. */\n private indexUnindexedEntities<T extends Entity>(entities: T[], fieldName: string): void {\n const first = entities[0];\n if (first === undefined) return;\n\n const meta = getMetadata(first);\n const entry = this.#indexes.get(meta.tagName)?.get(fieldName);\n if (entry === undefined || entry.indexedCount === entities.length) return;\n\n for (let i = entry.indexedCount; i < entities.length; i++) {\n const entity = entities[i];\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n}\n\n// Use the same field access pattern as entityMatches\nfunction getFieldValue(entity: Entity, fieldName: string, field: Field): any {\n const fn = fieldName as keyof Entity;\n switch (field.kind) {\n case \"primaryKey\":\n return entity.idTaggedMaybe;\n case \"enum\":\n case \"primitive\":\n return (entity as any)[fn];\n case \"m2o\":\n case \"poly\":\n const relation = (entity as any)[fn] as\n | ManyToOneReference<Entity, any, any>\n | PolymorphicReference<Entity, any, any>;\n if (isLoadedReference(relation)) {\n return relation.get;\n } else if (relation.isSet) {\n return relation.id;\n } else {\n return undefined;\n }\n default:\n return undefined;\n }\n}\n\n/**\n * For a given field, like `firstName`, holds the reverse index of value `bob` -> entities `[a1, a2]`.\n *\n * This also handles indexes `Entity` values as both instances & IDs, to handle both:\n * - new/unpersisted entities not yet having IDs, and\n * - relations not having loaded their instances yet\n */\nclass FieldIndex {\n readonly #valueToEntities = new Map<FieldValue, Set<Entity>>();\n\n /** @return entities that have `value` as their current value for this field. */\n get(value: any): Set<Entity> | undefined {\n if (isEntity(value) && value.idTaggedMaybe) {\n // If `value` is a persisted entity like `a:1`, and entities could have indexed their `author_id=a:1` field\n // values as the unloaded \"just an a:1 string id\" value, before `a:1` was loaded into memory.\n const matchId = this.#valueToEntities.get(value.idTaggedMaybe);\n // But other entities could also have indexes `a:1` as an instance, so go ahead and check both\n const matchInstance = this.#valueToEntities.get(value);\n if (matchId && matchInstance) {\n return new Set([...matchId, ...matchInstance]);\n }\n return matchId ?? matchInstance;\n }\n // Treat null and undefined as equivalent for unset relations\n if (value === null) value = undefined;\n return this.#valueToEntities.get(value);\n }\n\n add(value: any, entity: Entity): void {\n // If this is an entity, store both the entity itself & its ID\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doAdd(value.idTaggedMaybe, entity);\n }\n this.doAdd(value, entity);\n }\n\n remove(value: any, entity: Entity): void {\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doRemove(value.idTaggedMaybe, entity);\n }\n this.doRemove(value, entity);\n }\n\n private doAdd(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value) ?? new Set();\n if (set.size === 0) this.#valueToEntities.set(value, set);\n set.add(entity);\n }\n\n private doRemove(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value);\n if (set) {\n set.delete(entity);\n if (set.size === 0) {\n this.#valueToEntities.delete(value);\n }\n }\n }\n}\n\nfunction intersectSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {\n const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];\n const result = new Set<T>();\n for (const item of smaller) {\n if (larger.has(item)) {\n result.add(item);\n }\n }\n return result;\n}\n"],"mappings":"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"}
|
|
@@ -47,27 +47,24 @@ function parseStatement(arg) {
|
|
|
47
47
|
"with",
|
|
48
48
|
...operation === "update" ? ["set"] : []
|
|
49
49
|
], `SQL ${operation}`);
|
|
50
|
-
const target = statement[operation];
|
|
51
|
-
|
|
52
|
-
const mgmt = require_queries_sql_Tables.getTableMgmt(target);
|
|
53
|
-
const meta = require_queries_sql_Tables.isEntityTable(target) ? require_queries_sql_Tables.getTableMgmt(target).meta : void 0;
|
|
54
|
-
const custom = require_queries_sql_Tables.isCustomTable(target) ? require_queries_sql_Tables.getTableMgmt(target) : void 0;
|
|
50
|
+
const target = mutationTarget(statement[operation]);
|
|
51
|
+
const { meta } = target;
|
|
55
52
|
if (meta) {
|
|
56
53
|
if (meta.inheritanceType || meta.baseType || meta.baseTypes.length || meta.subTypes.length) require_utils.fail("SQL mutations do not support CTI/STI targets or inherited table families");
|
|
57
54
|
if (meta.supportsEmExecute !== true) require_utils.fail(`SQL mutations require supported physical metadata for ${meta.type}; run codegen`);
|
|
58
|
-
for (const
|
|
55
|
+
for (const column of Object.values(meta.columns)) requireColumnMetadata(target, column);
|
|
59
56
|
} else if (statement.softDeletes !== void 0) require_utils.fail("Custom table mutations do not support softDeletes");
|
|
60
|
-
const fields = Object.entries(
|
|
57
|
+
const fields = Object.entries(target.columns);
|
|
61
58
|
const assigner = new require_queries_sql_AliasAssigner.AliasAssigner();
|
|
62
59
|
const withCtx = new require_queries_sql_query.Ctx(assigner, void 0);
|
|
63
60
|
const ctes = require_queries_sql_query.registerCtes(statement, withCtx, assigner);
|
|
64
61
|
const refs = [];
|
|
65
62
|
const ctx = new require_queries_sql_query.Ctx(assigner, withCtx);
|
|
66
|
-
const alias = assigner.getAlias(mgmt.tableName);
|
|
67
|
-
ctx.register(mgmt, alias);
|
|
63
|
+
const alias = assigner.getAlias(target.mgmt.tableName);
|
|
64
|
+
ctx.register(target.mgmt, alias);
|
|
68
65
|
const returning = statement.returning === void 0 ? void 0 : require_queries_sql_query.projectionToSql(statement.returning, ctx);
|
|
69
66
|
if (returning) for (const select of returning.selects) refs.push(...select.refs);
|
|
70
|
-
let sql = `${operation === "delete" ? "DELETE FROM" : operation.toUpperCase() + (operation === "insert" ? " INTO" : "")} ${require_queries_sql_Tables.tableSqlName(mgmt)} AS ${require_queries_sql_keywords.kq(alias)}`;
|
|
67
|
+
let sql = `${operation === "delete" ? "DELETE FROM" : operation.toUpperCase() + (operation === "insert" ? " INTO" : "")} ${require_queries_sql_Tables.tableSqlName(target.mgmt)} AS ${require_queries_sql_keywords.kq(alias)}`;
|
|
71
68
|
const bindings = [];
|
|
72
69
|
if (operation === "insert") {
|
|
73
70
|
if ("values" in statement === "from" in statement) require_utils.fail("INSERT requires exactly one of values or from");
|
|
@@ -76,8 +73,8 @@ function parseStatement(arg) {
|
|
|
76
73
|
if (!Array.isArray(statement.values)) require_utils.fail("INSERT values must be an array of field POJOs");
|
|
77
74
|
const rows = statement.values;
|
|
78
75
|
const valuesCtx = new require_queries_sql_query.Ctx(assigner, withCtx);
|
|
79
|
-
const entries = rows.map((row) =>
|
|
80
|
-
for (const row of entries) for (const [key] of required) if (!row.some((entry) => entry[0] === key)) require_utils.fail(`INSERT requires ${
|
|
76
|
+
const entries = rows.map((row) => assignments(target, row, "insert"));
|
|
77
|
+
for (const row of entries) for (const [key] of required) if (!row.some((entry) => entry[0] === key)) require_utils.fail(`INSERT requires ${target.name}.${key}`);
|
|
81
78
|
if (rows.length === 0) return void 0;
|
|
82
79
|
const keys = fields.filter(([key]) => entries.some((row) => row.some((entry) => entry[0] === key))).map(([key, field]) => [key, field.columnName]);
|
|
83
80
|
sql += ` (${keys.map(([, column]) => require_queries_sql_keywords.kq(column)).join(", ")}) VALUES `;
|
|
@@ -85,7 +82,7 @@ function parseStatement(arg) {
|
|
|
85
82
|
return `(${keys.map(([key]) => {
|
|
86
83
|
const entry = row.find((entry) => entry[0] === key);
|
|
87
84
|
if (!entry) return "DEFAULT";
|
|
88
|
-
const cell =
|
|
85
|
+
const cell = assignmentToSql(target, writableField(target, key, "insert"), entry[1], valuesCtx);
|
|
89
86
|
bindings.push(...cell.bindings);
|
|
90
87
|
refs.push(...cell.refs);
|
|
91
88
|
return cell.sql;
|
|
@@ -95,13 +92,13 @@ function parseStatement(arg) {
|
|
|
95
92
|
const source = require_queries_sql_query.parseNestedQuery(statement.from, withCtx, assigner);
|
|
96
93
|
if (source.output.kind !== "pojo") require_utils.fail("INSERT SELECT requires named POJO output columns");
|
|
97
94
|
const columns = source.output.columns;
|
|
98
|
-
for (const [key] of required) if (!columns.some((column) => column[0] === key)) require_utils.fail(`INSERT requires ${
|
|
95
|
+
for (const [key] of required) if (!columns.some((column) => column[0] === key)) require_utils.fail(`INSERT requires ${target.name}.${key}`);
|
|
99
96
|
for (const [key, expr] of columns) {
|
|
100
|
-
const field =
|
|
97
|
+
const field = writableField(target, key, "insert");
|
|
101
98
|
const left = field.outputType;
|
|
102
99
|
const right = expr.outputType;
|
|
103
|
-
if (!left || !right || left.dbType !== right.dbType || left.domain !== right.domain || left.idMeta !== right.idMeta) require_utils.fail(`INSERT SELECT ${
|
|
104
|
-
if (!field.sqlNullable && expr.sqlNullable === true) require_utils.fail(`INSERT SELECT ${
|
|
100
|
+
if (!left || !right || left.dbType !== right.dbType || left.domain !== right.domain || left.idMeta !== right.idMeta) require_utils.fail(`INSERT SELECT ${target.name}.${key} has incompatible or unknown storage codecs`);
|
|
101
|
+
if (!field.sqlNullable && expr.sqlNullable === true) require_utils.fail(`INSERT SELECT ${target.name}.${key} cannot accept a nullable output`);
|
|
105
102
|
}
|
|
106
103
|
const keys = fields.filter(([key]) => columns.some((column) => column[0] === key)).map(([key, field]) => [key, field.columnName]);
|
|
107
104
|
const sourceAlias = require_queries_sql_keywords.safeKq(assigner.getLiteralAlias("sq"));
|
|
@@ -115,11 +112,11 @@ function parseStatement(arg) {
|
|
|
115
112
|
const whereCondition = mutationCondition(statement.where, ctx);
|
|
116
113
|
if (Object.hasOwn(statement, "where") && !whereCondition && statement.allowAll !== true) require_utils.fail("UPDATE and DELETE require allowAll: true when a supplied where is undefined or fully pruned");
|
|
117
114
|
if (operation === "update") {
|
|
118
|
-
const entries =
|
|
115
|
+
const entries = assignments(target, statement.set, "update");
|
|
119
116
|
sql += " SET " + entries.map((entry) => {
|
|
120
117
|
const [key, value] = entry;
|
|
121
|
-
const field =
|
|
122
|
-
const cell =
|
|
118
|
+
const field = writableField(target, key, "update");
|
|
119
|
+
const cell = assignmentToSql(target, field, value, ctx);
|
|
123
120
|
bindings.push(...cell.bindings);
|
|
124
121
|
refs.push(...cell.refs);
|
|
125
122
|
return `${require_queries_sql_keywords.kq(field.columnName)} = ${cell.sql}`;
|
|
@@ -166,55 +163,66 @@ function decodeStatementResult(em, plan, result) {
|
|
|
166
163
|
rows: plan.decodeRows(em, result.rows)
|
|
167
164
|
};
|
|
168
165
|
}
|
|
166
|
+
/** Reads the writable columns from either kind of table handle, so the compiler has one target shape. */
|
|
167
|
+
function mutationTarget(target) {
|
|
168
|
+
if (require_queries_sql_Tables.isEntityTable(target)) {
|
|
169
|
+
const mgmt = require_queries_sql_Tables.getTableMgmt(target);
|
|
170
|
+
return {
|
|
171
|
+
mgmt,
|
|
172
|
+
name: mgmt.meta.type,
|
|
173
|
+
columns: mgmt.meta.columns,
|
|
174
|
+
meta: mgmt.meta
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (require_queries_sql_Tables.isCustomTable(target)) {
|
|
178
|
+
const mgmt = require_queries_sql_Tables.getTableMgmt(target);
|
|
179
|
+
return {
|
|
180
|
+
mgmt,
|
|
181
|
+
name: mgmt.tableName,
|
|
182
|
+
columns: mgmt.columns,
|
|
183
|
+
meta: void 0
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return require_utils.fail("A mutation target must be a table");
|
|
187
|
+
}
|
|
169
188
|
/** SQL mutations require complete physical metadata. */
|
|
170
|
-
function requireColumnMetadata(
|
|
171
|
-
if (typeof column.sqlNullable !== "boolean" || typeof column.hasDefault !== "boolean" || typeof column.isGenerated !== "boolean") require_utils.fail(`Missing physical metadata for ${
|
|
189
|
+
function requireColumnMetadata(target, column) {
|
|
190
|
+
if (typeof column.sqlNullable !== "boolean" || typeof column.hasDefault !== "boolean" || typeof column.isGenerated !== "boolean") require_utils.fail(`Missing physical metadata for ${target.name}.${column.columnName}; run codegen`);
|
|
172
191
|
}
|
|
173
192
|
/** Validates every supplied key, including undefined fields, before pruning omitted values. */
|
|
174
|
-
function assignments(
|
|
175
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || require_queries_sql_Expr.isExpr(value) || require_Entity.isEntity(value)) require_utils.fail(`${operation} assignments must be a field POJO`);
|
|
176
|
-
const entries = Object.entries(value);
|
|
177
|
-
for (const [key] of entries) writableField(meta, key, operation);
|
|
178
|
-
checkPojo(value, Object.keys(meta.columns), `${operation} assignments`);
|
|
179
|
-
const defined = entries.filter((entry) => entry[1] !== void 0);
|
|
180
|
-
if (!defined.length) require_utils.fail(`${operation} requires at least one defined field; empty rows/sets are not DEFAULT VALUES`);
|
|
181
|
-
return defined;
|
|
182
|
-
}
|
|
183
|
-
/** Validates assignment keys against a custom table's declared writable columns. */
|
|
184
|
-
function customAssignments(table, value, operation) {
|
|
193
|
+
function assignments(target, value, operation) {
|
|
185
194
|
if (!value || typeof value !== "object" || Array.isArray(value) || require_queries_sql_Expr.isExpr(value) || require_Entity.isEntity(value)) require_utils.fail(`${operation} assignments must be a field POJO`);
|
|
186
195
|
const entries = Object.entries(value);
|
|
187
|
-
for (const [key] of entries)
|
|
188
|
-
checkPojo(value, Object.keys(
|
|
196
|
+
for (const [key] of entries) writableField(target, key, operation);
|
|
197
|
+
checkPojo(value, Object.keys(target.columns), `${operation} assignments`);
|
|
189
198
|
const defined = entries.filter((entry) => entry[1] !== void 0);
|
|
190
199
|
if (!defined.length) require_utils.fail(`${operation} requires at least one defined field; empty rows/sets are not DEFAULT VALUES`);
|
|
191
200
|
return defined;
|
|
192
201
|
}
|
|
193
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
if (operation === "update" && key === "id") require_utils.fail("UPDATE primary-key assignments are not supported");
|
|
206
|
-
if (column.isGenerated) require_utils.fail(`Generated field ${meta.type}.${key} is omit-only`);
|
|
207
|
-
if (operation === "update" ? !column.update : column.insert === "never") require_utils.fail(`Unsupported SQL mutation field ${meta.type}.${key}`);
|
|
202
|
+
/**
|
|
203
|
+
* Applies physical write restrictions, not ORM-derived, protected, or business-immutable flags.
|
|
204
|
+
*
|
|
205
|
+
* The primary-key and generated checks only sharpen the message: `Column` already denies both through
|
|
206
|
+
* `insert: "never"` and `update: false`, for declared custom columns as well as generated ones.
|
|
207
|
+
*/
|
|
208
|
+
function writableField(target, key, operation) {
|
|
209
|
+
const column = Object.hasOwn(target.columns, key) ? target.columns[key] : void 0;
|
|
210
|
+
if (!column) require_utils.fail(`Unsupported SQL mutation field ${target.name}.${key}`);
|
|
211
|
+
if (operation === "update" && column.columnName === "id") require_utils.fail("UPDATE primary-key assignments are not supported");
|
|
212
|
+
if (column.isGenerated) require_utils.fail(`Generated field ${target.name}.${key} is omit-only`);
|
|
213
|
+
if (operation === "update" ? !column.update : column.insert === "never") require_utils.fail(`Unsupported SQL mutation field ${target.name}.${key}`);
|
|
208
214
|
return column;
|
|
209
215
|
}
|
|
210
216
|
/**
|
|
211
217
|
* Classifies SQL expressions and SQL NULL before invoking the column's entity-independent write codec.
|
|
212
218
|
* Normalizes public PK/FK ids to internal tagged ids using the target entity's idType.
|
|
219
|
+
*
|
|
220
|
+
* A custom table's columns never carry `idMetadata`, so they take the literal path below unchanged.
|
|
213
221
|
*/
|
|
214
|
-
function assignmentToSql(
|
|
222
|
+
function assignmentToSql(target, column, value, ctx) {
|
|
215
223
|
if (require_queries_sql_Expr.isExpr(value)) return require_queries_sql_Expr.asNode(value).toSql(ctx);
|
|
216
224
|
if (value === null) {
|
|
217
|
-
if (!column.sqlNullable) require_utils.fail(`${
|
|
225
|
+
if (!column.sqlNullable) require_utils.fail(`${target.name}.${column.columnName} is physically NOT NULL`);
|
|
218
226
|
return {
|
|
219
227
|
sql: "NULL",
|
|
220
228
|
bindings: [],
|
|
@@ -230,24 +238,7 @@ function assignmentToSql(meta, column, value, ctx) {
|
|
|
230
238
|
} else if (typeof value !== (other.idType === "number" ? "number" : "string")) require_utils.fail(`Expected a persisted ${other.type} or its ID; nested creation is not supported`);
|
|
231
239
|
else value = other.idType === "untagged-string" ? require_keys.keyToTaggedId(other, value) : require_keys.toTaggedId(other, value);
|
|
232
240
|
}
|
|
233
|
-
if (!column.codec.mapToDbValue) require_utils.fail(`The codec for ${
|
|
234
|
-
return {
|
|
235
|
-
sql: "?",
|
|
236
|
-
bindings: [column.mapToDbValue(value)],
|
|
237
|
-
refs: []
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
/** Encodes a custom-table literal through its declared primitive codec. */
|
|
241
|
-
function customAssignmentToSql(table, column, value, ctx) {
|
|
242
|
-
if (require_queries_sql_Expr.isExpr(value)) return require_queries_sql_Expr.asNode(value).toSql(ctx);
|
|
243
|
-
if (value === null) {
|
|
244
|
-
if (!column.sqlNullable) require_utils.fail(`${table.tableName}.${column.columnName} is physically NOT NULL`);
|
|
245
|
-
return {
|
|
246
|
-
sql: "NULL",
|
|
247
|
-
bindings: [],
|
|
248
|
-
refs: []
|
|
249
|
-
};
|
|
250
|
-
}
|
|
241
|
+
if (!column.codec.mapToDbValue) require_utils.fail(`The codec for ${target.name}.${column.columnName} does not support SQL value writes`);
|
|
251
242
|
return {
|
|
252
243
|
sql: "?",
|
|
253
244
|
bindings: [column.mapToDbValue(value)],
|