@farming-labs/orm-sql 0.0.11 → 0.0.12
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/index.cjs +266 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +266 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
toUniqueLookupWhere,
|
|
10
10
|
validateUniqueLookupUpdateData
|
|
11
11
|
} from "@farming-labs/orm";
|
|
12
|
+
var nativeNodeIdentity = /* @__PURE__ */ Symbol("nativeNodeIdentity");
|
|
12
13
|
var manifestCache = /* @__PURE__ */ new WeakMap();
|
|
13
14
|
function getManifest(schema) {
|
|
14
15
|
const cached = manifestCache.get(schema);
|
|
@@ -148,12 +149,12 @@ function mergeWhere(...clauses) {
|
|
|
148
149
|
AND: defined
|
|
149
150
|
};
|
|
150
151
|
}
|
|
151
|
-
function compileFieldFilter(model, fieldName, filter, dialect, state) {
|
|
152
|
+
function compileFieldFilter(model, fieldName, filter, dialect, state, tableAlias = model.table) {
|
|
152
153
|
const field = model.fields[fieldName];
|
|
153
154
|
if (!field) {
|
|
154
155
|
throw new Error(`Unknown field "${fieldName}" on model "${model.name}".`);
|
|
155
156
|
}
|
|
156
|
-
const column = `${quoteIdentifier(
|
|
157
|
+
const column = `${quoteIdentifier(tableAlias, dialect)}.${quoteIdentifier(field.column, dialect)}`;
|
|
157
158
|
const createValueExpression = (value) => {
|
|
158
159
|
const placeholder = createPlaceholder(dialect, state, encodeValue(field, dialect, value));
|
|
159
160
|
if (field.kind === "json" && dialect === "mysql") {
|
|
@@ -215,39 +216,39 @@ function compileFieldFilter(model, fieldName, filter, dialect, state) {
|
|
|
215
216
|
if (clauses.length === 1) return clauses[0];
|
|
216
217
|
return `(${clauses.join(" and ")})`;
|
|
217
218
|
}
|
|
218
|
-
function compileWhere(model, where, dialect, state) {
|
|
219
|
+
function compileWhere(model, where, dialect, state, tableAlias = model.table) {
|
|
219
220
|
if (!where) return void 0;
|
|
220
221
|
const clauses = [];
|
|
221
222
|
for (const [key, value] of Object.entries(where)) {
|
|
222
223
|
if (key === "AND") {
|
|
223
224
|
const items = Array.isArray(value) ? value : [];
|
|
224
225
|
if (!items.length) continue;
|
|
225
|
-
const nested = items.map((item) => compileWhere(model, item, dialect, state)).filter(Boolean).map((item) => `(${item})`);
|
|
226
|
+
const nested = items.map((item) => compileWhere(model, item, dialect, state, tableAlias)).filter(Boolean).map((item) => `(${item})`);
|
|
226
227
|
if (nested.length) clauses.push(nested.join(" and "));
|
|
227
228
|
continue;
|
|
228
229
|
}
|
|
229
230
|
if (key === "OR") {
|
|
230
231
|
const items = Array.isArray(value) ? value : [];
|
|
231
232
|
if (!items.length) continue;
|
|
232
|
-
const nested = items.map((item) => compileWhere(model, item, dialect, state)).filter(Boolean).map((item) => `(${item})`);
|
|
233
|
+
const nested = items.map((item) => compileWhere(model, item, dialect, state, tableAlias)).filter(Boolean).map((item) => `(${item})`);
|
|
233
234
|
if (nested.length) clauses.push(`(${nested.join(" or ")})`);
|
|
234
235
|
continue;
|
|
235
236
|
}
|
|
236
237
|
if (key === "NOT") {
|
|
237
|
-
const nested = compileWhere(model, value, dialect, state);
|
|
238
|
+
const nested = compileWhere(model, value, dialect, state, tableAlias);
|
|
238
239
|
if (nested) clauses.push(`not (${nested})`);
|
|
239
240
|
continue;
|
|
240
241
|
}
|
|
241
|
-
clauses.push(compileFieldFilter(model, key, value, dialect, state));
|
|
242
|
+
clauses.push(compileFieldFilter(model, key, value, dialect, state, tableAlias));
|
|
242
243
|
}
|
|
243
244
|
if (!clauses.length) return void 0;
|
|
244
245
|
return clauses.join(" and ");
|
|
245
246
|
}
|
|
246
|
-
function compileOrderBy(model, orderBy, dialect) {
|
|
247
|
+
function compileOrderBy(model, orderBy, dialect, tableAlias = model.table) {
|
|
247
248
|
if (!orderBy) return "";
|
|
248
249
|
const parts = Object.entries(orderBy).filter(([fieldName]) => fieldName in model.fields).map(([fieldName, direction]) => {
|
|
249
250
|
const field = model.fields[fieldName];
|
|
250
|
-
return `${quoteIdentifier(
|
|
251
|
+
return `${quoteIdentifier(tableAlias, dialect)}.${quoteIdentifier(field.column, dialect)} ${direction === "desc" ? "desc" : "asc"}`;
|
|
251
252
|
});
|
|
252
253
|
if (!parts.length) return "";
|
|
253
254
|
return ` order by ${parts.join(", ")}`;
|
|
@@ -270,13 +271,14 @@ function compilePagination(dialect, take, skip) {
|
|
|
270
271
|
}
|
|
271
272
|
function buildSelectStatement(model, dialect, args) {
|
|
272
273
|
const state = { params: [] };
|
|
274
|
+
const tableAlias = args.tableAlias ?? model.table;
|
|
273
275
|
const selectList = Object.values(model.fields).map(
|
|
274
|
-
(field) => `${quoteIdentifier(
|
|
276
|
+
(field) => `${quoteIdentifier(tableAlias, dialect)}.${quoteIdentifier(field.column, dialect)} as ${quoteIdentifier(field.name, dialect)}`
|
|
275
277
|
);
|
|
276
|
-
let sql = `select ${selectList.join(", ")} from ${quoteIdentifier(model.table, dialect)}`;
|
|
277
|
-
const where = compileWhere(model, args.where, dialect, state);
|
|
278
|
+
let sql = `select ${selectList.join(", ")} from ${quoteIdentifier(model.table, dialect)} as ${quoteIdentifier(tableAlias, dialect)}`;
|
|
279
|
+
const where = compileWhere(model, args.where, dialect, state, tableAlias);
|
|
278
280
|
if (where) sql += ` where ${where}`;
|
|
279
|
-
sql += compileOrderBy(model, args.orderBy, dialect);
|
|
281
|
+
sql += compileOrderBy(model, args.orderBy, dialect, tableAlias);
|
|
280
282
|
sql += compilePagination(dialect, args.take, args.skip);
|
|
281
283
|
return { sql, params: state.params };
|
|
282
284
|
}
|
|
@@ -578,6 +580,10 @@ function createMysqlPoolAdapter(pool) {
|
|
|
578
580
|
}
|
|
579
581
|
function createSqlDriver(adapter) {
|
|
580
582
|
async function loadRows(schema, modelName, args) {
|
|
583
|
+
const nativeRows = await loadRowsWithNativeJoins(schema, modelName, args);
|
|
584
|
+
if (nativeRows) {
|
|
585
|
+
return nativeRows;
|
|
586
|
+
}
|
|
581
587
|
const manifest = getManifest(schema);
|
|
582
588
|
const model = manifest.models[modelName];
|
|
583
589
|
const statement = buildSelectStatement(model, adapter.dialect, args);
|
|
@@ -603,6 +609,253 @@ function createSqlDriver(adapter) {
|
|
|
603
609
|
const row = result.rows[0];
|
|
604
610
|
return row ? decodeRow(model, adapter.dialect, row) : null;
|
|
605
611
|
}
|
|
612
|
+
function createNativePresenceAlias(model, alias, includeAllScalars, selectedScalarKeys) {
|
|
613
|
+
const occupiedAliases = new Set(
|
|
614
|
+
(includeAllScalars ? Object.keys(model.fields) : selectedScalarKeys).map(
|
|
615
|
+
(fieldName) => `${alias}__${fieldName}`
|
|
616
|
+
)
|
|
617
|
+
);
|
|
618
|
+
let candidate = `${alias}__orm_presence`;
|
|
619
|
+
let suffix = 0;
|
|
620
|
+
while (occupiedAliases.has(candidate)) {
|
|
621
|
+
suffix += 1;
|
|
622
|
+
candidate = `${alias}__orm_presence_${suffix}`;
|
|
623
|
+
}
|
|
624
|
+
return candidate;
|
|
625
|
+
}
|
|
626
|
+
function buildNativeJoinPlan(schema, modelName, select, aliasState) {
|
|
627
|
+
const manifest = getManifest(schema);
|
|
628
|
+
const model = manifest.models[modelName];
|
|
629
|
+
const alias = `t${aliasState.next++}`;
|
|
630
|
+
const entries = select ? Object.entries(select) : [];
|
|
631
|
+
const selectedScalarKeys = select ? entries.filter(([key, value]) => key in model.fields && value === true).map(([key]) => key) : Object.keys(model.fields);
|
|
632
|
+
const node = {
|
|
633
|
+
modelName,
|
|
634
|
+
model,
|
|
635
|
+
alias,
|
|
636
|
+
presenceAlias: createNativePresenceAlias(model, alias, !select, selectedScalarKeys),
|
|
637
|
+
includeAllScalars: !select,
|
|
638
|
+
selectedScalarKeys,
|
|
639
|
+
children: []
|
|
640
|
+
};
|
|
641
|
+
for (const [key, value] of entries) {
|
|
642
|
+
if (value === void 0 || !(key in schema.models[modelName].relations)) continue;
|
|
643
|
+
const relation = schema.models[modelName].relations[key];
|
|
644
|
+
const relationArgs = value === true ? {} : value;
|
|
645
|
+
if (relationArgs.where !== void 0 || relationArgs.orderBy !== void 0 || relationArgs.take !== void 0 || relationArgs.skip !== void 0) {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
const child = buildNativeJoinPlan(
|
|
649
|
+
schema,
|
|
650
|
+
relation.target,
|
|
651
|
+
relationArgs.select,
|
|
652
|
+
aliasState
|
|
653
|
+
);
|
|
654
|
+
if (!child) return null;
|
|
655
|
+
child.relationName = key;
|
|
656
|
+
child.relationKind = relation.kind;
|
|
657
|
+
if (relation.kind === "belongsTo") {
|
|
658
|
+
const sourceField = model.fields[relation.foreignKey];
|
|
659
|
+
if (!sourceField) return null;
|
|
660
|
+
const targetReference = parseReference(sourceField.references);
|
|
661
|
+
const targetFieldName = targetReference?.field ?? identityField(manifest.models[relation.target]).name;
|
|
662
|
+
const targetField = child.model.fields[targetFieldName];
|
|
663
|
+
if (!targetField) return null;
|
|
664
|
+
child.sourceField = sourceField;
|
|
665
|
+
child.targetField = targetField;
|
|
666
|
+
} else if (relation.kind === "hasOne" || relation.kind === "hasMany") {
|
|
667
|
+
const targetForeignField = child.model.fields[relation.foreignKey];
|
|
668
|
+
if (!targetForeignField) return null;
|
|
669
|
+
const sourceReference = parseReference(targetForeignField.references);
|
|
670
|
+
const sourceFieldName = sourceReference?.field ?? identityField(manifest.models[modelName]).name;
|
|
671
|
+
const sourceField = model.fields[sourceFieldName];
|
|
672
|
+
if (!sourceField) return null;
|
|
673
|
+
child.sourceField = sourceField;
|
|
674
|
+
child.targetField = targetForeignField;
|
|
675
|
+
} else {
|
|
676
|
+
const throughModel = manifest.models[relation.through];
|
|
677
|
+
const throughFromField = throughModel.fields[relation.from];
|
|
678
|
+
const throughToField = throughModel.fields[relation.to];
|
|
679
|
+
if (!throughFromField || !throughToField) return null;
|
|
680
|
+
const throughFromReference = parseReference(throughFromField.references);
|
|
681
|
+
const throughToReference = parseReference(throughToField.references);
|
|
682
|
+
const sourceFieldName = throughFromReference?.field ?? identityField(manifest.models[modelName]).name;
|
|
683
|
+
const targetFieldName = throughToReference?.field ?? identityField(child.model).name;
|
|
684
|
+
const sourceField = model.fields[sourceFieldName];
|
|
685
|
+
const targetField = child.model.fields[targetFieldName];
|
|
686
|
+
if (!sourceField || !targetField) return null;
|
|
687
|
+
child.sourceField = sourceField;
|
|
688
|
+
child.targetField = targetField;
|
|
689
|
+
child.throughModel = throughModel;
|
|
690
|
+
child.throughAlias = `t${aliasState.next++}`;
|
|
691
|
+
child.throughFromField = throughFromField;
|
|
692
|
+
child.throughToField = throughToField;
|
|
693
|
+
}
|
|
694
|
+
node.children.push(child);
|
|
695
|
+
}
|
|
696
|
+
return node;
|
|
697
|
+
}
|
|
698
|
+
function hasNativeJoinableRelations(schema, modelName, select) {
|
|
699
|
+
if (!select) return false;
|
|
700
|
+
const plan = buildNativeJoinPlan(schema, modelName, select, { next: 0 });
|
|
701
|
+
return !!plan && plan.children.length > 0;
|
|
702
|
+
}
|
|
703
|
+
function collectNativeJoinSelects(node, selectList) {
|
|
704
|
+
const scalarKeys = node.includeAllScalars ? Object.keys(node.model.fields) : node.selectedScalarKeys;
|
|
705
|
+
for (const fieldName of scalarKeys) {
|
|
706
|
+
const field = node.model.fields[fieldName];
|
|
707
|
+
if (!field) continue;
|
|
708
|
+
selectList.push(
|
|
709
|
+
`${quoteIdentifier(node.alias, adapter.dialect)}.${quoteIdentifier(field.column, adapter.dialect)} as ${quoteIdentifier(`${node.alias}__${field.name}`, adapter.dialect)}`
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
const identity = identityField(node.model);
|
|
713
|
+
selectList.push(
|
|
714
|
+
`${quoteIdentifier(node.alias, adapter.dialect)}.${quoteIdentifier(identity.column, adapter.dialect)} as ${quoteIdentifier(node.presenceAlias, adapter.dialect)}`
|
|
715
|
+
);
|
|
716
|
+
for (const child of node.children) {
|
|
717
|
+
collectNativeJoinSelects(child, selectList);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function collectNativeJoinClauses(node, joins) {
|
|
721
|
+
for (const child of node.children) {
|
|
722
|
+
if (child.relationKind === "manyToMany") {
|
|
723
|
+
joins.push(
|
|
724
|
+
` left join ${quoteIdentifier(child.throughModel.table, adapter.dialect)} as ${quoteIdentifier(child.throughAlias, adapter.dialect)} on ${quoteIdentifier(node.alias, adapter.dialect)}.${quoteIdentifier(child.sourceField.column, adapter.dialect)} = ${quoteIdentifier(child.throughAlias, adapter.dialect)}.${quoteIdentifier(child.throughFromField.column, adapter.dialect)}`
|
|
725
|
+
);
|
|
726
|
+
joins.push(
|
|
727
|
+
` left join ${quoteIdentifier(child.model.table, adapter.dialect)} as ${quoteIdentifier(child.alias, adapter.dialect)} on ${quoteIdentifier(child.throughAlias, adapter.dialect)}.${quoteIdentifier(child.throughToField.column, adapter.dialect)} = ${quoteIdentifier(child.alias, adapter.dialect)}.${quoteIdentifier(child.targetField.column, adapter.dialect)}`
|
|
728
|
+
);
|
|
729
|
+
} else {
|
|
730
|
+
const leftColumn = child.relationKind === "belongsTo" ? `${quoteIdentifier(node.alias, adapter.dialect)}.${quoteIdentifier(child.sourceField.column, adapter.dialect)}` : `${quoteIdentifier(child.alias, adapter.dialect)}.${quoteIdentifier(child.targetField.column, adapter.dialect)}`;
|
|
731
|
+
const rightColumn = child.relationKind === "belongsTo" ? `${quoteIdentifier(child.alias, adapter.dialect)}.${quoteIdentifier(child.targetField.column, adapter.dialect)}` : `${quoteIdentifier(node.alias, adapter.dialect)}.${quoteIdentifier(child.sourceField.column, adapter.dialect)}`;
|
|
732
|
+
joins.push(
|
|
733
|
+
` left join ${quoteIdentifier(child.model.table, adapter.dialect)} as ${quoteIdentifier(child.alias, adapter.dialect)} on ${leftColumn} = ${rightColumn}`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
collectNativeJoinClauses(child, joins);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function buildNativeJoinRootSource(root, args) {
|
|
740
|
+
const state = { params: [] };
|
|
741
|
+
const sourceAlias = `${root.alias}__src`;
|
|
742
|
+
const selectList = Object.values(root.model.fields).map(
|
|
743
|
+
(field) => `${quoteIdentifier(sourceAlias, adapter.dialect)}.${quoteIdentifier(field.column, adapter.dialect)} as ${quoteIdentifier(field.column, adapter.dialect)}`
|
|
744
|
+
);
|
|
745
|
+
let sql = `select ${selectList.join(", ")} from ${quoteIdentifier(root.model.table, adapter.dialect)} as ${quoteIdentifier(sourceAlias, adapter.dialect)}`;
|
|
746
|
+
const where = compileWhere(root.model, args.where, adapter.dialect, state, sourceAlias);
|
|
747
|
+
if (where) sql += ` where ${where}`;
|
|
748
|
+
sql += compileOrderBy(root.model, args.orderBy, adapter.dialect, sourceAlias);
|
|
749
|
+
sql += compilePagination(adapter.dialect, args.take, args.skip);
|
|
750
|
+
return {
|
|
751
|
+
sql: `(${sql})`,
|
|
752
|
+
params: state.params
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function buildNativeJoinStatement(root, args) {
|
|
756
|
+
const state = { params: [] };
|
|
757
|
+
const selectList = [];
|
|
758
|
+
const joins = [];
|
|
759
|
+
collectNativeJoinSelects(root, selectList);
|
|
760
|
+
collectNativeJoinClauses(root, joins);
|
|
761
|
+
const rootSource = buildNativeJoinRootSource(root, args);
|
|
762
|
+
state.params.push(...rootSource.params);
|
|
763
|
+
let sql = `select ${selectList.join(", ")} from ${rootSource.sql} as ${quoteIdentifier(root.alias, adapter.dialect)}`;
|
|
764
|
+
if (joins.length) sql += joins.join("");
|
|
765
|
+
sql += compileOrderBy(root.model, args.orderBy, adapter.dialect, root.alias);
|
|
766
|
+
return { sql, params: state.params };
|
|
767
|
+
}
|
|
768
|
+
function nodePresenceValue(node, rawRow) {
|
|
769
|
+
return rawRow[node.presenceAlias];
|
|
770
|
+
}
|
|
771
|
+
function projectNativeJoinNode(node, rawRow) {
|
|
772
|
+
if (nodePresenceValue(node, rawRow) == null) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
const output = {};
|
|
776
|
+
Object.defineProperty(output, nativeNodeIdentity, {
|
|
777
|
+
value: rawRow[node.presenceAlias],
|
|
778
|
+
enumerable: false,
|
|
779
|
+
configurable: true
|
|
780
|
+
});
|
|
781
|
+
const scalarKeys = node.includeAllScalars ? Object.keys(node.model.fields) : node.selectedScalarKeys;
|
|
782
|
+
for (const fieldName of scalarKeys) {
|
|
783
|
+
const field = node.model.fields[fieldName];
|
|
784
|
+
if (!field) continue;
|
|
785
|
+
output[field.name] = decodeValue(
|
|
786
|
+
field,
|
|
787
|
+
adapter.dialect,
|
|
788
|
+
rawRow[`${node.alias}__${field.name}`]
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
for (const child of node.children) {
|
|
792
|
+
const childValue = projectNativeJoinNode(child, rawRow);
|
|
793
|
+
output[child.relationName] = child.relationKind === "hasMany" || child.relationKind === "manyToMany" ? childValue ? [childValue] : [] : childValue;
|
|
794
|
+
}
|
|
795
|
+
return output;
|
|
796
|
+
}
|
|
797
|
+
function mergeNativeJoinNode(node, target, next) {
|
|
798
|
+
for (const child of node.children) {
|
|
799
|
+
const relationName = child.relationName;
|
|
800
|
+
if (child.relationKind === "hasMany" || child.relationKind === "manyToMany") {
|
|
801
|
+
const targetRows = Array.isArray(target[relationName]) ? target[relationName] : [];
|
|
802
|
+
const nextRows = Array.isArray(next[relationName]) ? next[relationName] : [];
|
|
803
|
+
if (!Array.isArray(target[relationName])) {
|
|
804
|
+
target[relationName] = targetRows;
|
|
805
|
+
}
|
|
806
|
+
for (const nextRow of nextRows) {
|
|
807
|
+
const identity = nextRow[nativeNodeIdentity];
|
|
808
|
+
const existing2 = targetRows.find((entry) => entry[nativeNodeIdentity] === identity);
|
|
809
|
+
if (existing2) {
|
|
810
|
+
mergeNativeJoinNode(child, existing2, nextRow);
|
|
811
|
+
} else {
|
|
812
|
+
targetRows.push(nextRow);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const nextValue = next[relationName];
|
|
818
|
+
if (nextValue === void 0) continue;
|
|
819
|
+
if (nextValue === null) {
|
|
820
|
+
if (!(relationName in target)) {
|
|
821
|
+
target[relationName] = null;
|
|
822
|
+
}
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
const existing = target[relationName];
|
|
826
|
+
if (!existing || typeof existing !== "object") {
|
|
827
|
+
target[relationName] = nextValue;
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
mergeNativeJoinNode(child, existing, nextValue);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
async function loadRowsWithNativeJoins(schema, modelName, args) {
|
|
834
|
+
if (!hasNativeJoinableRelations(schema, modelName, args.select)) {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
const plan = buildNativeJoinPlan(schema, modelName, args.select, { next: 0 });
|
|
838
|
+
if (!plan || !plan.children.length) {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
const statement = buildNativeJoinStatement(plan, args);
|
|
842
|
+
const result = await adapter.query(statement.sql, statement.params);
|
|
843
|
+
const groupedRows = [];
|
|
844
|
+
const groupedByIdentity = /* @__PURE__ */ new Map();
|
|
845
|
+
for (const row of result.rows) {
|
|
846
|
+
const projected = projectNativeJoinNode(plan, row);
|
|
847
|
+
if (!projected) continue;
|
|
848
|
+
const identity = projected[nativeNodeIdentity];
|
|
849
|
+
const existing = groupedByIdentity.get(identity);
|
|
850
|
+
if (existing) {
|
|
851
|
+
mergeNativeJoinNode(plan, existing, projected);
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
groupedByIdentity.set(identity, projected);
|
|
855
|
+
groupedRows.push(projected);
|
|
856
|
+
}
|
|
857
|
+
return groupedRows;
|
|
858
|
+
}
|
|
606
859
|
async function projectRow(schema, modelName, row, select) {
|
|
607
860
|
const manifest = getManifest(schema);
|
|
608
861
|
const model = manifest.models[modelName];
|