ilana-orm 1.0.17 → 1.0.18

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/README.md CHANGED
@@ -447,6 +447,9 @@ module.exports = {
447
447
  extension: "ts",
448
448
  },
449
449
 
450
+ // SQL query logging — logs every query with bound values and execution time
451
+ logging: process.env.NODE_ENV === "development",
452
+
450
453
  // Debugging
451
454
  debug: process.env.NODE_ENV === "development",
452
455
 
@@ -1518,11 +1521,11 @@ const users = await User.all();
1518
1521
 
1519
1522
  // Find by primary key
1520
1523
  const user = await User.find(1);
1521
- const user = await User.findOrFail(1); // Throws if not found
1524
+ const user = await User.findOrFail(1); // Throws ModelNotFoundException if not found
1522
1525
 
1523
1526
  // First record
1524
1527
  const user = await User.first();
1525
- const user = await User.firstOrFail(); // Throws if not found
1528
+ const user = await User.firstOrFail(); // Throws ModelNotFoundException if not found
1526
1529
 
1527
1530
  // Create or find
1528
1531
  const user = await User.firstOrCreate(
@@ -1545,11 +1548,11 @@ const users = await User.all();
1545
1548
 
1546
1549
  // Find by primary key
1547
1550
  const user = await User.find(1);
1548
- const user = await User.findOrFail(1); // Throws if not found
1551
+ const user = await User.findOrFail(1); // Throws ModelNotFoundException if not found
1549
1552
 
1550
1553
  // First record
1551
1554
  const user = await User.first();
1552
- const user = await User.firstOrFail(); // Throws if not found
1555
+ const user = await User.firstOrFail(); // Throws ModelNotFoundException if not found
1553
1556
 
1554
1557
  // Create or find
1555
1558
  const user = await User.firstOrCreate(
@@ -4022,6 +4025,160 @@ const users = await User.query().on("reporting_db").get();
4022
4025
 
4023
4026
  ````
4024
4027
 
4028
+ ## Debugging
4029
+
4030
+ ### Query Logging
4031
+
4032
+ Enable SQL query logging in `ilana.config.js`:
4033
+
4034
+ ```javascript
4035
+ // ilana.config.js
4036
+ module.exports = {
4037
+ default: 'mysql',
4038
+ logging: process.env.NODE_ENV === 'development', // logs all queries in dev
4039
+ connections: { ... }
4040
+ };
4041
+ ```
4042
+
4043
+ Or toggle programmatically:
4044
+
4045
+ ```javascript
4046
+ import { Database } from 'ilana-orm';
4047
+
4048
+ Database.enableLogging(); // turn on
4049
+ Database.disableLogging(); // turn off
4050
+ ```
4051
+
4052
+ Output:
4053
+ ```
4054
+ [IlanaORM] select * from "users" where "role" = 'admin' order by "created_at" desc limit 10 — 3ms
4055
+ [IlanaORM] select * from "posts" where "user_id" in (1, 2, 3) — 1ms
4056
+ ```
4057
+
4058
+ Inspect a query without executing it:
4059
+
4060
+ ```javascript
4061
+ const sql = User.query().where('role', 'admin').toSql();
4062
+ console.log(sql); // select * from "users" where "role" = 'admin'
4063
+ ```
4064
+
4065
+ ### ModelNotFoundException
4066
+
4067
+ `findOrFail()`, `firstOrFail()`, and `sole()` throw a `ModelNotFoundException` — a named error class you can catch specifically:
4068
+
4069
+ ```javascript
4070
+ import { ModelNotFoundException } from 'ilana-orm';
4071
+
4072
+ // In a route
4073
+ app.get('/users/:id', async (req, res) => {
4074
+ const user = await User.findOrFail(req.params.id);
4075
+ res.json(user);
4076
+ });
4077
+
4078
+ // Global Express error handler
4079
+ app.use((err, req, res, next) => {
4080
+ if (err instanceof ModelNotFoundException) {
4081
+ return res.status(404).json({ message: err.message });
4082
+ // "User with id 99 not found"
4083
+ }
4084
+ res.status(500).json({ message: 'Server error' });
4085
+ });
4086
+ ```
4087
+
4088
+ Properties: `err.message`, `err.model` (class name), `err.id` (the id passed to `findOrFail`).
4089
+
4090
+ Call `err.toResponse()` to get a plain `{ status: 404, message }` object suitable for any HTTP framework:
4091
+
4092
+ ```javascript
4093
+ app.get('/users/:id', async (req, res) => {
4094
+ try {
4095
+ return res.json(await User.findOrFail(req.params.id));
4096
+ } catch (err) {
4097
+ if (err instanceof ModelNotFoundException) {
4098
+ const { status, message } = err.toResponse();
4099
+ return res.status(status).json({ message });
4100
+ }
4101
+ throw err;
4102
+ }
4103
+ });
4104
+ ```
4105
+
4106
+ ### Column Expressions with `F()`
4107
+
4108
+ Reference a column's current value in an update — no raw SQL, no race conditions, no need to fetch first:
4109
+
4110
+ ```javascript
4111
+ import { F } from 'ilana-orm';
4112
+
4113
+ await Post.query().where('id', postId).update({ views: F('views').plus(1) });
4114
+ await Product.query().where('id', id).update({ stock: F('stock').minus(quantity) });
4115
+ ```
4116
+
4117
+ Available: `.plus(n)`, `.minus(n)`, `.times(n)`, `.divide(n)`.
4118
+
4119
+ ### Bulk Restore
4120
+
4121
+ Restore many soft-deleted records at once via the query builder:
4122
+
4123
+ ```javascript
4124
+ await User.query().onlyTrashed().where('role', 'admin').restore();
4125
+ // UPDATE users SET deleted_at = NULL WHERE role = 'admin' AND deleted_at IS NOT NULL
4126
+ ```
4127
+
4128
+ ### Enum Helpers
4129
+
4130
+ Define possible values for enum columns and get auto-generated `isX()` / `makeX()` helpers on every instance:
4131
+
4132
+ ```javascript
4133
+ class User extends Model {
4134
+ static enums = {
4135
+ role: ['user', 'moderator', 'admin'],
4136
+ status: ['active', 'suspended'],
4137
+ };
4138
+ }
4139
+
4140
+ const user = await User.find(1);
4141
+ user.isAdmin(); // true / false
4142
+ await user.makeAdmin(); // sets role = 'admin' and saves
4143
+ user.isSuspended(); // true / false
4144
+ ```
4145
+
4146
+ ### Strict Loading
4147
+
4148
+ Throw an error when an unloaded relation is accessed — catches N+1 problems at development time:
4149
+
4150
+ ```javascript
4151
+ class Post extends Model {
4152
+ static strictLoading = true;
4153
+ }
4154
+
4155
+ const posts = await Post.all(); // no .with('comments')
4156
+ posts[0].relations.comments; // throws: 'comments' was not eager loaded on Post
4157
+ ```
4158
+
4159
+ ### Touch
4160
+
4161
+ Automatically update a parent's `updated_at` whenever the child saves:
4162
+
4163
+ ```javascript
4164
+ class Comment extends Model {
4165
+ static touches = ['post'];
4166
+
4167
+ post() { return this.belongsTo('Post', 'post_id'); }
4168
+ }
4169
+
4170
+ await comment.save(); // also bumps posts SET updated_at = NOW() WHERE id = comment.post_id
4171
+ ```
4172
+
4173
+ ### Plain Object Results with `values()`
4174
+
4175
+ Return raw plain objects instead of model instances — faster for read-heavy endpoints where you don't need model methods:
4176
+
4177
+ ```javascript
4178
+ const users = await User.query().select('id', 'name', 'email').values();
4179
+ // [{ id: 1, name: 'John', email: 'john@example.com' }, ...]
4180
+ ```
4181
+
4025
4182
  ## TypeScript Support
4026
4183
 
4027
4184
  ### Type-Safe Models
@@ -4249,8 +4406,10 @@ User.updateOrCreate(search, update); // Update or create
4249
4406
  User.firstOrNew(search, create); // Find or new instance
4250
4407
  User.upsert(data, unique, update); // Upsert records
4251
4408
 
4252
- // Deletion methods
4253
- User.destroy(ids); // Delete by IDs
4409
+ // Deletion & seeding
4410
+ User.destroy(ids); // Delete by IDs (soft-delete aware)
4411
+ User.truncate(); // Delete all rows in the table
4412
+ User.seed(n); // Create n records using the registered factory
4254
4413
  User.withTrashed(); // Include soft deleted
4255
4414
  User.onlyTrashed(); // Only soft deleted
4256
4415
  User.withoutTrashed(); // Exclude soft deleted
@@ -4297,6 +4456,8 @@ user.update(attributes); // Update model
4297
4456
  user.delete(); // Delete model
4298
4457
  user.forceDelete(); // Force delete (ignores softDeletes)
4299
4458
  user.restore(); // Restore soft deleted
4459
+ user.fresh(); // Re-fetch from DB and return new instance
4460
+ user.is(other); // Check if two instances are the same record
4300
4461
 
4301
4462
  // Attributes
4302
4463
  user.fill(attributes); // Mass assign (respects fillable/guarded)
@@ -4435,6 +4596,18 @@ query.findOrFail(id); // Find or throw
4435
4596
  query.pluck(column); // Get column values
4436
4597
  query.exists(); // Check existence
4437
4598
  query.doesntExist(); // Check non-existence
4599
+ query.sole(); // Get exactly one result — throws if zero or more than one
4600
+ query.tap(callback); // Run a callback for debugging without breaking the chain
4601
+ query.values(); // Return plain objects instead of model instances
4602
+ ```
4603
+
4604
+ #### Soft Deletes (QueryBuilder)
4605
+
4606
+ ```javascript
4607
+ query.withTrashed(); // include soft-deleted records
4608
+ query.onlyTrashed(); // only soft-deleted records
4609
+ query.withoutTrashed(); // exclude soft-deleted (default)
4610
+ query.restore(); // bulk-restore matched soft-deleted records
4438
4611
  ```
4439
4612
 
4440
4613
  #### Pagination
package/cli/ilana.js CHANGED
@@ -127,7 +127,10 @@ import Database from 'ilana-orm/database/connection.js';
127
127
  const config = {
128
128
  default: process.env.DB_CONNECTION || 'mysql',
129
129
  timezone: process.env.DB_TIMEZONE || 'UTC',
130
-
130
+
131
+ // Set to true (or use process.env.NODE_ENV === 'development') to log all SQL queries
132
+ logging: false,
133
+
131
134
  connections: {
132
135
  sqlite: {
133
136
  client: 'sqlite3',
@@ -136,7 +139,7 @@ const config = {
136
139
  },
137
140
  useNullAsDefault: true
138
141
  },
139
-
142
+
140
143
  mysql: {
141
144
  client: 'mysql2',
142
145
  connection: {
@@ -148,7 +151,7 @@ const config = {
148
151
  timezone: process.env.DB_TIMEZONE || 'UTC'
149
152
  }
150
153
  },
151
-
154
+
152
155
  postgres: {
153
156
  client: 'pg',
154
157
  connection: {
@@ -160,12 +163,12 @@ const config = {
160
163
  }
161
164
  }
162
165
  },
163
-
166
+
164
167
  migrations: {
165
168
  directory: './${structure.databaseDir}/migrations',
166
169
  tableName: 'migrations'
167
170
  },
168
-
171
+
169
172
  seeds: {
170
173
  directory: './${structure.databaseDir}/seeds'
171
174
  }
@@ -186,7 +189,10 @@ const Database = require('ilana-orm/database/connection');
186
189
  const config = {
187
190
  default: process.env.DB_CONNECTION || 'mysql',
188
191
  timezone: process.env.DB_TIMEZONE || 'UTC',
189
-
192
+
193
+ // Set to true (or use process.env.NODE_ENV === 'development') to log all SQL queries
194
+ logging: false,
195
+
190
196
  connections: {
191
197
  sqlite: {
192
198
  client: 'sqlite3',
@@ -195,7 +201,7 @@ const config = {
195
201
  },
196
202
  useNullAsDefault: true
197
203
  },
198
-
204
+
199
205
  mysql: {
200
206
  client: 'mysql2',
201
207
  connection: {
@@ -207,7 +213,7 @@ const config = {
207
213
  timezone: process.env.DB_TIMEZONE || 'UTC'
208
214
  }
209
215
  },
210
-
216
+
211
217
  postgres: {
212
218
  client: 'pg',
213
219
  connection: {
@@ -219,12 +225,12 @@ const config = {
219
225
  }
220
226
  }
221
227
  },
222
-
228
+
223
229
  migrations: {
224
230
  directory: './${structure.databaseDir}/migrations',
225
231
  tableName: 'migrations'
226
232
  },
227
-
233
+
228
234
  seeds: {
229
235
  directory: './${structure.databaseDir}/seeds'
230
236
  }
@@ -823,13 +829,13 @@ function parseMigrationsForTable(tableName, migrationsDir) {
823
829
 
824
830
  // Match column definitions: table.string('col'), table.integer('col').nullable(), etc.
825
831
  const colRegex = new RegExp(
826
- `${alias}\\.(\\w+)\\s*\\(\\s*['"]([^'"]+)['"](?:[^)]*)?\\)([^;\\n]*)`,
832
+ `${alias}\\.(\\w+)\\s*\\(\\s*['"]([^'"]+)['"]([^)]*)\\)([^;\\n]*)`,
827
833
  'g'
828
834
  );
829
835
 
830
836
  let colMatch;
831
837
  while ((colMatch = colRegex.exec(block)) !== null) {
832
- const [, method, colName, rest] = colMatch;
838
+ const [, method, colName, args, rest] = colMatch;
833
839
  const knexDef = KNEX_COLUMN_MAP[method];
834
840
  if (!knexDef) continue;
835
841
 
@@ -837,7 +843,17 @@ function parseMigrationsForTable(tableName, migrationsDir) {
837
843
  const isNotNullable = /\.notNullable\(\)/.test(rest);
838
844
  const nullable = isNullable ? true : isNotNullable ? false : knexDef.nullable;
839
845
 
840
- columns[colName] = { type: knexDef.type, nullable };
846
+ // For enum columns, extract the values array and build a union type
847
+ let tsType = knexDef.type;
848
+ if (method === 'enum') {
849
+ const valuesMatch = args.match(/\[([^\]]+)\]/);
850
+ if (valuesMatch) {
851
+ const values = [...valuesMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => `'${m[1]}'`);
852
+ if (values.length) tsType = values.join(' | ');
853
+ }
854
+ }
855
+
856
+ columns[colName] = { type: tsType, nullable };
841
857
  }
842
858
  }
843
859
  }
@@ -2,6 +2,7 @@ import { Knex } from 'knex';
2
2
 
3
3
  export interface DatabaseConfig {
4
4
  default: string;
5
+ logging?: boolean;
5
6
  connections: {
6
7
  [name: string]: Knex.Config;
7
8
  };
@@ -27,6 +28,8 @@ export default class Database {
27
28
  private static _currentTransaction: Knex.Transaction | null;
28
29
 
29
30
  static configure(config: DatabaseConfig): void;
31
+ static enableLogging(): void;
32
+ static disableLogging(): void;
30
33
  static connection(name?: string): Knex;
31
34
  static getDefaultConnection(): string;
32
35
  static hasConnection(name: string): boolean;
@@ -3,11 +3,33 @@ const knex = require('knex');
3
3
  class Database {
4
4
  static connections = new Map();
5
5
  static _currentTransaction = null;
6
+ static _logging = false;
7
+
8
+ static enableLogging() {
9
+ this._logging = true;
10
+ }
11
+
12
+ static disableLogging() {
13
+ this._logging = false;
14
+ }
15
+
16
+ static _log(sql, bindings, ms) {
17
+ if (!this._logging) return;
18
+ const bound = sql.replace(/\?/g, () => {
19
+ const val = bindings?.shift();
20
+ if (val === null || val === undefined) return 'NULL';
21
+ if (typeof val === 'string') return `'${val}'`;
22
+ return val;
23
+ });
24
+ const time = ms !== undefined ? ` \x1b[90m— ${ms}ms\x1b[0m` : '';
25
+ console.log(`\x1b[36m[IlanaORM]\x1b[0m ${bound}${time}`);
26
+ }
6
27
 
7
28
  static configure(config) {
8
29
  this.config = config;
9
30
  this.defaultConnection = config.default;
10
-
31
+ if (config.logging) this._logging = true;
32
+
11
33
  // Initialize all configured connections
12
34
  for (const [name, connConfig] of Object.entries(config.connections)) {
13
35
  try {
@@ -21,6 +43,23 @@ class Database {
21
43
  directory: './seeds'
22
44
  }
23
45
  });
46
+
47
+ // Attach query logger
48
+ connection.on('query', (query) => {
49
+ this._queryStart = Date.now();
50
+ this._pendingQuery = query.sql;
51
+ this._pendingBindings = [...(query.bindings || [])];
52
+ });
53
+ connection.on('query-response', (response, query) => {
54
+ const ms = Date.now() - (this._queryStart || Date.now());
55
+ this._log(query.sql, [...(query.bindings || [])], ms);
56
+ });
57
+ connection.on('query-error', (error, query) => {
58
+ if (this._logging) {
59
+ console.error(`\x1b[36m[IlanaORM]\x1b[0m \x1b[31mERROR\x1b[0m ${query.sql}`);
60
+ }
61
+ });
62
+
24
63
  this.connections.set(name, connection);
25
64
  } catch (error) {
26
65
  if (error.code === 'MODULE_NOT_FOUND' && error.message.includes(connConfig.client)) {
package/ilana.config.js CHANGED
@@ -1,4 +1,4 @@
1
- const Database = require('ilana/database/connection').default;
1
+ const Database = require('./database/connection');
2
2
 
3
3
  const config = {
4
4
  default: 'sqlite',
package/index.d.ts CHANGED
@@ -5,5 +5,22 @@ export { default as Database } from './database/connection';
5
5
  export * from './orm/Relation';
6
6
  export * from './orm/CustomCasts';
7
7
 
8
+ export declare class FExpression {
9
+ constructor(column: string);
10
+ plus(n: number): any;
11
+ minus(n: number): any;
12
+ times(n: number): any;
13
+ divide(n: number): any;
14
+ }
15
+ export declare function F(column: string): FExpression;
16
+
17
+ export declare class ModelNotFoundException extends Error {
18
+ name: 'ModelNotFoundException';
19
+ model: string;
20
+ id?: any;
21
+ constructor(model: string, id?: any);
22
+ toResponse(): { status: 404; message: string };
23
+ }
24
+
8
25
  // Default export
9
26
  export { default } from './orm/Model';
package/index.js CHANGED
@@ -10,6 +10,8 @@ const Seeder = require('./orm/Seeder');
10
10
  const Factory = require('./orm/Factory');
11
11
  const Relation = require('./orm/Relation');
12
12
  const CustomCasts = require('./orm/CustomCasts');
13
+ const { ModelNotFoundException } = require('./orm/Errors');
14
+ const { F } = require('./orm/F');
13
15
 
14
16
  module.exports = {
15
17
  Model,
@@ -22,11 +24,13 @@ module.exports = {
22
24
  Seeder,
23
25
  Factory: Factory.Factory,
24
26
  defineFactory: Factory.defineFactory,
25
-
27
+ ModelNotFoundException,
28
+ F,
29
+
26
30
  // Relationships
27
31
  MorphOne: Relation.MorphOne,
28
32
  ...Relation,
29
-
33
+
30
34
  // Custom Casts
31
35
  ...CustomCasts
32
36
  };
package/index.mjs CHANGED
@@ -15,6 +15,8 @@ export const {
15
15
  Seeder,
16
16
  Factory,
17
17
  defineFactory,
18
+ ModelNotFoundException,
19
+ F,
18
20
  Relation,
19
21
  HasOne,
20
22
  HasMany,
package/jest.config.js ADDED
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ testEnvironment: 'node',
3
+ setupFiles: ['<rootDir>/tests/setup.js'],
4
+ };
package/orm/Errors.js ADDED
@@ -0,0 +1,18 @@
1
+ class ModelNotFoundException extends Error {
2
+ constructor(model, id) {
3
+ const message = id !== undefined
4
+ ? `${model} with id ${id} not found`
5
+ : `${model} not found`;
6
+ super(message);
7
+ this.name = 'ModelNotFoundException';
8
+ this.model = model;
9
+ this.id = id;
10
+ if (Error.captureStackTrace) Error.captureStackTrace(this, ModelNotFoundException);
11
+ }
12
+
13
+ toResponse() {
14
+ return { status: 404, message: this.message };
15
+ }
16
+ }
17
+
18
+ module.exports = { ModelNotFoundException };
package/orm/F.js ADDED
@@ -0,0 +1,22 @@
1
+ const Database = require('../database/connection');
2
+
3
+ class FExpression {
4
+ constructor(column) {
5
+ this.column = column;
6
+ }
7
+
8
+ _raw(op, n) {
9
+ return Database.raw(`?? ${op} ?`, [this.column, n]);
10
+ }
11
+
12
+ plus(n) { return this._raw('+', n); }
13
+ minus(n) { return this._raw('-', n); }
14
+ times(n) { return this._raw('*', n); }
15
+ divide(n) { return this._raw('/', n); }
16
+ }
17
+
18
+ function F(column) {
19
+ return new FExpression(column);
20
+ }
21
+
22
+ module.exports = { F, FExpression };
package/orm/Factory.js CHANGED
@@ -14,6 +14,9 @@ class Factory {
14
14
  this.count = 1;
15
15
  this.currentStates = [];
16
16
  this.relationships = new Map();
17
+ this._has = new Map();
18
+ this._for = new Map();
19
+ this._hasAttached = new Map();
17
20
  this._sequenceCount = 0;
18
21
  this.sequences = new Map();
19
22
  }
@@ -57,23 +60,30 @@ class Factory {
57
60
  return this;
58
61
  }
59
62
 
60
- for(relation, factory) {
61
- this.relationships.set(relation, factory);
63
+ for(relation, relFactory) {
64
+ this._for.set(relation, relFactory);
62
65
  return this;
63
66
  }
64
67
 
65
- has(factory, relation) {
66
- if (relation) {
67
- this.relationships.set(relation, factory);
68
- }
68
+ has(relFactory, relation) {
69
+ this._has.set(relation, relFactory);
69
70
  return this;
70
71
  }
71
72
 
72
- hasAttached(factory, relation) {
73
- this.relationships.set(relation, factory);
73
+ hasAttached(relFactory, relation) {
74
+ this._hasAttached.set(relation, relFactory);
74
75
  return this;
75
76
  }
76
77
 
78
+ _getRelation(relationName) {
79
+ try {
80
+ const dummy = new this.model({});
81
+ return typeof dummy[relationName] === 'function' ? dummy[relationName]() : null;
82
+ } catch (_) {
83
+ return null;
84
+ }
85
+ }
86
+
77
87
  sequence() {
78
88
  return ++this._sequenceCount;
79
89
  }
@@ -173,16 +183,40 @@ class Factory {
173
183
  }
174
184
 
175
185
  async createOne(attributes = {}) {
176
- const model = await this.makeOne(attributes);
177
-
178
- // Run beforeCreating callbacks
186
+ // Handle 'for' (belongsTo) — create parent first, inject FK into this model
187
+ const parentAttrs = {};
188
+ for (const [relationName, relFactory] of this._for) {
189
+ const parent = await relFactory.createOne({});
190
+ const rel = this._getRelation(relationName);
191
+ if (rel && rel.foreignKey) parentAttrs[rel.foreignKey] = parent.getKey();
192
+ }
193
+
194
+ const model = await this.makeOne({ ...parentAttrs, ...attributes });
195
+
179
196
  for (const callback of this._beforeCreatingCallbacks) {
180
197
  await callback(model);
181
198
  }
182
199
 
183
200
  await model.save();
184
201
 
185
- // Run afterCreating callbacks
202
+ // Handle 'has' (hasMany) — create children with FK pointing to this model
203
+ for (const [relationName, relFactory] of this._has) {
204
+ const rel = this._getRelation(relationName);
205
+ if (rel && rel.foreignKey) {
206
+ await relFactory.create({ [rel.foreignKey]: model.getKey() });
207
+ }
208
+ }
209
+
210
+ // Handle 'hasAttached' (belongsToMany) — create and attach via pivot
211
+ for (const [relationName, relFactory] of this._hasAttached) {
212
+ const related = await relFactory.create({});
213
+ const relatedArr = Array.isArray(related) ? related : [related];
214
+ const rel = this._getRelation(relationName);
215
+ if (rel && typeof rel.attach === 'function') {
216
+ for (const r of relatedArr) await rel.attach(r.getKey());
217
+ }
218
+ }
219
+
186
220
  for (const callback of this._afterCreatingCallbacks) {
187
221
  await callback(model);
188
222
  }
@@ -354,6 +388,9 @@ if (typeof Model !== 'undefined') {
354
388
  newFactory.states = new Map(existingFactory.states);
355
389
  newFactory._afterCreatingCallbacks = [...existingFactory._afterCreatingCallbacks];
356
390
  newFactory._beforeCreatingCallbacks = [...existingFactory._beforeCreatingCallbacks];
391
+ newFactory._has = new Map(existingFactory._has);
392
+ newFactory._for = new Map(existingFactory._for);
393
+ newFactory._hasAttached = new Map(existingFactory._hasAttached);
357
394
  return newFactory;
358
395
  }
359
396
  throw new Error(`No factory defined for model: ${this.name}`);
package/orm/Model.d.ts CHANGED
@@ -50,6 +50,9 @@ export default class Model<TAttributes extends ModelAttributes = ModelAttributes
50
50
  protected static globalScopes: Map<string, (query: QueryBuilder) => void>;
51
51
  protected static appends: string[];
52
52
  protected static timezone: string;
53
+ static strictLoading: boolean;
54
+ static touches: string[];
55
+ static enums: { [column: string]: string[] };
53
56
 
54
57
  // Instance properties
55
58
  attributes: ModelAttributes;
@@ -90,6 +93,8 @@ export default class Model<TAttributes extends ModelAttributes = ModelAttributes
90
93
  static generateUuid(): string;
91
94
  static insert(data: ModelAttributes | ModelAttributes[]): Promise<any>;
92
95
  static destroy(ids: any | any[]): Promise<number>;
96
+ static truncate(): Promise<void>;
97
+ static seed(count?: number): Promise<any[]>;
93
98
  static firstOrCreate(attributes: ModelAttributes, values?: ModelAttributes): Promise<Model>;
94
99
  static firstOrNew(attributes: ModelAttributes, values?: ModelAttributes): Promise<Model>;
95
100
  static updateOrCreate(attributes: ModelAttributes, values?: ModelAttributes): Promise<Model>;
@@ -143,10 +148,14 @@ export default class Model<TAttributes extends ModelAttributes = ModelAttributes
143
148
  getDirty(): ModelAttributes;
144
149
  delete(): Promise<boolean>;
145
150
  restore(): Promise<boolean>;
151
+ increment(column: string, amount?: number): Promise<this>;
152
+ decrement(column: string, amount?: number): Promise<this>;
146
153
  trashed(): boolean;
147
154
  only(keys: string[]): ModelAttributes;
148
155
  except(keys: string[]): ModelAttributes;
149
156
  forceDelete(): Promise<boolean>;
157
+ fresh(): Promise<this | null>;
158
+ is(other: Model): boolean;
150
159
  toJSON(): any;
151
160
 
152
161
  // Relationships
package/orm/Model.js CHANGED
@@ -35,6 +35,9 @@ class Model {
35
35
  static globalScopes = new Map();
36
36
  static appends = [];
37
37
  static timezone = 'UTC';
38
+ static strictLoading = false;
39
+ static touches = [];
40
+ static enums = {};
38
41
 
39
42
  // --- Instance props ---
40
43
  attributes = {};
@@ -62,6 +65,25 @@ class Model {
62
65
  ? this.appends
63
66
  : this.constructor.appends;
64
67
 
68
+ // Wrap relations in a Proxy for strict loading enforcement
69
+ const modelClass = this.constructor;
70
+ this.relations = new Proxy({}, {
71
+ get(target, key) {
72
+ if (typeof key !== 'string') return target[key];
73
+ if (modelClass.strictLoading && !(key in target)) {
74
+ throw new Error(
75
+ `Strict loading violation: '${key}' was not eager loaded on ${modelClass.name}. ` +
76
+ `Use .with('${key}') in your query.`
77
+ );
78
+ }
79
+ return target[key];
80
+ },
81
+ set(target, key, value) { target[key] = value; return true; },
82
+ has(target, key) { return key in target; },
83
+ ownKeys(target) { return Object.keys(target); },
84
+ getOwnPropertyDescriptor(target, key) { return Object.getOwnPropertyDescriptor(target, key); },
85
+ });
86
+
65
87
  // defer attribute setting
66
88
  if (attrs && Object.keys(attrs).length) this._deferred = attrs;
67
89
 
@@ -79,6 +101,7 @@ class Model {
79
101
  this._deferred = null;
80
102
  // Recreate getters after initialization
81
103
  this._createAttributeGetters();
104
+ this._generateEnumHelpers();
82
105
  }
83
106
 
84
107
  _createAttributeGetters() {
@@ -106,6 +129,26 @@ class Model {
106
129
  }
107
130
  }
108
131
 
132
+ _generateEnumHelpers() {
133
+ const enums = this.constructor.enums || {};
134
+ for (const [column, values] of Object.entries(enums)) {
135
+ for (const value of values) {
136
+ const pascal = value.charAt(0).toUpperCase() + value.slice(1);
137
+ const isMethod = `is${pascal}`;
138
+ const makeMethod = `make${pascal}`;
139
+ if (!this[isMethod]) {
140
+ this[isMethod] = () => this.getAttribute(column) === value;
141
+ }
142
+ if (!this[makeMethod]) {
143
+ this[makeMethod] = async () => {
144
+ this.setAttribute(column, value);
145
+ return this.save();
146
+ };
147
+ }
148
+ }
149
+ }
150
+ }
151
+
109
152
  _toPascalCase(key) {
110
153
  return key.replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase());
111
154
  }
@@ -210,6 +253,11 @@ class Model {
210
253
  }
211
254
 
212
255
  static async insert(data) { return this.query().insert(data); }
256
+ static async truncate() { return this.query().toKnex().truncate(); }
257
+ static async seed(count = 1) {
258
+ const { factory } = require('./Factory');
259
+ return factory(this).times(count).create();
260
+ }
213
261
  static async destroy(ids) {
214
262
  const idList = Array.isArray(ids) ? ids : [ids];
215
263
  if (this.softDeletes) {
@@ -525,9 +573,27 @@ class Model {
525
573
  this.syncOriginal();
526
574
  }
527
575
 
576
+ await this._touchRelations();
577
+
528
578
  return true;
529
579
  }
530
580
 
581
+ async _touchRelations() {
582
+ const touches = this.constructor.touches || [];
583
+ for (const relName of touches) {
584
+ if (typeof this[relName] !== 'function') continue;
585
+ const rel = this[relName]();
586
+ if (!rel || rel.constructor.name !== 'BelongsTo') continue;
587
+ const parentClass = rel.getRelatedClass();
588
+ const parentId = this.getAttribute(rel.foreignKey);
589
+ if (!parentId) continue;
590
+ const col = parentClass.updatedAt || 'updated_at';
591
+ await parentClass.query()
592
+ .where(parentClass.primaryKey || 'id', parentId)
593
+ .update({ [col]: new Date() });
594
+ }
595
+ }
596
+
531
597
  async update(attributes = {}) {
532
598
  this.fill(attributes);
533
599
  return await this.save();
@@ -604,6 +670,20 @@ class Model {
604
670
  return result;
605
671
  }
606
672
 
673
+ async increment(column, amount = 1) {
674
+ await this.constructor.query().where(this.constructor.primaryKey, this.getKey()).increment(column, amount);
675
+ this.setAttribute(column, (this.getAttribute(column) || 0) + amount);
676
+ this.syncOriginal();
677
+ return this;
678
+ }
679
+
680
+ async decrement(column, amount = 1) {
681
+ await this.constructor.query().where(this.constructor.primaryKey, this.getKey()).decrement(column, amount);
682
+ this.setAttribute(column, (this.getAttribute(column) || 0) - amount);
683
+ this.syncOriginal();
684
+ return this;
685
+ }
686
+
607
687
  async forceDelete() {
608
688
  if (!this.exists) return false;
609
689
 
@@ -614,6 +694,16 @@ class Model {
614
694
  return true;
615
695
  }
616
696
 
697
+ async fresh() {
698
+ if (!this.exists) return null;
699
+ return this.constructor.find(this.getKey());
700
+ }
701
+
702
+ is(other) {
703
+ if (!other || !(other instanceof this.constructor)) return false;
704
+ return this.getKey() === other.getKey();
705
+ }
706
+
617
707
  // JSON serialization
618
708
  toJSON() {
619
709
  this._initialize();
@@ -135,6 +135,8 @@ export default class QueryBuilder {
135
135
  pluck(column: string): Promise<any[]>;
136
136
  exists(): Promise<boolean>;
137
137
  doesntExist(): Promise<boolean>;
138
+ sole(): Promise<Model>;
139
+ tap(callback: (query: this) => void): this;
138
140
 
139
141
  // Pagination
140
142
  paginate(page?: number, perPage?: number): Promise<PaginationResult<Model>>;
@@ -150,6 +152,8 @@ export default class QueryBuilder {
150
152
  insert(data: any | any[]): Promise<any>;
151
153
  insertGetId(data: any): Promise<any>;
152
154
  update(data: any): Promise<number>;
155
+ increment(column: string, amount?: number): Promise<number>;
156
+ decrement(column: string, amount?: number): Promise<number>;
153
157
  delete(): Promise<number>;
154
158
  upsert(data: any[], uniqueBy: string[], update?: string[]): Promise<any>;
155
159
 
@@ -167,6 +171,10 @@ export default class QueryBuilder {
167
171
  withTrashed(): this;
168
172
  onlyTrashed(): this;
169
173
  withoutTrashed(): this;
174
+ restore(): Promise<number>;
175
+
176
+ // Plain object result (no model hydration)
177
+ values(): Promise<any[]>;
170
178
 
171
179
  // Debug
172
180
  toSql(): string;
@@ -1,6 +1,7 @@
1
1
  const Database = require('../database/connection');
2
2
  const Collection = require('./Collection');
3
3
  const ModelRegistry = require('./ModelRegistry');
4
+ const { ModelNotFoundException } = require('./Errors');
4
5
 
5
6
  class QueryBuilder {
6
7
  constructor(tableName, modelClass, connectionName) {
@@ -609,13 +610,13 @@ class QueryBuilder {
609
610
 
610
611
  async findOrFail(id) {
611
612
  const result = await this.find(id);
612
- if (!result) throw new Error(`Model not found with id: ${id}`);
613
+ if (!result) throw new ModelNotFoundException(this.modelClass?.name || 'Model', id);
613
614
  return result;
614
615
  }
615
616
 
616
617
  async firstOrFail() {
617
618
  const result = await this.first();
618
- if (!result) throw new Error('No records found.');
619
+ if (!result) throw new ModelNotFoundException(this.modelClass?.name || 'Model');
619
620
  return result;
620
621
  }
621
622
 
@@ -623,6 +624,18 @@ class QueryBuilder {
623
624
  return !(await this.exists());
624
625
  }
625
626
 
627
+ async sole() {
628
+ const results = await this._softQuery().limit(2);
629
+ if (results.length === 0) throw new ModelNotFoundException(this.modelClass?.name || 'Model');
630
+ if (results.length > 1) throw new Error(`${this.modelClass?.name || 'Model'}: sole() found more than one result.`);
631
+ return this.modelClass ? new this.modelClass(results[0]) : results[0];
632
+ }
633
+
634
+ tap(callback) {
635
+ callback(this);
636
+ return this;
637
+ }
638
+
626
639
  async pluck(column) {
627
640
  return await this._softQuery().pluck(column);
628
641
  }
@@ -758,6 +771,14 @@ class QueryBuilder {
758
771
  return this.query.update(data);
759
772
  }
760
773
 
774
+ async increment(column, amount = 1) {
775
+ return this.query.increment(column, amount);
776
+ }
777
+
778
+ async decrement(column, amount = 1) {
779
+ return this.query.decrement(column, amount);
780
+ }
781
+
761
782
  async delete() {
762
783
  return this.query.del();
763
784
  }
@@ -1033,6 +1054,18 @@ class QueryBuilder {
1033
1054
  return this;
1034
1055
  }
1035
1056
 
1057
+ async restore() {
1058
+ if (!this.modelClass || !this.modelClass.softDeletes) {
1059
+ throw new Error(`restore() requires softDeletes to be enabled on ${this.modelClass?.name || 'the model'}`);
1060
+ }
1061
+ const col = this.modelClass.deletedAt || 'deleted_at';
1062
+ return this._softQuery().update({ [col]: null });
1063
+ }
1064
+
1065
+ async values() {
1066
+ return this._softQuery();
1067
+ }
1068
+
1036
1069
  // Debug
1037
1070
  toSql() {
1038
1071
  return this.query.toSQL().sql;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilana-orm",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "A fully-featured, Eloquent-style ORM for Node.js with TypeScript support",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",