masterrecord 0.3.29 → 0.3.31

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.
@@ -111,8 +111,16 @@ class EntityModel {
111
111
 
112
112
  unique(){
113
113
  this.obj.unique = true; // yes
114
- return this;
114
+ return this;
115
+
116
+ }
115
117
 
118
+ index(indexName){
119
+ if(!this.obj.indexes){
120
+ this.obj.indexes = [];
121
+ }
122
+ this.obj.indexes.push(indexName || true);
123
+ return this;
116
124
  }
117
125
 
118
126
  // this means that it can be an empty field
@@ -46,17 +46,60 @@ class EntityModelBuilder {
46
46
  MDB.obj.name = methodName;
47
47
  obj[methodName] = MDB.obj;
48
48
  }
49
+
50
+ // Extract composite indexes from static property (Option A)
51
+ if (model.compositeIndexes) {
52
+ obj.__compositeIndexes = this.#normalizeCompositeIndexes(
53
+ model.compositeIndexes,
54
+ model.name
55
+ );
56
+ } else {
57
+ obj.__compositeIndexes = []; // Initialize empty array
58
+ }
59
+
49
60
  return obj;
50
61
  }
51
62
 
52
63
  static cleanNull(obj) {
53
- for (var propName in obj) {
64
+ for (var propName in obj) {
54
65
  if (obj[propName] === null) {
55
66
  delete obj[propName];
56
67
  }
57
68
  }
58
69
  }
59
70
 
71
+ static #normalizeCompositeIndexes(indexes, tableName) {
72
+ if (!Array.isArray(indexes)) {
73
+ throw new Error(`compositeIndexes must be an array`);
74
+ }
75
+
76
+ return indexes.map((index, i) => {
77
+ // Simple array: ['col1', 'col2'] -> auto-generate name
78
+ if (Array.isArray(index)) {
79
+ const colNames = index.join('_');
80
+ return {
81
+ columns: index,
82
+ name: `idx_${tableName.toLowerCase()}_${colNames}`,
83
+ unique: false
84
+ };
85
+ }
86
+
87
+ // Object: { columns: [...], name?, unique? }
88
+ if (!index.columns || !Array.isArray(index.columns)) {
89
+ throw new Error(`Composite index must have 'columns' array`);
90
+ }
91
+
92
+ const name = index.name ||
93
+ `idx_${tableName.toLowerCase()}_${index.columns.join('_')}`;
94
+
95
+ return {
96
+ columns: index.columns,
97
+ name: name,
98
+ unique: index.unique || false
99
+ };
100
+ });
101
+ }
102
+
60
103
  }
61
104
 
62
105
  module.exports = EntityModelBuilder;
@@ -232,6 +232,30 @@ class migrationMySQLQuery {
232
232
  return `ALTER TABLE \`${table.tableName}\` RENAME COLUMN \`${table.name}\` TO \`${table.newName}\``
233
233
  }
234
234
 
235
+ createIndex(indexInfo){
236
+ const indexName = indexInfo.indexName === true
237
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
238
+ : indexInfo.indexName;
239
+ return `CREATE INDEX \`${indexName}\` ON \`${indexInfo.tableName}\`(\`${indexInfo.columnName}\`)`;
240
+ }
241
+
242
+ dropIndex(indexInfo){
243
+ const indexName = indexInfo.indexName === true
244
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
245
+ : indexInfo.indexName;
246
+ return `DROP INDEX \`${indexName}\` ON \`${indexInfo.tableName}\``;
247
+ }
248
+
249
+ createCompositeIndex(indexInfo){
250
+ const columns = indexInfo.columns.map(c => `\`${c}\``).join(', ');
251
+ const uniqueKeyword = indexInfo.unique ? 'UNIQUE ' : '';
252
+ return `CREATE ${uniqueKeyword}INDEX \`${indexInfo.indexName}\` ON \`${indexInfo.tableName}\`(${columns})`;
253
+ }
254
+
255
+ dropCompositeIndex(indexInfo){
256
+ return `DROP INDEX \`${indexInfo.indexName}\` ON \`${indexInfo.tableName}\``;
257
+ }
258
+
235
259
  /**
236
260
  * SEED DATA METHODS
237
261
  * Support for inserting seed data during migrations
@@ -219,6 +219,30 @@ class migrationPostgresQuery {
219
219
  return `ALTER TABLE "${table.tableName}" RENAME COLUMN "${table.name}" TO "${table.newName}"`;
220
220
  }
221
221
 
222
+ createIndex(indexInfo){
223
+ const indexName = indexInfo.indexName === true
224
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
225
+ : indexInfo.indexName;
226
+ return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${indexInfo.tableName}"("${indexInfo.columnName}")`;
227
+ }
228
+
229
+ dropIndex(indexInfo){
230
+ const indexName = indexInfo.indexName === true
231
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
232
+ : indexInfo.indexName;
233
+ return `DROP INDEX IF EXISTS "${indexName}"`;
234
+ }
235
+
236
+ createCompositeIndex(indexInfo){
237
+ const columns = indexInfo.columns.map(c => `"${c}"`).join(', ');
238
+ const uniqueKeyword = indexInfo.unique ? 'UNIQUE ' : '';
239
+ return `CREATE ${uniqueKeyword}INDEX IF NOT EXISTS "${indexInfo.indexName}" ON "${indexInfo.tableName}"(${columns})`;
240
+ }
241
+
242
+ dropCompositeIndex(indexInfo){
243
+ return `DROP INDEX IF EXISTS "${indexInfo.indexName}"`;
244
+ }
245
+
222
246
  /**
223
247
  * SEED DATA METHODS
224
248
  * Support for inserting seed data during migrations
@@ -187,6 +187,30 @@ class migrationSQLiteQuery {
187
187
  return `ALTER TABLE ${table.tableName} RENAME COLUMN ${table.name} TO ${table.newName}`
188
188
  }
189
189
 
190
+ createIndex(indexInfo){
191
+ const indexName = indexInfo.indexName === true
192
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
193
+ : indexInfo.indexName;
194
+ return `CREATE INDEX IF NOT EXISTS ${indexName} ON ${indexInfo.tableName}(${indexInfo.columnName})`;
195
+ }
196
+
197
+ dropIndex(indexInfo){
198
+ const indexName = indexInfo.indexName === true
199
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
200
+ : indexInfo.indexName;
201
+ return `DROP INDEX IF EXISTS ${indexName}`;
202
+ }
203
+
204
+ createCompositeIndex(indexInfo){
205
+ const columns = indexInfo.columns.join(', ');
206
+ const uniqueKeyword = indexInfo.unique ? 'UNIQUE ' : '';
207
+ return `CREATE ${uniqueKeyword}INDEX IF NOT EXISTS ${indexInfo.indexName} ON ${indexInfo.tableName}(${columns})`;
208
+ }
209
+
210
+ dropCompositeIndex(indexInfo){
211
+ return `DROP INDEX IF EXISTS ${indexInfo.indexName}`;
212
+ }
213
+
190
214
  /**
191
215
  * SEED DATA METHODS
192
216
  * Support for inserting seed data during migrations
@@ -82,6 +82,76 @@ module.exports = ${this.name};
82
82
  }
83
83
  }
84
84
 
85
+ createIndex(type, indexInfo){
86
+ const indexName = indexInfo.indexName === true
87
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
88
+ : indexInfo.indexName;
89
+
90
+ const indexInfoStr = JSON.stringify({
91
+ tableName: indexInfo.tableName,
92
+ columnName: indexInfo.columnName,
93
+ indexName: indexInfo.indexName
94
+ });
95
+
96
+ if(type === "up"){
97
+ this.#up += os.EOL + ` this.createIndex(${indexInfoStr});`
98
+ }
99
+ else{
100
+ this.#down += os.EOL + ` this.dropIndex(${indexInfoStr});`
101
+ }
102
+ }
103
+
104
+ dropIndex(type, indexInfo){
105
+ const indexName = indexInfo.indexName === true
106
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
107
+ : indexInfo.indexName;
108
+
109
+ const indexInfoStr = JSON.stringify({
110
+ tableName: indexInfo.tableName,
111
+ columnName: indexInfo.columnName,
112
+ indexName: indexInfo.indexName
113
+ });
114
+
115
+ if(type === "up"){
116
+ this.#up += os.EOL + ` this.dropIndex(${indexInfoStr});`
117
+ }
118
+ else{
119
+ this.#down += os.EOL + ` this.createIndex(${indexInfoStr});`
120
+ }
121
+ }
122
+
123
+ createCompositeIndex(type, indexInfo){
124
+ const indexInfoStr = JSON.stringify({
125
+ tableName: indexInfo.tableName,
126
+ columns: indexInfo.columns,
127
+ indexName: indexInfo.indexName,
128
+ unique: indexInfo.unique
129
+ });
130
+
131
+ if(type === "up"){
132
+ this.#up += os.EOL + ` this.createCompositeIndex(${indexInfoStr});`
133
+ }
134
+ else{
135
+ this.#down += os.EOL + ` this.dropCompositeIndex(${indexInfoStr});`
136
+ }
137
+ }
138
+
139
+ dropCompositeIndex(type, indexInfo){
140
+ const indexInfoStr = JSON.stringify({
141
+ tableName: indexInfo.tableName,
142
+ columns: indexInfo.columns,
143
+ indexName: indexInfo.indexName,
144
+ unique: indexInfo.unique
145
+ });
146
+
147
+ if(type === "up"){
148
+ this.#up += os.EOL + ` this.dropCompositeIndex(${indexInfoStr});`
149
+ }
150
+ else{
151
+ this.#down += os.EOL + ` this.createCompositeIndex(${indexInfoStr});`
152
+ }
153
+ }
154
+
85
155
  }
86
156
 
87
157
  module.exports = MigrationTemplate;
@@ -24,7 +24,11 @@ class Migrations{
24
24
  newColumns : [],
25
25
  newTables : [],
26
26
  deletedColumns : [],
27
- updatedColumns : []
27
+ updatedColumns : [],
28
+ newIndexes : [],
29
+ deletedIndexes : [],
30
+ newCompositeIndexes : [],
31
+ deletedCompositeIndexes : []
28
32
  }
29
33
  tables.push(table);
30
34
  });
@@ -38,9 +42,13 @@ class Migrations{
38
42
  newColumns : [],
39
43
  newTables : [],
40
44
  deletedColumns : [],
41
- updatedColumns : []
45
+ updatedColumns : [],
46
+ newIndexes : [],
47
+ deletedIndexes : [],
48
+ newCompositeIndexes : [],
49
+ deletedCompositeIndexes : []
42
50
  }
43
-
51
+
44
52
  oldSchema.forEach(function (oldItem, index) {
45
53
  var oldItemName = oldItem["__name"];
46
54
  if(table.name === oldItemName){
@@ -48,7 +56,7 @@ class Migrations{
48
56
  tables.push(table);
49
57
  }
50
58
  });
51
-
59
+
52
60
  });
53
61
  }
54
62
 
@@ -156,11 +164,164 @@ class Migrations{
156
164
  #buildMigrationObject(oldSchema, newSchema){
157
165
 
158
166
  var tables = this.#organizeSchemaByTables(oldSchema, newSchema);
159
-
167
+
160
168
  tables = this.#findNewTables(tables);
161
169
  tables = this.#findNewColumns(tables);
162
170
  tables = this.#findDeletedColumns(tables);
163
171
  tables = this.#findUpdatedColumns(tables);
172
+ tables = this.#findNewIndexes(tables);
173
+ tables = this.#findDeletedIndexes(tables);
174
+ tables = this.#findNewCompositeIndexes(tables);
175
+ tables = this.#findDeletedCompositeIndexes(tables);
176
+ return tables;
177
+ }
178
+
179
+ #findNewIndexes(tables){
180
+ tables.forEach(function (item, index) {
181
+ if(item.new && item.old){
182
+ Object.keys(item.new).forEach(function (key) {
183
+ if(typeof item.new[key] === "object" && item.new[key].indexes){
184
+ var columnName = item.new[key].name;
185
+ var newIndexes = item.new[key].indexes;
186
+
187
+ // Check if this column existed before
188
+ var oldColumn = null;
189
+ Object.keys(item.old).forEach(function (oldKey) {
190
+ if(typeof item.old[oldKey] === "object" && item.old[oldKey].name === columnName){
191
+ oldColumn = item.old[oldKey];
192
+ }
193
+ });
194
+
195
+ // If column didn't exist before, or didn't have indexes, all indexes are new
196
+ if(!oldColumn || !oldColumn.indexes){
197
+ newIndexes.forEach(function(indexName){
198
+ item.newIndexes.push({
199
+ tableName: item.name,
200
+ columnName: columnName,
201
+ indexName: indexName
202
+ });
203
+ });
204
+ } else {
205
+ // Check for new indexes that weren't in the old column
206
+ newIndexes.forEach(function(indexName){
207
+ if(!oldColumn.indexes.includes(indexName)){
208
+ item.newIndexes.push({
209
+ tableName: item.name,
210
+ columnName: columnName,
211
+ indexName: indexName
212
+ });
213
+ }
214
+ });
215
+ }
216
+ }
217
+ });
218
+ }
219
+ });
220
+ return tables;
221
+ }
222
+
223
+ #findDeletedIndexes(tables){
224
+ tables.forEach(function (item, index) {
225
+ if(item.new && item.old){
226
+ Object.keys(item.old).forEach(function (key) {
227
+ if(typeof item.old[key] === "object" && item.old[key].indexes){
228
+ var columnName = item.old[key].name;
229
+ var oldIndexes = item.old[key].indexes;
230
+
231
+ // Check if this column still exists
232
+ var newColumn = null;
233
+ Object.keys(item.new).forEach(function (newKey) {
234
+ if(typeof item.new[newKey] === "object" && item.new[newKey].name === columnName){
235
+ newColumn = item.new[newKey];
236
+ }
237
+ });
238
+
239
+ // If column doesn't exist anymore, or doesn't have indexes, all indexes are deleted
240
+ if(!newColumn || !newColumn.indexes){
241
+ oldIndexes.forEach(function(indexName){
242
+ item.deletedIndexes.push({
243
+ tableName: item.name,
244
+ columnName: columnName,
245
+ indexName: indexName
246
+ });
247
+ });
248
+ } else {
249
+ // Check for indexes that were removed
250
+ oldIndexes.forEach(function(indexName){
251
+ if(!newColumn.indexes.includes(indexName)){
252
+ item.deletedIndexes.push({
253
+ tableName: item.name,
254
+ columnName: columnName,
255
+ indexName: indexName
256
+ });
257
+ }
258
+ });
259
+ }
260
+ }
261
+ });
262
+ }
263
+ });
264
+ return tables;
265
+ }
266
+
267
+ #findNewCompositeIndexes(tables) {
268
+ tables.forEach(function (item, index) {
269
+ if (item.new && item.old) {
270
+ const newComposite = item.new.__compositeIndexes || [];
271
+ const oldComposite = item.old.__compositeIndexes || [];
272
+
273
+ newComposite.forEach(function(newIdx) {
274
+ const exists = oldComposite.some(oldIdx =>
275
+ oldIdx.name === newIdx.name
276
+ );
277
+
278
+ if (!exists) {
279
+ item.newCompositeIndexes.push({
280
+ tableName: item.name,
281
+ columns: newIdx.columns,
282
+ indexName: newIdx.name,
283
+ unique: newIdx.unique
284
+ });
285
+ }
286
+ });
287
+ } else if (item.new && !item.old) {
288
+ // New table - all composite indexes are new
289
+ const composites = item.new.__compositeIndexes || [];
290
+ composites.forEach(function(idx) {
291
+ item.newCompositeIndexes.push({
292
+ tableName: item.name,
293
+ columns: idx.columns,
294
+ indexName: idx.name,
295
+ unique: idx.unique
296
+ });
297
+ });
298
+ }
299
+ });
300
+ return tables;
301
+ }
302
+
303
+ #findDeletedCompositeIndexes(tables) {
304
+ tables.forEach(function (item, index) {
305
+ if (item.new && item.old) {
306
+ const newComposite = item.new.__compositeIndexes || [];
307
+ const oldComposite = item.old.__compositeIndexes || [];
308
+
309
+ oldComposite.forEach(function(oldIdx) {
310
+ const exists = newComposite.some(newIdx =>
311
+ newIdx.name === oldIdx.name
312
+ );
313
+
314
+ if (!exists) {
315
+ item.deletedCompositeIndexes.push({
316
+ tableName: item.name,
317
+ columns: oldIdx.columns,
318
+ indexName: oldIdx.name,
319
+ unique: oldIdx.unique
320
+ });
321
+ }
322
+ });
323
+ }
324
+ });
164
325
  return tables;
165
326
  }
166
327
 
@@ -302,6 +463,10 @@ class Migrations{
302
463
  (t.newColumns && t.newColumns.length) ||
303
464
  (t.deletedColumns && t.deletedColumns.length) ||
304
465
  (t.updatedColumns && t.updatedColumns.length) ||
466
+ (t.newIndexes && t.newIndexes.length) ||
467
+ (t.deletedIndexes && t.deletedIndexes.length) ||
468
+ (t.newCompositeIndexes && t.newCompositeIndexes.length) ||
469
+ (t.deletedCompositeIndexes && t.deletedCompositeIndexes.length) ||
305
470
  (t.old === null) || (t.new === null)){
306
471
  return true;
307
472
  }
@@ -348,6 +513,22 @@ class Migrations{
348
513
  }
349
514
  });
350
515
 
516
+ item.newIndexes.forEach(function (indexInfo, index) {
517
+ MT.createIndex("up", indexInfo);
518
+ });
519
+
520
+ item.deletedIndexes.forEach(function (indexInfo, index) {
521
+ MT.dropIndex("up", indexInfo);
522
+ });
523
+
524
+ item.newCompositeIndexes.forEach(function (indexInfo, index) {
525
+ MT.createCompositeIndex("up", indexInfo);
526
+ });
527
+
528
+ item.deletedCompositeIndexes.forEach(function (indexInfo, index) {
529
+ MT.dropCompositeIndex("up", indexInfo);
530
+ });
531
+
351
532
  });
352
533
 
353
534
  return MT.get();
@@ -110,6 +110,35 @@ class schema{
110
110
  var query = queryBuilder.createTable(table);
111
111
  this.context._execute(query);
112
112
  }
113
+
114
+ // Create indexes for columns that have .index() defined
115
+ const self = this;
116
+ Object.keys(table).forEach(function(key){
117
+ if(typeof table[key] === "object" && table[key].indexes && !key.startsWith('__')){
118
+ const columnName = table[key].name;
119
+ table[key].indexes.forEach(function(indexName){
120
+ const indexInfo = {
121
+ tableName: tableName,
122
+ columnName: columnName,
123
+ indexName: indexName
124
+ };
125
+ self.createIndex(indexInfo);
126
+ });
127
+ }
128
+ });
129
+
130
+ // Create composite indexes
131
+ if (table.__compositeIndexes) {
132
+ table.__compositeIndexes.forEach(function(compositeIdx) {
133
+ const indexInfo = {
134
+ tableName: tableName,
135
+ columns: compositeIdx.columns,
136
+ indexName: compositeIdx.name,
137
+ unique: compositeIdx.unique
138
+ };
139
+ self.createCompositeIndex(indexInfo);
140
+ });
141
+ }
113
142
  }
114
143
  }else{
115
144
  console.log("Table that you're trying to create is undefined. Please check if there are any changes that need to be made");
@@ -394,6 +423,106 @@ class schema{
394
423
  }
395
424
  }
396
425
 
426
+ createIndex(indexInfo){
427
+ if(indexInfo){
428
+ if(this.context.isSQLite){
429
+ var sqliteQuery = require("./migrationSQLiteQuery");
430
+ var queryBuilder = new sqliteQuery();
431
+ var query = queryBuilder.createIndex(indexInfo);
432
+ this.context._execute(query);
433
+ }
434
+
435
+ if(this.context.isMySQL){
436
+ var sqlquery = require("./migrationMySQLQuery");
437
+ var queryBuilder = new sqlquery();
438
+ var query = queryBuilder.createIndex(indexInfo);
439
+ this.context._execute(query);
440
+ }
441
+
442
+ if(this.context.isPostgres){
443
+ var postgresQuery = require("./migrationPostgresQuery");
444
+ var queryBuilder = new postgresQuery();
445
+ var query = queryBuilder.createIndex(indexInfo);
446
+ this.context._execute(query);
447
+ }
448
+ }
449
+ }
450
+
451
+ dropIndex(indexInfo){
452
+ if(indexInfo){
453
+ if(this.context.isSQLite){
454
+ var sqliteQuery = require("./migrationSQLiteQuery");
455
+ var queryBuilder = new sqliteQuery();
456
+ var query = queryBuilder.dropIndex(indexInfo);
457
+ this.context._execute(query);
458
+ }
459
+
460
+ if(this.context.isMySQL){
461
+ var sqlquery = require("./migrationMySQLQuery");
462
+ var queryBuilder = new sqlquery();
463
+ var query = queryBuilder.dropIndex(indexInfo);
464
+ this.context._execute(query);
465
+ }
466
+
467
+ if(this.context.isPostgres){
468
+ var postgresQuery = require("./migrationPostgresQuery");
469
+ var queryBuilder = new postgresQuery();
470
+ var query = queryBuilder.dropIndex(indexInfo);
471
+ this.context._execute(query);
472
+ }
473
+ }
474
+ }
475
+
476
+ createCompositeIndex(indexInfo){
477
+ if(indexInfo){
478
+ if(this.context.isSQLite){
479
+ var sqliteQuery = require("./migrationSQLiteQuery");
480
+ var queryBuilder = new sqliteQuery();
481
+ var query = queryBuilder.createCompositeIndex(indexInfo);
482
+ this.context._execute(query);
483
+ }
484
+
485
+ if(this.context.isMySQL){
486
+ var sqlquery = require("./migrationMySQLQuery");
487
+ var queryBuilder = new sqlquery();
488
+ var query = queryBuilder.createCompositeIndex(indexInfo);
489
+ this.context._execute(query);
490
+ }
491
+
492
+ if(this.context.isPostgres){
493
+ var postgresQuery = require("./migrationPostgresQuery");
494
+ var queryBuilder = new postgresQuery();
495
+ var query = queryBuilder.createCompositeIndex(indexInfo);
496
+ this.context._execute(query);
497
+ }
498
+ }
499
+ }
500
+
501
+ dropCompositeIndex(indexInfo){
502
+ if(indexInfo){
503
+ if(this.context.isSQLite){
504
+ var sqliteQuery = require("./migrationSQLiteQuery");
505
+ var queryBuilder = new sqliteQuery();
506
+ var query = queryBuilder.dropCompositeIndex(indexInfo);
507
+ this.context._execute(query);
508
+ }
509
+
510
+ if(this.context.isMySQL){
511
+ var sqlquery = require("./migrationMySQLQuery");
512
+ var queryBuilder = new sqlquery();
513
+ var query = queryBuilder.dropCompositeIndex(indexInfo);
514
+ this.context._execute(query);
515
+ }
516
+
517
+ if(this.context.isPostgres){
518
+ var postgresQuery = require("./migrationPostgresQuery");
519
+ var queryBuilder = new postgresQuery();
520
+ var query = queryBuilder.dropCompositeIndex(indexInfo);
521
+ this.context._execute(query);
522
+ }
523
+ }
524
+ }
525
+
397
526
  seed(tableName, rows){
398
527
  if(!tableName || !rows){ return; }
399
528
  const items = Array.isArray(rows) ? rows : [rows];
package/context.js CHANGED
@@ -980,6 +980,10 @@ class context {
980
980
  }
981
981
 
982
982
  validModel.__name = tableName;
983
+
984
+ // Merge context-level composite indexes with entity-defined indexes
985
+ this.#mergeCompositeIndexes(validModel, tableName);
986
+
983
987
  this.__entities.push(validModel); // Store model object
984
988
  const buildMod = tools.createNewInstance(validModel, query, this);
985
989
  this.__builderEntities.push(buildMod); // Store query builder entity
@@ -994,6 +998,85 @@ class context {
994
998
  });
995
999
  }
996
1000
 
1001
+ /**
1002
+ * Define a composite index on an entity (Option C - Context-level)
1003
+ * @param {Function|string} model - Entity class or table name
1004
+ * @param {Array<string>} columns - Column names to include in index
1005
+ * @param {Object} options - Index options { name?: string, unique?: boolean }
1006
+ */
1007
+ compositeIndex(model, columns, options = {}) {
1008
+ // Resolve table name
1009
+ let tableName;
1010
+ if (typeof model === 'string') {
1011
+ tableName = model;
1012
+ } else if (typeof model === 'function') {
1013
+ tableName = model.name;
1014
+ } else {
1015
+ throw new Error('compositeIndex: model must be entity class or table name');
1016
+ }
1017
+
1018
+ // Validate columns
1019
+ if (!Array.isArray(columns) || columns.length < 2) {
1020
+ throw new Error('compositeIndex: columns must be array with at least 2 columns');
1021
+ }
1022
+
1023
+ // Auto-generate name if not provided
1024
+ const indexName = options.name ||
1025
+ `idx_${tableName.toLowerCase()}_${columns.join('_')}`;
1026
+
1027
+ const indexDef = {
1028
+ columns: columns,
1029
+ name: indexName,
1030
+ unique: options.unique || false
1031
+ };
1032
+
1033
+ // Store in context for later merging with entity-defined indexes
1034
+ if (!this.__contextCompositeIndexes) {
1035
+ this.__contextCompositeIndexes = {};
1036
+ }
1037
+ if (!this.__contextCompositeIndexes[tableName]) {
1038
+ this.__contextCompositeIndexes[tableName] = [];
1039
+ }
1040
+
1041
+ // Check for duplicate index names
1042
+ const existing = this.__contextCompositeIndexes[tableName].find(
1043
+ idx => idx.name === indexName
1044
+ );
1045
+ if (existing) {
1046
+ console.warn(`Warning: Composite index '${indexName}' already defined on ${tableName}`);
1047
+ return;
1048
+ }
1049
+
1050
+ this.__contextCompositeIndexes[tableName].push(indexDef);
1051
+ }
1052
+
1053
+ /**
1054
+ * Merge context-level and entity-level composite indexes
1055
+ * @private
1056
+ * @param {Object} entityObj - Entity object with __compositeIndexes
1057
+ * @param {string} tableName - Table name
1058
+ */
1059
+ #mergeCompositeIndexes(entityObj, tableName) {
1060
+ // Start with entity-defined indexes
1061
+ const entityIndexes = entityObj.__compositeIndexes || [];
1062
+
1063
+ // Add context-defined indexes
1064
+ const contextIndexes = (this.__contextCompositeIndexes &&
1065
+ this.__contextCompositeIndexes[tableName]) || [];
1066
+
1067
+ // Merge and deduplicate by name
1068
+ const allIndexes = [...entityIndexes];
1069
+ const existingNames = new Set(entityIndexes.map(idx => idx.name));
1070
+
1071
+ contextIndexes.forEach(idx => {
1072
+ if (!existingNames.has(idx.name)) {
1073
+ allIndexes.push(idx);
1074
+ }
1075
+ });
1076
+
1077
+ entityObj.__compositeIndexes = allIndexes;
1078
+ }
1079
+
997
1080
  /**
998
1081
  * Get current model validation state
999
1082
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "masterrecord",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
4
4
  "description": "An Object-relational mapping for the Master framework. Master Record connects classes to relational database tables to establish a database with almost zero-configuration ",
5
5
  "main": "MasterRecord.js",
6
6
  "bin": {
package/readme.md CHANGED
@@ -1790,6 +1790,336 @@ try {
1790
1790
 
1791
1791
  ---
1792
1792
 
1793
+ ## Field Constraints & Indexes
1794
+
1795
+ Define database constraints and performance indexes using the fluent API:
1796
+
1797
+ ```javascript
1798
+ class User {
1799
+ id(db) {
1800
+ db.integer().primary().auto();
1801
+ }
1802
+
1803
+ email(db) {
1804
+ db.string()
1805
+ .notNullable()
1806
+ .unique()
1807
+ .index(); // Creates performance index
1808
+ }
1809
+
1810
+ username(db) {
1811
+ db.string()
1812
+ .notNullable()
1813
+ .index('idx_username_custom'); // Custom index name
1814
+ }
1815
+
1816
+ status(db) {
1817
+ db.string().nullable();
1818
+ }
1819
+
1820
+ created_at(db) {
1821
+ db.timestamp().default('CURRENT_TIMESTAMP');
1822
+ }
1823
+ }
1824
+ ```
1825
+
1826
+ ### Available Constraint Methods
1827
+
1828
+ - `.notNullable()` - Column cannot be NULL
1829
+ - `.nullable()` - Column can be NULL (default)
1830
+ - `.unique()` - Unique constraint (enforces uniqueness at DB level)
1831
+ - `.index()` - Creates performance index (auto-generated name: `idx_tablename_columnname`)
1832
+ - `.index('custom_name')` - Creates index with custom name
1833
+ - `.primary()` - Primary key (automatically indexed)
1834
+ - `.default(value)` - Default value
1835
+
1836
+ ### Index vs Unique Constraint
1837
+
1838
+ **Understanding the difference:**
1839
+
1840
+ - `.unique()` creates a UNIQUE constraint (prevents duplicate values, enforces data integrity)
1841
+ - `.index()` creates a performance index (improves query speed, allows duplicates)
1842
+ - You can use both together: `.unique().index()` creates a unique index for both integrity and performance
1843
+
1844
+ **Examples:**
1845
+
1846
+ ```javascript
1847
+ // Email must be unique (no performance index)
1848
+ email(db) {
1849
+ db.string().notNullable().unique();
1850
+ }
1851
+
1852
+ // Username indexed for fast lookups (allows duplicates)
1853
+ username(db) {
1854
+ db.string().notNullable().index();
1855
+ }
1856
+
1857
+ // Email with both unique constraint AND performance index
1858
+ email(db) {
1859
+ db.string().notNullable().unique().index();
1860
+ }
1861
+ ```
1862
+
1863
+ ### Automatic Index Migration
1864
+
1865
+ When you add `.index()` to a field, MasterRecord automatically generates migration code:
1866
+
1867
+ ```javascript
1868
+ // In your entity
1869
+ class User {
1870
+ email(db) {
1871
+ db.string().notNullable().index();
1872
+ }
1873
+ }
1874
+
1875
+ // Generated migration (automatic)
1876
+ class Migration_20250101 extends masterrecord.schema {
1877
+ async up(table) {
1878
+ this.init(table);
1879
+ this.createIndex({
1880
+ tableName: 'User',
1881
+ columnName: 'email',
1882
+ indexName: 'idx_user_email'
1883
+ });
1884
+ }
1885
+
1886
+ async down(table) {
1887
+ this.init(table);
1888
+ this.dropIndex({
1889
+ tableName: 'User',
1890
+ columnName: 'email',
1891
+ indexName: 'idx_user_email'
1892
+ });
1893
+ }
1894
+ }
1895
+ ```
1896
+
1897
+ **Rollback support:**
1898
+
1899
+ Migrations automatically include rollback logic. Running `masterrecord migrate down` will drop all indexes created by that migration.
1900
+
1901
+ ---
1902
+
1903
+ ## Composite Indexes
1904
+
1905
+ Create multi-column indexes for queries that filter or sort on multiple columns together.
1906
+
1907
+ ### API - Two Ways to Define
1908
+
1909
+ **Option A: Entity Class (Recommended for core indexes)**
1910
+
1911
+ ```javascript
1912
+ class CreditLedger {
1913
+ id(db) {
1914
+ db.integer().primary().auto();
1915
+ }
1916
+
1917
+ organization_id(db) {
1918
+ db.integer().notNullable();
1919
+ }
1920
+
1921
+ created_at(db) {
1922
+ db.timestamp().default('CURRENT_TIMESTAMP');
1923
+ }
1924
+
1925
+ resource_type(db) {
1926
+ db.string().notNullable();
1927
+ }
1928
+
1929
+ resource_id(db) {
1930
+ db.integer().notNullable();
1931
+ }
1932
+
1933
+ // Define composite indexes in entity
1934
+ static compositeIndexes = [
1935
+ // Simple array - auto-generates name
1936
+ ['organization_id', 'created_at'],
1937
+ ['resource_type', 'resource_id'],
1938
+
1939
+ // With custom name
1940
+ {
1941
+ columns: ['status', 'created_at'],
1942
+ name: 'idx_status_timeline'
1943
+ },
1944
+
1945
+ // Unique composite index
1946
+ {
1947
+ columns: ['email', 'tenant_id'],
1948
+ unique: true
1949
+ }
1950
+ ];
1951
+ }
1952
+ ```
1953
+
1954
+ **Option C: Context-Level (For environment-specific or centralized schema)**
1955
+
1956
+ ```javascript
1957
+ class AppContext extends context {
1958
+ onConfig() {
1959
+ this.dbset(CreditLedger);
1960
+
1961
+ // Define composite indexes in context
1962
+ this.compositeIndex(CreditLedger, ['organization_id', 'created_at']);
1963
+ this.compositeIndex(CreditLedger, ['resource_type', 'resource_id']);
1964
+ this.compositeIndex(CreditLedger, ['status', 'created_at'], {
1965
+ name: 'idx_status_timeline'
1966
+ });
1967
+ this.compositeIndex(CreditLedger, ['email', 'tenant_id'], {
1968
+ unique: true
1969
+ });
1970
+
1971
+ // Can also use table name as string
1972
+ this.compositeIndex('CreditLedger', ['user_id', 'created_at']);
1973
+ }
1974
+ }
1975
+ ```
1976
+
1977
+ **Combined Usage (Best of Both)**
1978
+
1979
+ ```javascript
1980
+ class User {
1981
+ email(db) { db.string(); }
1982
+ tenant_id(db) { db.integer(); }
1983
+ last_name(db) { db.string(); }
1984
+ first_name(db) { db.string(); }
1985
+
1986
+ // Core indexes in entity
1987
+ static compositeIndexes = [
1988
+ ['last_name', 'first_name']
1989
+ ];
1990
+ }
1991
+
1992
+ class AppContext extends context {
1993
+ onConfig() {
1994
+ this.dbset(User);
1995
+
1996
+ // Add tenant-specific index for multi-tenant deployments
1997
+ if (process.env.MULTI_TENANT === 'true') {
1998
+ this.compositeIndex(User, ['tenant_id', 'email'], { unique: true });
1999
+ }
2000
+
2001
+ // Add performance index for production
2002
+ if (process.env.NODE_ENV === 'production') {
2003
+ this.compositeIndex(User, ['tenant_id', 'last_name']);
2004
+ }
2005
+ }
2006
+ }
2007
+ ```
2008
+
2009
+ ### When to Use Composite Indexes
2010
+
2011
+ Composite indexes are most effective for queries that:
2012
+ 1. **Filter on multiple columns**: `WHERE org_id = ? AND status = ?`
2013
+ 2. **Filter and sort**: `WHERE status = ? ORDER BY created_at`
2014
+ 3. **Enforce uniqueness**: Unique constraint on multiple columns together
2015
+
2016
+ **Example queries that benefit:**
2017
+
2018
+ ```javascript
2019
+ // Benefits from composite index (organization_id, created_at)
2020
+ const ledger = await db.CreditLedger
2021
+ .where(c => c.organization_id == $$, orgId)
2022
+ .orderBy(c => c.created_at)
2023
+ .toList();
2024
+
2025
+ // Benefits from composite index (resource_type, resource_id)
2026
+ const entry = await db.CreditLedger
2027
+ .where(c => c.resource_type == $$ && c.resource_id == $$, 'Order', 123)
2028
+ .single();
2029
+ ```
2030
+
2031
+ ### Column Order Matters
2032
+
2033
+ The order of columns in a composite index affects query performance:
2034
+
2035
+ ```javascript
2036
+ static compositeIndexes = [
2037
+ // Index: (status, created_at)
2038
+ ['status', 'created_at']
2039
+ ];
2040
+
2041
+ // ✅ FAST: Uses index efficiently
2042
+ // WHERE status = ? ORDER BY created_at
2043
+ await db.Orders
2044
+ .where(o => o.status == $$, 'pending')
2045
+ .orderBy(o => o.created_at)
2046
+ .toList();
2047
+
2048
+ // ⚠️ SLOWER: Can only use first column
2049
+ // WHERE created_at > ?
2050
+ await db.Orders
2051
+ .where(o => o.created_at > $$, yesterday)
2052
+ .toList();
2053
+ ```
2054
+
2055
+ **Rule of thumb:** Put the most selective (filtered) columns first, then sort columns.
2056
+
2057
+ ### Automatic Migration Generation
2058
+
2059
+ ```javascript
2060
+ // Your entity definition triggers migration
2061
+ class CreditLedger {
2062
+ organization_id(db) { db.integer(); }
2063
+ created_at(db) { db.timestamp(); }
2064
+
2065
+ static compositeIndexes = [
2066
+ ['organization_id', 'created_at']
2067
+ ];
2068
+ }
2069
+
2070
+ // Generated migration (automatic)
2071
+ class Migration_20250101 extends masterrecord.schema {
2072
+ async up(table) {
2073
+ this.init(table);
2074
+ this.createCompositeIndex({
2075
+ tableName: 'CreditLedger',
2076
+ columns: ['organization_id', 'created_at'],
2077
+ indexName: 'idx_creditleger_organization_id_created_at',
2078
+ unique: false
2079
+ });
2080
+ }
2081
+
2082
+ async down(table) {
2083
+ this.init(table);
2084
+ this.dropCompositeIndex({
2085
+ tableName: 'CreditLedger',
2086
+ columns: ['organization_id', 'created_at'],
2087
+ indexName: 'idx_creditleger_organization_id_created_at',
2088
+ unique: false
2089
+ });
2090
+ }
2091
+ }
2092
+ ```
2093
+
2094
+ ### Single vs Composite Indexes
2095
+
2096
+ ```javascript
2097
+ class User {
2098
+ email(db) {
2099
+ db.string().index(); // Single-column index
2100
+ }
2101
+
2102
+ first_name(db) {
2103
+ db.string(); // Part of composite below
2104
+ }
2105
+
2106
+ last_name(db) {
2107
+ db.string(); // Part of composite below
2108
+ }
2109
+
2110
+ static compositeIndexes = [
2111
+ // Composite index for name lookups
2112
+ ['last_name', 'first_name']
2113
+ ];
2114
+ }
2115
+ ```
2116
+
2117
+ **When to use single vs composite:**
2118
+ - **Single index**: Column queried independently (`WHERE email = ?`)
2119
+ - **Composite index**: Columns queried together (`WHERE last_name = ? AND first_name = ?`)
2120
+
2121
+ ---
2122
+
1793
2123
  ## Business Logic Validation
1794
2124
 
1795
2125
  Add validators to your entity definitions for automatic validation on property assignment.
@@ -2301,20 +2631,41 @@ await db.saveChanges(); // Batch insert
2301
2631
 
2302
2632
  ### 3. Use Indexes
2303
2633
 
2634
+ **Single-column indexes:**
2635
+
2304
2636
  ```javascript
2305
2637
  class User {
2306
- constructor() {
2307
- this.email = {
2308
- type: 'string',
2309
- unique: true // Automatically creates index
2310
- };
2638
+ email(db) {
2639
+ db.string().index(); // Single column
2311
2640
  }
2312
2641
  }
2642
+ ```
2643
+
2644
+ **Composite indexes for multi-column queries:**
2313
2645
 
2314
- // For complex queries, add database indexes manually
2315
- // CREATE INDEX idx_user_status ON User(status);
2646
+ ```javascript
2647
+ class Order {
2648
+ user_id(db) { db.integer(); }
2649
+ status(db) { db.string(); }
2650
+ created_at(db) { db.timestamp(); }
2651
+
2652
+ static compositeIndexes = [
2653
+ // For: WHERE user_id = ? AND status = ?
2654
+ ['user_id', 'status'],
2655
+
2656
+ // For: WHERE status = ? ORDER BY created_at
2657
+ ['status', 'created_at']
2658
+ ];
2659
+ }
2316
2660
  ```
2317
2661
 
2662
+ **Best practices:**
2663
+ - Index foreign keys for join performance
2664
+ - Use composite indexes for queries with multiple WHERE conditions
2665
+ - Column order matters: most selective (filtered) columns first
2666
+ - Don't over-index - each index adds write overhead
2667
+ - Primary keys are automatically indexed
2668
+
2318
2669
  ### 4. Limit Result Sets
2319
2670
 
2320
2671
  ```javascript