ilana-orm 1.0.17 → 1.0.19
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 +293 -37
- package/cli/ilana.js +60 -158
- package/database/connection.d.ts +3 -0
- package/database/connection.js +40 -1
- package/database/schema-builder.js +4 -0
- package/ilana.config.js +1 -1
- package/index.d.ts +17 -0
- package/index.edge.js +10 -0
- package/index.edge.mjs +37 -0
- package/index.js +6 -2
- package/index.mjs +2 -0
- package/jest.config.js +4 -0
- package/orm/Errors.js +18 -0
- package/orm/F.js +22 -0
- package/orm/Factory.js +49 -12
- package/orm/MigrationRunner.js +16 -48
- package/orm/Model.d.ts +25 -1
- package/orm/Model.js +219 -19
- package/orm/QueryBuilder.d.ts +18 -0
- package/orm/QueryBuilder.js +109 -3
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ A fully-featured, Eloquent-style ORM for Node.js with automatic TypeScript suppo
|
|
|
24
24
|
- [Schema Builder](#schema-builder)
|
|
25
25
|
- [Transactions](#transactions)
|
|
26
26
|
- [Advanced Features](#advanced-features)
|
|
27
|
+
- [Supabase](#supabase)
|
|
27
28
|
- [Complete API Reference](#complete-api-reference)
|
|
28
29
|
- [TypeScript Support](#typescript-support)
|
|
29
30
|
- [Performance & Best Practices](#performance--best-practices)
|
|
@@ -447,6 +448,9 @@ module.exports = {
|
|
|
447
448
|
extension: "ts",
|
|
448
449
|
},
|
|
449
450
|
|
|
451
|
+
// SQL query logging — logs every query with bound values and execution time
|
|
452
|
+
logging: process.env.NODE_ENV === "development",
|
|
453
|
+
|
|
450
454
|
// Debugging
|
|
451
455
|
debug: process.env.NODE_ENV === "development",
|
|
452
456
|
|
|
@@ -790,47 +794,33 @@ export default class User extends Model {
|
|
|
790
794
|
|
|
791
795
|
### UUID Primary Keys
|
|
792
796
|
|
|
793
|
-
**JavaScript:**
|
|
794
797
|
```javascript
|
|
795
798
|
class User extends Model {
|
|
796
799
|
static table = 'users';
|
|
797
|
-
static keyType = '
|
|
800
|
+
static keyType = 'uuid';
|
|
798
801
|
static incrementing = false;
|
|
799
802
|
}
|
|
800
803
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const user = await User.create({
|
|
805
|
-
name: 'John Doe',
|
|
806
|
-
email: 'john@example.com',
|
|
807
|
-
});
|
|
808
|
-
// user.id will be a generated UUID
|
|
809
|
-
````
|
|
804
|
+
const user = await User.create({ name: 'John Doe', email: 'john@example.com' });
|
|
805
|
+
// user.id → "550e8400-e29b-41d4-a716-446655440000"
|
|
806
|
+
```
|
|
810
807
|
|
|
811
|
-
|
|
808
|
+
### ULID Primary Keys
|
|
812
809
|
|
|
813
|
-
|
|
814
|
-
export default class User extends Model {
|
|
815
|
-
protected static table = "users";
|
|
816
|
-
protected static keyType = "string" as const;
|
|
817
|
-
protected static incrementing = false;
|
|
810
|
+
ULIDs are 26-character sortable identifiers — URL-safe, lexicographically ordered by creation time:
|
|
818
811
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
812
|
+
```javascript
|
|
813
|
+
class Order extends Model {
|
|
814
|
+
static table = 'orders';
|
|
815
|
+
static keyType = 'ulid';
|
|
816
|
+
static incrementing = false;
|
|
823
817
|
}
|
|
824
818
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
name: "John Doe",
|
|
828
|
-
email: "john@example.com",
|
|
829
|
-
});
|
|
830
|
-
// user.id will be a generated UUID
|
|
819
|
+
const order = await Order.create({ total: 49.99 });
|
|
820
|
+
// order.id → "01J3X7KQZB8YTPNMCHW4RSVFGE"
|
|
831
821
|
```
|
|
832
822
|
|
|
833
|
-
|
|
823
|
+
Use `char(26)` for the column type in migrations.
|
|
834
824
|
|
|
835
825
|
### Attribute Casting
|
|
836
826
|
|
|
@@ -1518,11 +1508,11 @@ const users = await User.all();
|
|
|
1518
1508
|
|
|
1519
1509
|
// Find by primary key
|
|
1520
1510
|
const user = await User.find(1);
|
|
1521
|
-
const user = await User.findOrFail(1); // Throws if not found
|
|
1511
|
+
const user = await User.findOrFail(1); // Throws ModelNotFoundException if not found
|
|
1522
1512
|
|
|
1523
1513
|
// First record
|
|
1524
1514
|
const user = await User.first();
|
|
1525
|
-
const user = await User.firstOrFail(); // Throws if not found
|
|
1515
|
+
const user = await User.firstOrFail(); // Throws ModelNotFoundException if not found
|
|
1526
1516
|
|
|
1527
1517
|
// Create or find
|
|
1528
1518
|
const user = await User.firstOrCreate(
|
|
@@ -1545,11 +1535,11 @@ const users = await User.all();
|
|
|
1545
1535
|
|
|
1546
1536
|
// Find by primary key
|
|
1547
1537
|
const user = await User.find(1);
|
|
1548
|
-
const user = await User.findOrFail(1); // Throws if not found
|
|
1538
|
+
const user = await User.findOrFail(1); // Throws ModelNotFoundException if not found
|
|
1549
1539
|
|
|
1550
1540
|
// First record
|
|
1551
1541
|
const user = await User.first();
|
|
1552
|
-
const user = await User.firstOrFail(); // Throws if not found
|
|
1542
|
+
const user = await User.firstOrFail(); // Throws ModelNotFoundException if not found
|
|
1553
1543
|
|
|
1554
1544
|
// Create or find
|
|
1555
1545
|
const user = await User.firstOrCreate(
|
|
@@ -4022,6 +4012,222 @@ const users = await User.query().on("reporting_db").get();
|
|
|
4022
4012
|
|
|
4023
4013
|
````
|
|
4024
4014
|
|
|
4015
|
+
## Debugging
|
|
4016
|
+
|
|
4017
|
+
### Query Logging
|
|
4018
|
+
|
|
4019
|
+
Enable SQL query logging in `ilana.config.js`:
|
|
4020
|
+
|
|
4021
|
+
```javascript
|
|
4022
|
+
// ilana.config.js
|
|
4023
|
+
module.exports = {
|
|
4024
|
+
default: 'mysql',
|
|
4025
|
+
logging: process.env.NODE_ENV === 'development', // logs all queries in dev
|
|
4026
|
+
connections: { ... }
|
|
4027
|
+
};
|
|
4028
|
+
```
|
|
4029
|
+
|
|
4030
|
+
Or toggle programmatically:
|
|
4031
|
+
|
|
4032
|
+
```javascript
|
|
4033
|
+
import { Database } from 'ilana-orm';
|
|
4034
|
+
|
|
4035
|
+
Database.enableLogging(); // turn on
|
|
4036
|
+
Database.disableLogging(); // turn off
|
|
4037
|
+
```
|
|
4038
|
+
|
|
4039
|
+
Output:
|
|
4040
|
+
```
|
|
4041
|
+
[IlanaORM] select * from "users" where "role" = 'admin' order by "created_at" desc limit 10 — 3ms
|
|
4042
|
+
[IlanaORM] select * from "posts" where "user_id" in (1, 2, 3) — 1ms
|
|
4043
|
+
```
|
|
4044
|
+
|
|
4045
|
+
Inspect a query without executing it:
|
|
4046
|
+
|
|
4047
|
+
```javascript
|
|
4048
|
+
const sql = User.query().where('role', 'admin').toSql();
|
|
4049
|
+
console.log(sql); // select * from "users" where "role" = 'admin'
|
|
4050
|
+
```
|
|
4051
|
+
|
|
4052
|
+
### ModelNotFoundException
|
|
4053
|
+
|
|
4054
|
+
`findOrFail()`, `firstOrFail()`, and `sole()` throw a `ModelNotFoundException` — a named error class you can catch specifically:
|
|
4055
|
+
|
|
4056
|
+
```javascript
|
|
4057
|
+
import { ModelNotFoundException } from 'ilana-orm';
|
|
4058
|
+
|
|
4059
|
+
// In a route
|
|
4060
|
+
app.get('/users/:id', async (req, res) => {
|
|
4061
|
+
const user = await User.findOrFail(req.params.id);
|
|
4062
|
+
res.json(user);
|
|
4063
|
+
});
|
|
4064
|
+
|
|
4065
|
+
// Global Express error handler
|
|
4066
|
+
app.use((err, req, res, next) => {
|
|
4067
|
+
if (err instanceof ModelNotFoundException) {
|
|
4068
|
+
return res.status(404).json({ message: err.message });
|
|
4069
|
+
// "User with id 99 not found"
|
|
4070
|
+
}
|
|
4071
|
+
res.status(500).json({ message: 'Server error' });
|
|
4072
|
+
});
|
|
4073
|
+
```
|
|
4074
|
+
|
|
4075
|
+
Properties: `err.message`, `err.model` (class name), `err.id` (the id passed to `findOrFail`).
|
|
4076
|
+
|
|
4077
|
+
Call `err.toResponse()` to get a plain `{ status: 404, message }` object suitable for any HTTP framework:
|
|
4078
|
+
|
|
4079
|
+
```javascript
|
|
4080
|
+
app.get('/users/:id', async (req, res) => {
|
|
4081
|
+
try {
|
|
4082
|
+
return res.json(await User.findOrFail(req.params.id));
|
|
4083
|
+
} catch (err) {
|
|
4084
|
+
if (err instanceof ModelNotFoundException) {
|
|
4085
|
+
const { status, message } = err.toResponse();
|
|
4086
|
+
return res.status(status).json({ message });
|
|
4087
|
+
}
|
|
4088
|
+
throw err;
|
|
4089
|
+
}
|
|
4090
|
+
});
|
|
4091
|
+
```
|
|
4092
|
+
|
|
4093
|
+
### Column Expressions with `F()`
|
|
4094
|
+
|
|
4095
|
+
Reference a column's current value in an update — no raw SQL, no race conditions, no need to fetch first:
|
|
4096
|
+
|
|
4097
|
+
```javascript
|
|
4098
|
+
import { F } from 'ilana-orm';
|
|
4099
|
+
|
|
4100
|
+
await Post.query().where('id', postId).update({ views: F('views').plus(1) });
|
|
4101
|
+
await Product.query().where('id', id).update({ stock: F('stock').minus(quantity) });
|
|
4102
|
+
```
|
|
4103
|
+
|
|
4104
|
+
Available: `.plus(n)`, `.minus(n)`, `.times(n)`, `.divide(n)`.
|
|
4105
|
+
|
|
4106
|
+
### Bulk Restore
|
|
4107
|
+
|
|
4108
|
+
Restore many soft-deleted records at once via the query builder:
|
|
4109
|
+
|
|
4110
|
+
```javascript
|
|
4111
|
+
await User.query().onlyTrashed().where('role', 'admin').restore();
|
|
4112
|
+
// UPDATE users SET deleted_at = NULL WHERE role = 'admin' AND deleted_at IS NOT NULL
|
|
4113
|
+
```
|
|
4114
|
+
|
|
4115
|
+
### Enum Helpers
|
|
4116
|
+
|
|
4117
|
+
Define possible values for enum columns and get auto-generated `isX()` / `makeX()` helpers on every instance:
|
|
4118
|
+
|
|
4119
|
+
```javascript
|
|
4120
|
+
class User extends Model {
|
|
4121
|
+
static enums = {
|
|
4122
|
+
role: ['user', 'moderator', 'admin'],
|
|
4123
|
+
status: ['active', 'suspended'],
|
|
4124
|
+
};
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
const user = await User.find(1);
|
|
4128
|
+
user.isAdmin(); // true / false
|
|
4129
|
+
await user.makeAdmin(); // sets role = 'admin' and saves
|
|
4130
|
+
user.isSuspended(); // true / false
|
|
4131
|
+
```
|
|
4132
|
+
|
|
4133
|
+
### Strict Loading
|
|
4134
|
+
|
|
4135
|
+
Throw an error when an unloaded relation is accessed — catches N+1 problems at development time:
|
|
4136
|
+
|
|
4137
|
+
```javascript
|
|
4138
|
+
class Post extends Model {
|
|
4139
|
+
static strictLoading = true;
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
const posts = await Post.all(); // no .with('comments')
|
|
4143
|
+
posts[0].relations.comments; // throws: 'comments' was not eager loaded on Post
|
|
4144
|
+
```
|
|
4145
|
+
|
|
4146
|
+
### Touch
|
|
4147
|
+
|
|
4148
|
+
Automatically update a parent's `updated_at` whenever the child saves:
|
|
4149
|
+
|
|
4150
|
+
```javascript
|
|
4151
|
+
class Comment extends Model {
|
|
4152
|
+
static touches = ['post'];
|
|
4153
|
+
|
|
4154
|
+
post() { return this.belongsTo('Post', 'post_id'); }
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
await comment.save(); // also bumps posts SET updated_at = NOW() WHERE id = comment.post_id
|
|
4158
|
+
```
|
|
4159
|
+
|
|
4160
|
+
### Plain Object Results with `values()`
|
|
4161
|
+
|
|
4162
|
+
Return raw plain objects instead of model instances — faster for read-heavy endpoints where you don't need model methods:
|
|
4163
|
+
|
|
4164
|
+
```javascript
|
|
4165
|
+
const users = await User.query().select('id', 'name', 'email').values();
|
|
4166
|
+
// [{ id: 1, name: 'John', email: 'john@example.com' }, ...]
|
|
4167
|
+
```
|
|
4168
|
+
|
|
4169
|
+
### Vector / Semantic Search (pgvector)
|
|
4170
|
+
|
|
4171
|
+
Semantic search matches by **meaning**, not exact keywords. It works by converting text into a list of numbers called an **embedding** using an AI embedding model, storing those numbers in the database, and finding records whose embeddings are mathematically closest to a search query.
|
|
4172
|
+
|
|
4173
|
+
**Requires:** PostgreSQL + pgvector extension. An external embedding model (OpenAI, Cohere, Ollama, etc.) is also required — IlanaORM does not include one. Does **not** work with MySQL or SQLite.
|
|
4174
|
+
|
|
4175
|
+
```javascript
|
|
4176
|
+
// 1. Enable the extension in a migration
|
|
4177
|
+
await schema.enableVectorExtension();
|
|
4178
|
+
|
|
4179
|
+
// 2. Add a vector column
|
|
4180
|
+
await schema.table('posts', table => {
|
|
4181
|
+
table.specificType('embedding', 'vector(1536)'); // dimensions must match your model
|
|
4182
|
+
});
|
|
4183
|
+
|
|
4184
|
+
// 3. Configure the model
|
|
4185
|
+
class Post extends Model {
|
|
4186
|
+
static embeddingColumn = 'embedding';
|
|
4187
|
+
static embeddingProvider = async (text) => {
|
|
4188
|
+
// Must return Promise<number[]> — plug in any embedding API
|
|
4189
|
+
const res = await openai.embeddings.create({ model: 'text-embedding-ada-002', input: text });
|
|
4190
|
+
return res.data[0].embedding;
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4194
|
+
// 4. Store embeddings when creating records
|
|
4195
|
+
await Post.create({
|
|
4196
|
+
title: 'JavaScript tips',
|
|
4197
|
+
body: '...',
|
|
4198
|
+
embedding: JSON.stringify(await Post.embeddingProvider('JavaScript tips ...')),
|
|
4199
|
+
});
|
|
4200
|
+
|
|
4201
|
+
// 5. Search — converts text to vector, queries by similarity
|
|
4202
|
+
const posts = await Post.search('javascript performance tips', { limit: 5 });
|
|
4203
|
+
|
|
4204
|
+
// Or search by a raw vector you already have
|
|
4205
|
+
const posts = await Post.nearestTo(myVector, { distance: 'cosine', limit: 10 });
|
|
4206
|
+
|
|
4207
|
+
// Each result has a .distance attribute (lower = more similar)
|
|
4208
|
+
posts.forEach(p => console.log(p.title, p.distance));
|
|
4209
|
+
```
|
|
4210
|
+
|
|
4211
|
+
Distance options: `'cosine'` (default, `<=>`), `'l2'` (`<->`), `'inner'` (`<#>`).
|
|
4212
|
+
|
|
4213
|
+
### Edge Runtime
|
|
4214
|
+
|
|
4215
|
+
Import from `ilana-orm/edge` to skip the Node.js `fs`/`path` auto-loader. Required for Cloudflare Workers, Deno, Bun, and Next.js edge routes:
|
|
4216
|
+
|
|
4217
|
+
```javascript
|
|
4218
|
+
import { Model, Database } from 'ilana-orm/edge';
|
|
4219
|
+
|
|
4220
|
+
// Must configure explicitly — no auto-loading of ilana.config.js
|
|
4221
|
+
Database.configure({
|
|
4222
|
+
default: 'pg',
|
|
4223
|
+
connections: {
|
|
4224
|
+
pg: { client: 'pg', connection: { connectionString: process.env.DATABASE_URL } },
|
|
4225
|
+
},
|
|
4226
|
+
});
|
|
4227
|
+
|
|
4228
|
+
const users = await User.all();
|
|
4229
|
+
```
|
|
4230
|
+
|
|
4025
4231
|
## TypeScript Support
|
|
4026
4232
|
|
|
4027
4233
|
### Type-Safe Models
|
|
@@ -4074,6 +4280,32 @@ const user = await User.factory().create({
|
|
|
4074
4280
|
});
|
|
4075
4281
|
```
|
|
4076
4282
|
|
|
4283
|
+
## Supabase
|
|
4284
|
+
|
|
4285
|
+
Supabase is a hosted PostgreSQL platform. IlanaORM works with it out of the box using the `pg` driver — no special setup required.
|
|
4286
|
+
|
|
4287
|
+
```javascript
|
|
4288
|
+
// ilana.config.js
|
|
4289
|
+
const { Database } = require('ilana-orm');
|
|
4290
|
+
|
|
4291
|
+
Database.configure({
|
|
4292
|
+
default: 'pg',
|
|
4293
|
+
connections: {
|
|
4294
|
+
pg: {
|
|
4295
|
+
client: 'pg',
|
|
4296
|
+
connection: {
|
|
4297
|
+
connectionString: process.env.DATABASE_URL, // from Supabase dashboard
|
|
4298
|
+
ssl: { rejectUnauthorized: false }, // required for Supabase
|
|
4299
|
+
},
|
|
4300
|
+
},
|
|
4301
|
+
},
|
|
4302
|
+
});
|
|
4303
|
+
```
|
|
4304
|
+
|
|
4305
|
+
All features work including migrations, relations, soft deletes, and vector search (Supabase has pgvector built in).
|
|
4306
|
+
|
|
4307
|
+
For serverless or edge deployments with Supabase, use the [connection pooler URL](https://supabase.com/docs/guides/database/connecting-to-postgres) (port `6543`) and the `ilana-orm/edge` entry point for Cloudflare Workers or Next.js edge routes.
|
|
4308
|
+
|
|
4077
4309
|
## Performance & Best Practices
|
|
4078
4310
|
|
|
4079
4311
|
### Query Optimization
|
|
@@ -4249,12 +4481,18 @@ User.updateOrCreate(search, update); // Update or create
|
|
|
4249
4481
|
User.firstOrNew(search, create); // Find or new instance
|
|
4250
4482
|
User.upsert(data, unique, update); // Upsert records
|
|
4251
4483
|
|
|
4252
|
-
// Deletion
|
|
4253
|
-
User.destroy(ids); // Delete by IDs
|
|
4484
|
+
// Deletion & seeding
|
|
4485
|
+
User.destroy(ids); // Delete by IDs (soft-delete aware)
|
|
4486
|
+
User.truncate(); // Delete all rows in the table
|
|
4487
|
+
User.seed(n); // Create n records using the registered factory
|
|
4488
|
+
User.prune(); // Delete all records matching static prunable()
|
|
4254
4489
|
User.withTrashed(); // Include soft deleted
|
|
4255
4490
|
User.onlyTrashed(); // Only soft deleted
|
|
4256
4491
|
User.withoutTrashed(); // Exclude soft deleted
|
|
4257
4492
|
|
|
4493
|
+
// Events
|
|
4494
|
+
User.withoutEvents(async () => { ... }); // Run without firing events
|
|
4495
|
+
|
|
4258
4496
|
// Configuration
|
|
4259
4497
|
User.getTableName(); // Get table name
|
|
4260
4498
|
User.getPrimaryKey(); // Get primary key
|
|
@@ -4262,6 +4500,7 @@ User.getKeyType(); // Get key type
|
|
|
4262
4500
|
User.getIncrementing(); // Get incrementing flag
|
|
4263
4501
|
User.getConnectionName(); // Get connection name
|
|
4264
4502
|
User.generateUuid(); // Generate UUID
|
|
4503
|
+
User.generateUlid(); // Generate ULID
|
|
4265
4504
|
|
|
4266
4505
|
// Events
|
|
4267
4506
|
User.creating(callback); // Before creating
|
|
@@ -4297,6 +4536,10 @@ user.update(attributes); // Update model
|
|
|
4297
4536
|
user.delete(); // Delete model
|
|
4298
4537
|
user.forceDelete(); // Force delete (ignores softDeletes)
|
|
4299
4538
|
user.restore(); // Restore soft deleted
|
|
4539
|
+
user.fresh(); // Re-fetch from DB and return new instance
|
|
4540
|
+
user.is(other); // Check if two instances are the same record
|
|
4541
|
+
user.isNot(other); // Inverse of is()
|
|
4542
|
+
user.replicate(except?); // Clone as unsaved record (excludes PK + timestamps)
|
|
4300
4543
|
|
|
4301
4544
|
// Attributes
|
|
4302
4545
|
user.fill(attributes); // Mass assign (respects fillable/guarded)
|
|
@@ -4435,6 +4678,18 @@ query.findOrFail(id); // Find or throw
|
|
|
4435
4678
|
query.pluck(column); // Get column values
|
|
4436
4679
|
query.exists(); // Check existence
|
|
4437
4680
|
query.doesntExist(); // Check non-existence
|
|
4681
|
+
query.sole(); // Get exactly one result — throws if zero or more than one
|
|
4682
|
+
query.tap(callback); // Run a callback for debugging without breaking the chain
|
|
4683
|
+
query.values(); // Return plain objects instead of model instances
|
|
4684
|
+
```
|
|
4685
|
+
|
|
4686
|
+
#### Soft Deletes (QueryBuilder)
|
|
4687
|
+
|
|
4688
|
+
```javascript
|
|
4689
|
+
query.withTrashed(); // include soft-deleted records
|
|
4690
|
+
query.onlyTrashed(); // only soft-deleted records
|
|
4691
|
+
query.withoutTrashed(); // exclude soft-deleted (default)
|
|
4692
|
+
query.restore(); // bulk-restore matched soft-deleted records
|
|
4438
4693
|
```
|
|
4439
4694
|
|
|
4440
4695
|
#### Pagination
|
|
@@ -4469,9 +4724,10 @@ query.upsert(data, uniqueBy, update);
|
|
|
4469
4724
|
query.with(...relations);
|
|
4470
4725
|
query.withConstraints(relation, callback);
|
|
4471
4726
|
query.withCount(...relations); // adds relation_count subquery column per model
|
|
4472
|
-
query.
|
|
4473
|
-
query.
|
|
4474
|
-
query.
|
|
4727
|
+
query.has(relation, operator?, count?); // WHERE EXISTS, or count-based (e.g. has('posts', '>', 5))
|
|
4728
|
+
query.whereHas(relation, callback); // WHERE EXISTS with constraint subquery
|
|
4729
|
+
query.doesntHave(relation); // WHERE NOT EXISTS subquery
|
|
4730
|
+
query.whereDoesntHave(relation, callback); // WHERE NOT EXISTS with constraint
|
|
4475
4731
|
```
|
|
4476
4732
|
|
|
4477
4733
|
#### Locking
|