@bunnykit/orm 0.1.27 → 0.1.28

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
@@ -644,10 +644,10 @@ User.where("name", "Alice").dd(); // logs SQL and throws
644
644
  | `each(n, fn)` | Per-item iterate |
645
645
  | `cursor()` | Lazy async generator |
646
646
  | `lazy(n?)` | Chunked lazy generator |
647
- | `insert(data)` | Insert row(s) |
648
- | `insertGetId(data, col?)` | Insert and return ID |
649
- | `insertOrIgnore(data)` | Insert, ignore conflicts |
650
- | `upsert(data, uniqueBy, updateCols?)` | Insert or update on conflict |
647
+ | `insert(data, options?)` | Insert row(s) with optional chunking |
648
+ | `insertGetId(data, col?)` | Insert and return ID |
649
+ | `insertOrIgnore(data)` | Insert, ignore conflicts |
650
+ | `upsert(data, uniqueBy, updateCols?, options?)` | Insert or update on conflict, optional chunking |
651
651
  | `update(data)` | Update matched rows |
652
652
  | `updateFrom(tbl, a, op, b)` | Update with JOIN |
653
653
  | `delete()` | Delete matched rows |
@@ -739,10 +739,123 @@ const user = await User.firstOrCreate(
739
739
  { email: "alice@example.com" },
740
740
  { name: "Alice" },
741
741
  );
742
- const user = await User.updateOrCreate(
742
+ const user = await User.updateOrInsert(
743
743
  { email: "alice@example.com" },
744
744
  { name: "Alice Smith" },
745
745
  );
746
+
747
+ // Bulk insert / upsert (apply fillable, casts, timestamps, UUID keys)
748
+ await User.insert([
749
+ { name: "Alice", email: "alice1@example.com" },
750
+ { name: "Bob", email: "alice2@example.com" },
751
+ ], { chunkSize: 100 });
752
+
753
+ await User.upsert(
754
+ [{ email: "alice@example.com", name: "Alice Updated" }],
755
+ "email",
756
+ ["name"],
757
+ { chunkSize: 100 },
758
+ );
759
+
760
+ // Bulk create / save (fire model events by default)
761
+ const users = await User.createMany([
762
+ { name: "Alice", email: "alice@example.com" },
763
+ { name: "Bob", email: "bob@example.com" },
764
+ ]);
765
+
766
+ await User.saveMany(users);
767
+
768
+ // Bypass observers with { events: false }
769
+ await User.createMany(records, { events: false });
770
+ await User.saveMany(models, { events: false });
771
+ model.save({ events: false });
772
+ ```
773
+
774
+ ### Bulk Operations
775
+
776
+ Bunny provides bulk methods for inserting, upserting, and creating multiple records efficiently. All bulk insert and upsert operations apply fillable rules, attribute casts, timestamps, and UUID key generation automatically.
777
+
778
+ #### Model.insert(records, options?)
779
+
780
+ Insert raw records with automatic processing. Respects `fillable` guard, applies casts and timestamps, and generates UUID keys for `primaryKey = "uuid"` models.
781
+
782
+ ```ts
783
+ await User.insert([
784
+ { name: "Alice", email: "alice@example.com" },
785
+ { name: "Bob", email: "bob@example.com" },
786
+ ], { chunkSize: 500 });
787
+ ```
788
+
789
+ `chunkSize` batches large inserts to avoid exceeding query size limits.
790
+
791
+ #### Model.upsert(records, uniqueBy, updateColumns?, options?)
792
+
793
+ Insert or update records based on a unique key. On insert: applies fillable, casts, timestamps, and UUID generation. On update: only the specified `updateColumns` are modified.
794
+
795
+ ```ts
796
+ // Insert or update by email, updating only the name
797
+ await User.upsert(
798
+ [{ email: "alice@example.com", name: "Alice Updated" }],
799
+ "email",
800
+ ["name"],
801
+ { chunkSize: 500 },
802
+ );
803
+
804
+ // Insert or update by email, updating all columns except the unique key
805
+ await User.upsert(
806
+ [{ email: "alice@example.com", name: "Alice", active: true }],
807
+ "email",
808
+ );
809
+ ```
810
+
811
+ #### Model.updateOrInsert(attributes, values)
812
+
813
+ Find a record by `attributes` and update with `values`, or create a new record if not found. Combines `firstOrCreate` logic with the ability to pass explicit create values.
814
+
815
+ ```ts
816
+ const user = await User.updateOrInsert(
817
+ { email: "alice@example.com" },
818
+ { name: "Alice Smith", active: true },
819
+ );
820
+ ```
821
+
822
+ #### Model.createMany(records, options?)
823
+
824
+ Create multiple model instances with full ORM support. Fires `creating` / `created` observers by default. Pass `{ events: false }` to bypass observers for better performance.
825
+
826
+ ```ts
827
+ const users = await User.createMany([
828
+ { name: "Alice", email: "alice@example.com" },
829
+ { name: "Bob", email: "bob@example.com" },
830
+ ]);
831
+
832
+ // Bypass observers for bulk create
833
+ await User.createMany(records, { events: false });
834
+ ```
835
+
836
+ #### Model.saveMany(models, options?)
837
+
838
+ Persist an array of new or existing model instances. Existing models trigger update observers and update `updated_at`. New models trigger create observers. Auto-increment IDs are preserved when saving new models silently one-by-one where needed. Pass `{ events: false }` to bypass all observers.
839
+
840
+ ```ts
841
+ const users = [
842
+ new User({ name: "Alice" }),
843
+ new User({ id: 5, name: "Bob" }), // existing record
844
+ ];
845
+
846
+ await User.saveMany(users);
847
+
848
+ // Bypass observers for bulk save
849
+ await User.saveMany(models, { events: false });
850
+ ```
851
+
852
+ #### model.save(options?)
853
+
854
+ Save an existing model instance. Pass `{ events: false }` to bypass `updating` / `updated` observers.
855
+
856
+ ```ts
857
+ user.name = "Charlie";
858
+ await user.save({ events: false });
746
859
  ```
747
860
 
748
861
  ### Default Attributes
@@ -14,7 +14,7 @@ export { MySqlGrammar } from "./schema/grammars/MySqlGrammar.js";
14
14
  export { PostgresGrammar } from "./schema/grammars/PostgresGrammar.js";
15
15
  export { Builder } from "./query/Builder.js";
16
16
  export { Model, HasMany, BelongsTo, HasOne, HasManyThrough, HasOneThrough } from "./model/Model.js";
17
- export type { ModelAttributeInput, ModelAttributes, ModelColumn, ModelColumnValue, ModelConstructor, ModelRelationName, EagerLoadConstraint, EagerLoadDefinition, EagerLoadInput, GlobalScope, CastDefinition, CastsAttributes, } from "./model/Model.js";
17
+ export type { ModelAttributeInput, ModelAttributes, BulkModelOptions, SaveOptions, ModelColumn, ModelColumnValue, ModelConstructor, ModelRelationName, EagerLoadConstraint, EagerLoadDefinition, EagerLoadInput, GlobalScope, CastDefinition, CastsAttributes, } from "./model/Model.js";
18
18
  export { ModelNotFoundError } from "./model/ModelNotFoundError.js";
19
19
  export { ObserverRegistry, type ObserverContract } from "./model/Observer.js";
20
20
  export { MorphMap } from "./model/MorphMap.js";
@@ -19,6 +19,13 @@ export type ModelAttributes<T> = T extends {
19
19
  export type ModelColumn<T> = LiteralUnion<Extract<keyof ModelAttributes<T>, string>>;
20
20
  export type ModelColumnValue<T, K> = K extends keyof ModelAttributes<T> ? ModelAttributes<T>[K] : any;
21
21
  export type ModelAttributeInput<T> = Partial<ModelAttributes<T>> & Record<string, any>;
22
+ export interface BulkModelOptions {
23
+ chunkSize?: number;
24
+ events?: boolean;
25
+ }
26
+ export interface SaveOptions {
27
+ events?: boolean;
28
+ }
22
29
  export type ModelRelationValue = Relation<any> | MorphTo<any> | MorphOne<any> | MorphMany<any> | MorphToMany<any> | BelongsToMany<any>;
23
30
  export type ModelRelationName<T> = LiteralUnion<Extract<{
24
31
  [K in keyof T]-?: T[K] extends (...args: any[]) => ModelRelationValue ? K : never;
@@ -132,8 +139,19 @@ export declare class Model<T extends Record<string, any> = Record<string, any>>
132
139
  static applyGlobalScopes(builder: Builder<any>): void;
133
140
  static getQualifiedDeletedAtColumn(): string;
134
141
  static shouldAutoGeneratePrimaryKey(): Promise<boolean>;
142
+ static prepareBulkRecords<M extends ModelConstructor>(this: M, records: ModelAttributeInput<InstanceType<M>>[]): Promise<Record<string, any>[]>;
143
+ static prepareBulkRecord<M extends ModelConstructor>(this: M, record: ModelAttributeInput<InstanceType<M>>, options?: {
144
+ touchCreatedAt?: boolean;
145
+ touchUpdatedAt?: boolean;
146
+ generatePrimaryKey?: boolean;
147
+ }): Promise<Record<string, any>>;
135
148
  static hydrate<M extends ModelConstructor>(this: M, row: Record<string, any>, connection?: Connection): InstanceType<M>;
136
149
  static create<M extends ModelConstructor>(this: M, attributes: ModelAttributeInput<InstanceType<M>>): Promise<InstanceType<M>>;
150
+ static insert<M extends ModelConstructor>(this: M, records: ModelAttributeInput<InstanceType<M>> | ModelAttributeInput<InstanceType<M>>[], options?: Omit<BulkModelOptions, "events">): Promise<any>;
151
+ static upsert<M extends ModelConstructor>(this: M, records: ModelAttributeInput<InstanceType<M>> | ModelAttributeInput<InstanceType<M>>[], uniqueBy: ModelColumn<InstanceType<M>> | ModelColumn<InstanceType<M>>[], updateColumns?: ModelColumn<InstanceType<M>>[], options?: Omit<BulkModelOptions, "events">): Promise<any>;
152
+ static updateOrInsert<M extends ModelConstructor>(this: M, attributes: ModelAttributeInput<InstanceType<M>>, values?: ModelAttributeInput<InstanceType<M>>): Promise<boolean>;
153
+ static createMany<M extends ModelConstructor>(this: M, records: ModelAttributeInput<InstanceType<M>>[], options?: BulkModelOptions): Promise<InstanceType<M>[]>;
154
+ static saveMany<M extends ModelConstructor>(this: M, models: InstanceType<M>[], options?: BulkModelOptions): Promise<InstanceType<M>[]>;
137
155
  static find<M extends ModelConstructor>(this: M, id: any): Promise<InstanceType<M> | null>;
138
156
  static findOrFail<M extends ModelConstructor>(this: M, id: any): Promise<InstanceType<M>>;
139
157
  static first<M extends ModelConstructor>(this: M): Promise<InstanceType<M> | null>;
@@ -212,7 +230,7 @@ export declare class Model<T extends Record<string, any> = Record<string, any>>
212
230
  protected resolveCustomCast(cast: CastDefinition): CastsAttributes | null;
213
231
  getDirty(): Partial<T>;
214
232
  isDirty(): boolean;
215
- save(): Promise<this>;
233
+ save(options?: SaveOptions): Promise<this>;
216
234
  updateTimestamps(): void;
217
235
  touch(): Promise<boolean>;
218
236
  increment<K extends ModelColumn<this>>(column: K, amount?: number, extra?: ModelAttributeInput<this>): Promise<this>;
@@ -433,6 +433,33 @@ export class Model {
433
433
  const numericTypes = new Set(["integer", "int", "bigint", "smallint", "tinyint", "real", "float", "double", "decimal", "numeric"]);
434
434
  return !numericTypes.has(type);
435
435
  }
436
+ static async prepareBulkRecords(records) {
437
+ const prepared = [];
438
+ for (const record of records) {
439
+ prepared.push(await this.prepareBulkRecord(record));
440
+ }
441
+ return prepared;
442
+ }
443
+ static async prepareBulkRecord(record, options = {}) {
444
+ const instance = new this();
445
+ instance.fill(record);
446
+ const attributes = { ...instance.$attributes };
447
+ if (this.timestamps) {
448
+ const now = instance.freshTimestamp();
449
+ if (options.touchCreatedAt !== false && attributes.created_at === undefined)
450
+ attributes.created_at = now;
451
+ if (options.touchUpdatedAt !== false && attributes.updated_at === undefined)
452
+ attributes.updated_at = now;
453
+ }
454
+ if (options.generatePrimaryKey !== false) {
455
+ const primaryKey = this.primaryKey;
456
+ const primaryKeyValue = attributes[primaryKey];
457
+ if ((primaryKeyValue === null || primaryKeyValue === undefined || primaryKeyValue === "") && await this.shouldAutoGeneratePrimaryKey()) {
458
+ attributes[primaryKey] = crypto.randomUUID();
459
+ }
460
+ }
461
+ return attributes;
462
+ }
436
463
  static hydrate(row, connection) {
437
464
  const instance = new this();
438
465
  instance.$attributes = { ...instance.$attributes, ...row };
@@ -450,6 +477,100 @@ export class Model {
450
477
  await instance.save();
451
478
  return instance;
452
479
  }
480
+ static async insert(records, options = {}) {
481
+ const prepared = await this.prepareBulkRecords(Array.isArray(records) ? records : [records]);
482
+ const chunkSize = options.chunkSize || prepared.length || 1;
483
+ let result;
484
+ for (let i = 0; i < prepared.length; i += chunkSize) {
485
+ result = await this.query().insert(prepared.slice(i, i + chunkSize));
486
+ }
487
+ return result;
488
+ }
489
+ static async upsert(records, uniqueBy, updateColumns, options = {}) {
490
+ const prepared = await this.prepareBulkRecords(Array.isArray(records) ? records : [records]);
491
+ const chunkSize = options.chunkSize || prepared.length || 1;
492
+ let columns = updateColumns;
493
+ if (!columns && this.timestamps) {
494
+ const uniqueColumns = new Set(Array.isArray(uniqueBy) ? uniqueBy : [uniqueBy]);
495
+ columns = Object.keys(prepared[0] || {}).filter((column) => column !== "created_at" && !uniqueColumns.has(column));
496
+ }
497
+ let result;
498
+ for (let i = 0; i < prepared.length; i += chunkSize) {
499
+ result = await this.query().upsert(prepared.slice(i, i + chunkSize), uniqueBy, columns);
500
+ }
501
+ return result;
502
+ }
503
+ static async updateOrInsert(attributes, values = {}) {
504
+ const existing = await this.where(attributes).first();
505
+ if (existing) {
506
+ const update = await this.prepareBulkRecord(values, { touchUpdatedAt: true, touchCreatedAt: false, generatePrimaryKey: false });
507
+ await this.where(attributes).update(update);
508
+ return true;
509
+ }
510
+ await this.insert({ ...attributes, ...values });
511
+ return true;
512
+ }
513
+ static async createMany(records, options = {}) {
514
+ const models = records.map((attributes) => new this(attributes));
515
+ await this.saveMany(models, options);
516
+ return models;
517
+ }
518
+ static async saveMany(models, options = {}) {
519
+ const chunkSize = options.chunkSize || models.length || 1;
520
+ const events = options.events !== false;
521
+ if (events) {
522
+ for (const model of models) {
523
+ await model.save();
524
+ }
525
+ return models;
526
+ }
527
+ for (let i = 0; i < models.length; i += chunkSize) {
528
+ const chunk = models.slice(i, i + chunkSize);
529
+ const newModels = chunk.filter((model) => !model.$exists);
530
+ const existingModels = chunk.filter((model) => model.$exists);
531
+ if (newModels.length > 0) {
532
+ const shouldGeneratePrimaryKey = await this.shouldAutoGeneratePrimaryKey();
533
+ const bulkModels = [];
534
+ for (const model of newModels) {
535
+ const pk = model.getAttribute(this.primaryKey);
536
+ if (!shouldGeneratePrimaryKey && (pk === null || pk === undefined || pk === "")) {
537
+ const record = await this.prepareBulkRecord(model.$attributes);
538
+ const id = await this.query().insertGetId(record);
539
+ if (id)
540
+ record[this.primaryKey] = id;
541
+ model.$attributes = record;
542
+ model.$original = { ...record };
543
+ model.$exists = true;
544
+ }
545
+ else {
546
+ bulkModels.push(model);
547
+ }
548
+ }
549
+ if (bulkModels.length > 0) {
550
+ const records = await this.prepareBulkRecords(bulkModels.map((model) => model.$attributes));
551
+ await this.query().insert(records);
552
+ for (let index = 0; index < bulkModels.length; index++) {
553
+ bulkModels[index].$attributes = records[index];
554
+ bulkModels[index].$original = { ...records[index] };
555
+ bulkModels[index].$exists = true;
556
+ }
557
+ }
558
+ }
559
+ for (const model of existingModels) {
560
+ let dirty = model.getDirty();
561
+ if (Object.keys(dirty).length > 0 && this.timestamps) {
562
+ model.$attributes.updated_at = model.freshTimestamp();
563
+ delete model.$castCache.updated_at;
564
+ dirty = model.getDirty();
565
+ }
566
+ if (Object.keys(dirty).length === 0)
567
+ continue;
568
+ await this.query().where(this.primaryKey, model.getAttribute(this.primaryKey)).update(dirty);
569
+ model.$original = { ...model.$attributes };
570
+ }
571
+ }
572
+ return models;
573
+ }
453
574
  static async find(id) {
454
575
  return this.query().find(id, this.primaryKey);
455
576
  }
@@ -836,10 +957,12 @@ export class Model {
836
957
  isDirty() {
837
958
  return Object.keys(this.getDirty()).length > 0;
838
959
  }
839
- async save() {
960
+ async save(options = {}) {
840
961
  const constructor = this.constructor;
962
+ const events = options.events !== false;
841
963
  if (this.$exists) {
842
- await ObserverRegistry.dispatch("saving", this);
964
+ if (events)
965
+ await ObserverRegistry.dispatch("saving", this);
843
966
  let dirty = this.getDirty();
844
967
  if (Object.keys(dirty).length > 0 && constructor.timestamps) {
845
968
  this.$attributes["updated_at"] = this.freshTimestamp();
@@ -847,20 +970,25 @@ export class Model {
847
970
  dirty = this.getDirty();
848
971
  }
849
972
  if (Object.keys(dirty).length > 0) {
850
- await ObserverRegistry.dispatch("updating", this);
973
+ if (events)
974
+ await ObserverRegistry.dispatch("updating", this);
851
975
  const pk = this.getAttribute(constructor.primaryKey);
852
976
  const connection = this.getConnection();
853
977
  await new Builder(connection, connection.qualifyTable(constructor.getTable()))
854
978
  .where(constructor.primaryKey, pk)
855
979
  .update(dirty);
856
- await ObserverRegistry.dispatch("updated", this);
980
+ if (events)
981
+ await ObserverRegistry.dispatch("updated", this);
857
982
  }
858
983
  this.$original = { ...this.$attributes };
859
- await ObserverRegistry.dispatch("saved", this);
984
+ if (events)
985
+ await ObserverRegistry.dispatch("saved", this);
860
986
  }
861
987
  else {
862
- await ObserverRegistry.dispatch("creating", this);
863
- await ObserverRegistry.dispatch("saving", this);
988
+ if (events)
989
+ await ObserverRegistry.dispatch("creating", this);
990
+ if (events)
991
+ await ObserverRegistry.dispatch("saving", this);
864
992
  if (constructor.timestamps) {
865
993
  const now = this.freshTimestamp();
866
994
  this.$attributes["created_at"] = now;
@@ -889,8 +1017,10 @@ export class Model {
889
1017
  }
890
1018
  this.$exists = true;
891
1019
  this.$original = { ...this.$attributes };
892
- await ObserverRegistry.dispatch("created", this);
893
- await ObserverRegistry.dispatch("saved", this);
1020
+ if (events)
1021
+ await ObserverRegistry.dispatch("created", this);
1022
+ if (events)
1023
+ await ObserverRegistry.dispatch("saved", this);
894
1024
  }
895
1025
  const identityMap = IdentityMap.current();
896
1026
  if (identityMap) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunnykit/orm",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "An Eloquent-inspired ORM for Bun's native SQL client supporting SQLite, MySQL, and PostgreSQL.",
5
5
  "license": "MIT",
6
6
  "packageManager": "bun@1.3.12",