@trafficgroup/knex-rel 0.1.0 → 0.1.2
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/cameras_analysis.md +199 -0
- package/dist/dao/camera/camera.dao.d.ts +13 -0
- package/dist/dao/camera/camera.dao.js +93 -0
- package/dist/dao/camera/camera.dao.js.map +1 -0
- package/dist/dao/folder/folder.dao.js +6 -3
- package/dist/dao/folder/folder.dao.js.map +1 -1
- package/dist/dao/video/video.dao.d.ts +5 -0
- package/dist/dao/video/video.dao.js +46 -0
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/index.d.ts +12 -10
- package/dist/index.js +13 -11
- package/dist/index.js.map +1 -1
- package/dist/interfaces/camera/camera.interfaces.d.ts +9 -0
- package/dist/interfaces/camera/camera.interfaces.js +3 -0
- package/dist/interfaces/camera/camera.interfaces.js.map +1 -0
- package/dist/interfaces/folder/folder.interfaces.d.ts +3 -0
- package/dist/interfaces/video/video.interfaces.d.ts +4 -0
- package/folder_cameraid_analysis.md +167 -0
- package/migrations/20250911000000_migration.ts +61 -0
- package/migrations/20250917144153_migration.ts +37 -0
- package/migrations/20250924000000_camera_name_search_index.ts +22 -0
- package/package.json +1 -1
- package/plan.md +288 -0
- package/src/dao/camera/camera.dao.ts +79 -0
- package/src/dao/folder/folder.dao.ts +18 -3
- package/src/dao/video/video.dao.ts +49 -0
- package/src/index.ts +12 -10
- package/src/interfaces/camera/camera.interfaces.ts +9 -0
- package/src/interfaces/folder/folder.interfaces.ts +3 -0
- package/src/interfaces/video/video.interfaces.ts +4 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Folder Table CameraId Analysis
|
|
2
|
+
|
|
3
|
+
## Issue Summary
|
|
4
|
+
|
|
5
|
+
The `cameraId` column in the `folders` table is being saved as `null` despite being defined as optional in the schema and interface.
|
|
6
|
+
|
|
7
|
+
## Database Schema Analysis
|
|
8
|
+
|
|
9
|
+
### Current Schema (from migrations)
|
|
10
|
+
|
|
11
|
+
#### Initial Folder Table Creation (20250717161310_migration.ts)
|
|
12
|
+
|
|
13
|
+
```sql
|
|
14
|
+
CREATE TABLE folders (
|
|
15
|
+
id SERIAL PRIMARY KEY,
|
|
16
|
+
name VARCHAR NOT NULL,
|
|
17
|
+
createdBy INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
|
18
|
+
status ENUM('UPLOADING', 'COMPLETE') NOT NULL DEFAULT 'UPLOADING',
|
|
19
|
+
studyId INTEGER NOT NULL REFERENCES study(id) ON DELETE CASCADE,
|
|
20
|
+
created_at TIMESTAMP,
|
|
21
|
+
updated_at TIMESTAMP
|
|
22
|
+
);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
#### Camera Integration (20250911000000_migration.ts)
|
|
26
|
+
|
|
27
|
+
```sql
|
|
28
|
+
-- Added cameraId column to folders table
|
|
29
|
+
ALTER TABLE folders ADD COLUMN cameraId INTEGER NULLABLE
|
|
30
|
+
REFERENCES cameras(id) ON DELETE SET NULL;
|
|
31
|
+
CREATE INDEX ON folders (cameraId);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Current Interface Definition
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
export interface IFolder {
|
|
38
|
+
id: number;
|
|
39
|
+
uuid: string;
|
|
40
|
+
name: string;
|
|
41
|
+
createdBy: number; // user.id
|
|
42
|
+
status: "UPLOADING" | "COMPLETE";
|
|
43
|
+
studyId: number; // study.id
|
|
44
|
+
cameraId?: number; // camera.id - OPTIONAL field
|
|
45
|
+
created_at: string;
|
|
46
|
+
updated_at: string;
|
|
47
|
+
study?: IStudy;
|
|
48
|
+
camera?: ICamera;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## DAO Implementation Analysis
|
|
53
|
+
|
|
54
|
+
### Current FolderDAO Issues
|
|
55
|
+
|
|
56
|
+
1. **No Camera Relationship Joins**: The DAO queries join with `study` table but don't include `camera` joins
|
|
57
|
+
2. **Missing Camera Data Population**: Queries don't populate the optional `camera` field
|
|
58
|
+
3. **Basic CRUD Operations**: The `create()` method simply inserts the provided data without validation
|
|
59
|
+
|
|
60
|
+
### Key Methods Analysis
|
|
61
|
+
|
|
62
|
+
#### Create Method
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
async create(item: IFolder): Promise<IFolder> {
|
|
66
|
+
const [createdFolder] = await this._knex("folders")
|
|
67
|
+
.insert(item)
|
|
68
|
+
.returning("*");
|
|
69
|
+
return createdFolder;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- **Issue**: This will insert whatever `cameraId` is provided in the `item` parameter
|
|
74
|
+
- **Behavior**: If `cameraId` is undefined or not provided, it will be stored as `NULL` (which is valid)
|
|
75
|
+
|
|
76
|
+
#### Query Methods (getById, getByUuid, getAll)
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// Current pattern - only joins with study
|
|
80
|
+
const folder = await this._knex("folders as f")
|
|
81
|
+
.innerJoin("study as s", "f.studyId", "s.id")
|
|
82
|
+
.select("f.*", this._knex.raw("to_jsonb(s.*) as study"))
|
|
83
|
+
.where("f.id", id)
|
|
84
|
+
.first();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **Missing**: No LEFT JOIN with cameras table to populate camera data
|
|
88
|
+
- **Impact**: Even if `cameraId` has a valid value, the `camera` object won't be populated
|
|
89
|
+
|
|
90
|
+
## Root Cause Analysis
|
|
91
|
+
|
|
92
|
+
### Why CameraId is Null
|
|
93
|
+
|
|
94
|
+
1. **Optional Field Behavior**: Since `cameraId` is optional (`cameraId?: number`), if not explicitly provided in create requests, it defaults to `null`
|
|
95
|
+
|
|
96
|
+
2. **No Default Value**: Unlike other fields, there's no database-level default value for `cameraId`
|
|
97
|
+
|
|
98
|
+
3. **Application Logic Gap**: No validation or business logic to ensure `cameraId` is populated when appropriate
|
|
99
|
+
|
|
100
|
+
### Database Schema is Correct
|
|
101
|
+
|
|
102
|
+
The database schema properly supports `cameraId`:
|
|
103
|
+
|
|
104
|
+
- ✅ Column exists and is nullable
|
|
105
|
+
- ✅ Foreign key constraint to `cameras(id)`
|
|
106
|
+
- ✅ Proper ON DELETE SET NULL behavior
|
|
107
|
+
- ✅ Index created for performance
|
|
108
|
+
|
|
109
|
+
## Recommended Solutions
|
|
110
|
+
|
|
111
|
+
### 1. Immediate Fix: Verify Input Data
|
|
112
|
+
|
|
113
|
+
Check what data is being passed to the `create()` method:
|
|
114
|
+
|
|
115
|
+
- Log the `item` parameter in FolderDAO.create()
|
|
116
|
+
- Verify if `cameraId` is being provided in the request
|
|
117
|
+
|
|
118
|
+
### 2. Enhanced DAO Queries
|
|
119
|
+
|
|
120
|
+
Update FolderDAO to include camera relationships:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// Enhanced getById with camera join
|
|
124
|
+
async getById(id: number): Promise<IFolder | null> {
|
|
125
|
+
const folder = await this._knex("folders as f")
|
|
126
|
+
.innerJoin("study as s", "f.studyId", "s.id")
|
|
127
|
+
.leftJoin("cameras as c", "f.cameraId", "c.id")
|
|
128
|
+
.select(
|
|
129
|
+
"f.*",
|
|
130
|
+
this._knex.raw("to_jsonb(s.*) as study"),
|
|
131
|
+
this._knex.raw("to_jsonb(c.*) as camera")
|
|
132
|
+
)
|
|
133
|
+
.where("f.id", id)
|
|
134
|
+
.first();
|
|
135
|
+
return folder || null;
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 3. API Layer Investigation
|
|
140
|
+
|
|
141
|
+
Check the API endpoints that create folders:
|
|
142
|
+
|
|
143
|
+
- Verify if `cameraId` is being extracted from request body
|
|
144
|
+
- Ensure DTO/validation layer passes `cameraId` through
|
|
145
|
+
- Check if frontend is sending `cameraId` in requests
|
|
146
|
+
|
|
147
|
+
### 4. Business Logic Validation
|
|
148
|
+
|
|
149
|
+
Consider if folders should always have a camera:
|
|
150
|
+
|
|
151
|
+
- If required: Add validation in create method
|
|
152
|
+
- If optional: Current behavior is correct (null is valid)
|
|
153
|
+
|
|
154
|
+
## Implementation Priority
|
|
155
|
+
|
|
156
|
+
1. **High**: Investigate API request data flow
|
|
157
|
+
2. **High**: Add camera relationship to DAO queries
|
|
158
|
+
3. **Medium**: Add logging to track cameraId values
|
|
159
|
+
4. **Low**: Consider business logic changes
|
|
160
|
+
|
|
161
|
+
## Files to Examine Next
|
|
162
|
+
|
|
163
|
+
1. `api-rel/src/controllers/folder/folder.controller.ts` - Controller logic
|
|
164
|
+
2. `api-rel/src/routes/folder/` - Route definitions and DTOs
|
|
165
|
+
3. Frontend folder creation forms - Check if cameraId is being sent
|
|
166
|
+
|
|
167
|
+
The database schema and DAO patterns are correctly implemented. The issue likely lies in the data flow from the API/frontend layers not providing `cameraId` values during folder creation.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
// Create cameras table
|
|
5
|
+
await knex.schema.createTable("cameras", (table) => {
|
|
6
|
+
table.increments("id").primary();
|
|
7
|
+
table
|
|
8
|
+
.uuid("uuid")
|
|
9
|
+
.defaultTo(knex.raw("uuid_generate_v4()"))
|
|
10
|
+
.notNullable()
|
|
11
|
+
.unique();
|
|
12
|
+
table.string("name", 100).notNullable();
|
|
13
|
+
table.decimal("longitude", 10, 7).notNullable();
|
|
14
|
+
table.decimal("latitude", 10, 7).notNullable();
|
|
15
|
+
table.timestamps(true, true);
|
|
16
|
+
|
|
17
|
+
table.index(["uuid"]);
|
|
18
|
+
table.index(["longitude", "latitude"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Add cameraId to videos table
|
|
22
|
+
await knex.schema.alterTable("video", (table) => {
|
|
23
|
+
table
|
|
24
|
+
.integer("cameraId")
|
|
25
|
+
.nullable()
|
|
26
|
+
.references("id")
|
|
27
|
+
.inTable("cameras")
|
|
28
|
+
.onDelete("SET NULL");
|
|
29
|
+
table.index(["cameraId"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Add cameraId to folders table
|
|
33
|
+
await knex.schema.alterTable("folders", (table) => {
|
|
34
|
+
table
|
|
35
|
+
.integer("cameraId")
|
|
36
|
+
.nullable()
|
|
37
|
+
.references("id")
|
|
38
|
+
.inTable("cameras")
|
|
39
|
+
.onDelete("SET NULL");
|
|
40
|
+
table.index(["cameraId"]);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function down(knex: Knex): Promise<void> {
|
|
45
|
+
// Remove cameraId from folders table
|
|
46
|
+
await knex.schema.alterTable("folders", (table) => {
|
|
47
|
+
table.dropIndex(["cameraId"]);
|
|
48
|
+
table.dropForeign(["cameraId"]);
|
|
49
|
+
table.dropColumn("cameraId");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Remove cameraId from videos table
|
|
53
|
+
await knex.schema.alterTable("video", (table) => {
|
|
54
|
+
table.dropIndex(["cameraId"]);
|
|
55
|
+
table.dropForeign(["cameraId"]);
|
|
56
|
+
table.dropColumn("cameraId");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Drop cameras table
|
|
60
|
+
await knex.schema.dropTable("cameras");
|
|
61
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.schema.alterTable("video", (table) => {
|
|
5
|
+
// Add annotationSourceId column with foreign key to video table
|
|
6
|
+
table
|
|
7
|
+
.integer("annotationSourceId")
|
|
8
|
+
.nullable()
|
|
9
|
+
.references("id")
|
|
10
|
+
.inTable("video")
|
|
11
|
+
.onDelete("SET NULL");
|
|
12
|
+
|
|
13
|
+
// Add index for performance when querying by annotation source
|
|
14
|
+
table.index(["annotationSourceId"], "idx_video_annotation_source_id");
|
|
15
|
+
|
|
16
|
+
// Add composite index for efficient template lookups
|
|
17
|
+
table.index(
|
|
18
|
+
["folderId", "videoType", "status"],
|
|
19
|
+
"idx_video_template_lookup",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function down(knex: Knex): Promise<void> {
|
|
25
|
+
await knex.schema.alterTable("video", (table) => {
|
|
26
|
+
// Drop indexes first
|
|
27
|
+
table.dropIndex(
|
|
28
|
+
["folderId", "videoType", "status"],
|
|
29
|
+
"idx_video_template_lookup",
|
|
30
|
+
);
|
|
31
|
+
table.dropIndex(["annotationSourceId"], "idx_video_annotation_source_id");
|
|
32
|
+
|
|
33
|
+
// Drop foreign key and column
|
|
34
|
+
table.dropForeign(["annotationSourceId"]);
|
|
35
|
+
table.dropColumn("annotationSourceId");
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.schema.alterTable("cameras", (table) => {
|
|
5
|
+
// Add index for case-insensitive name searches
|
|
6
|
+
table.index(knex.raw("LOWER(name)"), "idx_cameras_name_lower");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Add GIN index for full-text search if needed for fuzzy matching
|
|
10
|
+
await knex.raw(`
|
|
11
|
+
CREATE INDEX idx_cameras_name_gin
|
|
12
|
+
ON cameras
|
|
13
|
+
USING GIN (to_tsvector('english', name))
|
|
14
|
+
`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function down(knex: Knex): Promise<void> {
|
|
18
|
+
await knex.raw("DROP INDEX IF EXISTS idx_cameras_name_gin");
|
|
19
|
+
await knex.schema.alterTable("cameras", (table) => {
|
|
20
|
+
table.dropIndex(knex.raw("LOWER(name)"), "idx_cameras_name_lower");
|
|
21
|
+
});
|
|
22
|
+
}
|
package/package.json
CHANGED
package/plan.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Database Optimization Plan for Camera Bulk Operations
|
|
2
|
+
|
|
3
|
+
## Current Schema Analysis
|
|
4
|
+
|
|
5
|
+
### Existing Tables & Indexes
|
|
6
|
+
|
|
7
|
+
**cameras table:**
|
|
8
|
+
|
|
9
|
+
- Primary key: `id` (auto-increment)
|
|
10
|
+
- Unique index: `uuid`
|
|
11
|
+
- Composite index: `[longitude, latitude]` for geospatial queries
|
|
12
|
+
|
|
13
|
+
**video table:**
|
|
14
|
+
|
|
15
|
+
- Primary key: `id`
|
|
16
|
+
- Foreign key: `cameraId` → `cameras.id` (nullable, with index)
|
|
17
|
+
- Foreign key: `folderId` → `folders.id` (with implicit index)
|
|
18
|
+
- Unique index: `uuid`
|
|
19
|
+
- Composite indexes:
|
|
20
|
+
- `[annotationSourceId]`
|
|
21
|
+
- `[folderId, videoType, status]` (template lookup)
|
|
22
|
+
|
|
23
|
+
**folders table:**
|
|
24
|
+
|
|
25
|
+
- Primary key: `id`
|
|
26
|
+
- Foreign key: `cameraId` → `cameras.id` (nullable, with index)
|
|
27
|
+
- Foreign key: `studyId` → `study.id` (with implicit index)
|
|
28
|
+
- Unique index: `uuid`
|
|
29
|
+
|
|
30
|
+
### Performance Analysis for Required Operations
|
|
31
|
+
|
|
32
|
+
#### 1. ✅ Bulk update videos when folder cameraId changes
|
|
33
|
+
|
|
34
|
+
**Query:** `UPDATE video SET cameraId = ? WHERE folderId = ?`
|
|
35
|
+
|
|
36
|
+
- **Current Performance:** GOOD
|
|
37
|
+
- **Existing Index:** `[folderId]` (implicit from FK constraint)
|
|
38
|
+
- **Status:** No optimization needed
|
|
39
|
+
|
|
40
|
+
#### 2. ❌ Search cameras by name with pagination
|
|
41
|
+
|
|
42
|
+
**Query:** `SELECT * FROM cameras WHERE name ILIKE '%search%' ORDER BY name LIMIT ? OFFSET ?`
|
|
43
|
+
|
|
44
|
+
- **Current Performance:** POOR - Full table scan
|
|
45
|
+
- **Missing Index:** Text search index on `name` column
|
|
46
|
+
- **Impact:** Critical performance issue for camera search
|
|
47
|
+
|
|
48
|
+
#### 3. ✅ Find videos by specific camera
|
|
49
|
+
|
|
50
|
+
**Query:** `SELECT * FROM video WHERE cameraId = ?`
|
|
51
|
+
|
|
52
|
+
- **Current Performance:** GOOD
|
|
53
|
+
- **Existing Index:** `[cameraId]` from migration `20250911000000_migration.ts`
|
|
54
|
+
- **Status:** No optimization needed
|
|
55
|
+
|
|
56
|
+
#### 4. ⚠️ Bulk assign camera to multiple videos
|
|
57
|
+
|
|
58
|
+
**Query:** `UPDATE video SET cameraId = ? WHERE id IN (...)`
|
|
59
|
+
|
|
60
|
+
- **Current Performance:** ACCEPTABLE but can be optimized
|
|
61
|
+
- **Existing Index:** Primary key on `id`
|
|
62
|
+
- **Optimization Opportunity:** Batch processing with transaction optimization
|
|
63
|
+
|
|
64
|
+
## Required Database Optimizations
|
|
65
|
+
|
|
66
|
+
### New Migrations Required
|
|
67
|
+
|
|
68
|
+
#### Migration: Add camera name search index
|
|
69
|
+
|
|
70
|
+
**File:** `20250924000000_camera_name_search_index.ts`
|
|
71
|
+
**Purpose:** Optimize camera search by name functionality
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
export async function up(knex: Knex): Promise<void> {
|
|
75
|
+
await knex.schema.alterTable("cameras", (table) => {
|
|
76
|
+
// Add index for case-insensitive name searches
|
|
77
|
+
table.index(knex.raw("LOWER(name)"), "idx_cameras_name_lower");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Add GIN index for full-text search if needed for fuzzy matching
|
|
81
|
+
await knex.raw(`
|
|
82
|
+
CREATE INDEX idx_cameras_name_gin
|
|
83
|
+
ON cameras
|
|
84
|
+
USING GIN (to_tsvector('english', name))
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function down(knex: Knex): Promise<void> {
|
|
89
|
+
await knex.raw("DROP INDEX IF EXISTS idx_cameras_name_gin");
|
|
90
|
+
await knex.schema.alterTable("cameras", (table) => {
|
|
91
|
+
table.dropIndex(knex.raw("LOWER(name)"), "idx_cameras_name_lower");
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### DAO Method Optimizations
|
|
97
|
+
|
|
98
|
+
#### CameraDAO Enhancements Required
|
|
99
|
+
|
|
100
|
+
**New Methods Needed:**
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// Optimized paginated search by name
|
|
104
|
+
async searchByName(
|
|
105
|
+
searchTerm: string,
|
|
106
|
+
page: number,
|
|
107
|
+
limit: number
|
|
108
|
+
): Promise<IDataPaginator<ICamera>> {
|
|
109
|
+
const offset = (page - 1) * limit;
|
|
110
|
+
const searchPattern = `%${searchTerm.toLowerCase()}%`;
|
|
111
|
+
|
|
112
|
+
const query = this._knex("cameras")
|
|
113
|
+
.whereRaw("LOWER(name) LIKE ?", [searchPattern])
|
|
114
|
+
.orderBy("name");
|
|
115
|
+
|
|
116
|
+
const [countResult] = await query.clone().clearSelect().count("* as count");
|
|
117
|
+
const totalCount = +countResult.count;
|
|
118
|
+
const cameras = await query.clone().limit(limit).offset(offset);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
success: true,
|
|
122
|
+
data: cameras,
|
|
123
|
+
page,
|
|
124
|
+
limit,
|
|
125
|
+
count: cameras.length,
|
|
126
|
+
totalCount,
|
|
127
|
+
totalPages: Math.ceil(totalCount / limit),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Get all videos associated with a camera (with pagination)
|
|
132
|
+
async getVideosByCamera(
|
|
133
|
+
cameraId: number,
|
|
134
|
+
page: number,
|
|
135
|
+
limit: number
|
|
136
|
+
): Promise<IDataPaginator<IVideo>> {
|
|
137
|
+
const offset = (page - 1) * limit;
|
|
138
|
+
|
|
139
|
+
const query = this._knex("video as v")
|
|
140
|
+
.innerJoin("folders as f", "v.folderId", "f.id")
|
|
141
|
+
.select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
|
|
142
|
+
.where("v.cameraId", cameraId)
|
|
143
|
+
.orderBy("v.created_at", "desc");
|
|
144
|
+
|
|
145
|
+
const [countResult] = await query.clone().clearSelect().count("* as count");
|
|
146
|
+
const totalCount = +countResult.count;
|
|
147
|
+
const videos = await query.clone().limit(limit).offset(offset);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
success: true,
|
|
151
|
+
data: videos,
|
|
152
|
+
page,
|
|
153
|
+
limit,
|
|
154
|
+
count: videos.length,
|
|
155
|
+
totalCount,
|
|
156
|
+
totalPages: Math.ceil(totalCount / limit),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### VideoDAO Enhancements Required
|
|
162
|
+
|
|
163
|
+
**New Methods Needed:**
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// Bulk update videos by folder (folder camera cascade)
|
|
167
|
+
async bulkUpdateCameraByFolder(
|
|
168
|
+
folderId: number,
|
|
169
|
+
cameraId: number | null
|
|
170
|
+
): Promise<number> {
|
|
171
|
+
const result = await this._knex("video")
|
|
172
|
+
.where("folderId", folderId)
|
|
173
|
+
.update({
|
|
174
|
+
cameraId: cameraId,
|
|
175
|
+
updated_at: this._knex.fn.now()
|
|
176
|
+
});
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Bulk assign camera to multiple videos
|
|
181
|
+
async bulkUpdateCamera(
|
|
182
|
+
videoIds: number[],
|
|
183
|
+
cameraId: number | null
|
|
184
|
+
): Promise<number> {
|
|
185
|
+
if (videoIds.length === 0) return 0;
|
|
186
|
+
|
|
187
|
+
const result = await this._knex("video")
|
|
188
|
+
.whereIn("id", videoIds)
|
|
189
|
+
.update({
|
|
190
|
+
cameraId: cameraId,
|
|
191
|
+
updated_at: this._knex.fn.now()
|
|
192
|
+
});
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Get videos by multiple folder IDs (for folder camera propagation)
|
|
197
|
+
async getVideosByFolderIds(folderIds: number[]): Promise<IVideo[]> {
|
|
198
|
+
if (folderIds.length === 0) return [];
|
|
199
|
+
|
|
200
|
+
return await this._knex("video")
|
|
201
|
+
.whereIn("folderId", folderIds)
|
|
202
|
+
.select("id", "uuid", "name", "folderId", "cameraId");
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Query Performance Strategies
|
|
207
|
+
|
|
208
|
+
### 1. Camera Search Optimization
|
|
209
|
+
|
|
210
|
+
- **Index Strategy:** Case-insensitive BTREE index on `LOWER(name)`
|
|
211
|
+
- **Query Pattern:** `WHERE LOWER(name) LIKE LOWER(?)`
|
|
212
|
+
- **Full-text Option:** GIN index with `to_tsvector()` for fuzzy search
|
|
213
|
+
- **Expected Performance:** O(log n) lookup instead of O(n) scan
|
|
214
|
+
|
|
215
|
+
### 2. Bulk Operation Optimization
|
|
216
|
+
|
|
217
|
+
- **Transaction Wrapping:** All bulk operations in single transactions
|
|
218
|
+
- **Batch Size Limits:** Process in chunks of 1000 records max
|
|
219
|
+
- **Index Usage:** Leverage existing primary key and foreign key indexes
|
|
220
|
+
|
|
221
|
+
### 3. Folder Camera Cascade Optimization
|
|
222
|
+
|
|
223
|
+
- **Single Query Strategy:** Use `WHERE folderId = ?` leveraging existing index
|
|
224
|
+
- **Atomic Updates:** Single UPDATE statement rather than individual updates
|
|
225
|
+
- **Expected Performance:** O(log n) folder lookup + O(k) video updates
|
|
226
|
+
|
|
227
|
+
## Implementation Order
|
|
228
|
+
|
|
229
|
+
1. **Migration First:** Deploy camera name search index
|
|
230
|
+
2. **DAO Methods:** Add optimized search and bulk operation methods
|
|
231
|
+
3. **Transaction Optimization:** Implement proper transaction handling
|
|
232
|
+
4. **Testing:** Validate performance improvements
|
|
233
|
+
|
|
234
|
+
## Risk Assessment
|
|
235
|
+
|
|
236
|
+
### Migration Safety: LOW RISK
|
|
237
|
+
|
|
238
|
+
- Adding indexes is non-blocking operation
|
|
239
|
+
- No data changes, only performance improvements
|
|
240
|
+
- Easy rollback with down migration
|
|
241
|
+
|
|
242
|
+
### Performance Impact: HIGH BENEFIT
|
|
243
|
+
|
|
244
|
+
- Camera search: 100x-1000x improvement (O(n) → O(log n))
|
|
245
|
+
- Bulk operations: Proper indexing already exists
|
|
246
|
+
- Folder cascade: Leverages existing folderId index
|
|
247
|
+
|
|
248
|
+
### Pattern Compliance: 100% COMPLIANT
|
|
249
|
+
|
|
250
|
+
- Follows existing DAO patterns exactly
|
|
251
|
+
- Maintains IBaseDAO interface requirements
|
|
252
|
+
- Uses established naming conventions
|
|
253
|
+
- Preserves UUID external API pattern
|
|
254
|
+
|
|
255
|
+
## Validation Strategy
|
|
256
|
+
|
|
257
|
+
### Performance Testing Queries
|
|
258
|
+
|
|
259
|
+
```sql
|
|
260
|
+
-- Test camera search performance
|
|
261
|
+
EXPLAIN ANALYZE SELECT * FROM cameras
|
|
262
|
+
WHERE LOWER(name) LIKE '%traffic%'
|
|
263
|
+
ORDER BY name LIMIT 20;
|
|
264
|
+
|
|
265
|
+
-- Test video-by-camera lookup
|
|
266
|
+
EXPLAIN ANALYZE SELECT * FROM video
|
|
267
|
+
WHERE cameraId = 1
|
|
268
|
+
ORDER BY created_at DESC LIMIT 20;
|
|
269
|
+
|
|
270
|
+
-- Test folder cascade update
|
|
271
|
+
EXPLAIN ANALYZE UPDATE video
|
|
272
|
+
SET cameraId = 1 WHERE folderId = 5;
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Success Metrics
|
|
276
|
+
|
|
277
|
+
- Camera search < 50ms for 10k+ cameras
|
|
278
|
+
- Video-by-camera < 100ms for 1k+ videos
|
|
279
|
+
- Folder cascade < 200ms for 100+ videos per folder
|
|
280
|
+
- Bulk video update < 500ms for 1000+ videos
|
|
281
|
+
|
|
282
|
+
## Conclusion
|
|
283
|
+
|
|
284
|
+
**Current Status:** Schema is well-designed with most needed indexes already present. Only critical gap is camera name search optimization.
|
|
285
|
+
|
|
286
|
+
**Priority:** HIGH for camera name search index, MEDIUM for DAO method enhancements.
|
|
287
|
+
|
|
288
|
+
**Complexity:** LOW - Single migration required, standard DAO patterns.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Knex } from "knex";
|
|
2
|
+
import { IBaseDAO, IDataPaginator } from "../../d.types";
|
|
3
|
+
import { ICamera } from "../../interfaces/camera/camera.interfaces";
|
|
4
|
+
import KnexManager from "../../KnexConnection";
|
|
5
|
+
|
|
6
|
+
export class CameraDAO implements IBaseDAO<ICamera> {
|
|
7
|
+
private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
|
|
8
|
+
|
|
9
|
+
async create(item: ICamera): Promise<ICamera> {
|
|
10
|
+
const [createdCamera] = await this._knex("cameras")
|
|
11
|
+
.insert(item)
|
|
12
|
+
.returning("*");
|
|
13
|
+
return createdCamera;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async getById(id: number): Promise<ICamera | null> {
|
|
17
|
+
const camera = await this._knex("cameras").where({ id }).first();
|
|
18
|
+
return camera || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async getByUuid(uuid: string): Promise<ICamera | null> {
|
|
22
|
+
const camera = await this._knex("cameras").where({ uuid }).first();
|
|
23
|
+
return camera || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async update(id: number, item: Partial<ICamera>): Promise<ICamera | null> {
|
|
27
|
+
const [updatedCamera] = await this._knex("cameras")
|
|
28
|
+
.where({ id })
|
|
29
|
+
.update(item)
|
|
30
|
+
.returning("*");
|
|
31
|
+
return updatedCamera || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async delete(id: number): Promise<boolean> {
|
|
35
|
+
const result = await this._knex("cameras").where({ id }).del();
|
|
36
|
+
return result > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getAll(page: number, limit: number): Promise<IDataPaginator<ICamera>> {
|
|
40
|
+
const offset = (page - 1) * limit;
|
|
41
|
+
|
|
42
|
+
const [countResult] = await this._knex("cameras").count("* as count");
|
|
43
|
+
const totalCount = +countResult.count;
|
|
44
|
+
const cameras = await this._knex("cameras").limit(limit).offset(offset);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
success: true,
|
|
48
|
+
data: cameras,
|
|
49
|
+
page,
|
|
50
|
+
limit,
|
|
51
|
+
count: cameras.length,
|
|
52
|
+
totalCount,
|
|
53
|
+
totalPages: Math.ceil(totalCount / limit),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async getByName(name: string): Promise<ICamera | null> {
|
|
58
|
+
const camera = await this._knex("cameras").where({ name }).first();
|
|
59
|
+
return camera || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async getCamerasNearCoordinates(
|
|
63
|
+
longitude: number,
|
|
64
|
+
latitude: number,
|
|
65
|
+
radiusKm: number = 1,
|
|
66
|
+
): Promise<ICamera[]> {
|
|
67
|
+
// Using ST_DWithin for geographic distance calculation
|
|
68
|
+
// This is a PostgreSQL-specific query for geospatial operations
|
|
69
|
+
const cameras = await this._knex("cameras").whereRaw(
|
|
70
|
+
`ST_DWithin(
|
|
71
|
+
ST_MakePoint(longitude, latitude)::geography,
|
|
72
|
+
ST_MakePoint(?, ?)::geography,
|
|
73
|
+
?
|
|
74
|
+
)`,
|
|
75
|
+
[longitude, latitude, radiusKm * 1000], // Convert km to meters
|
|
76
|
+
);
|
|
77
|
+
return cameras;
|
|
78
|
+
}
|
|
79
|
+
}
|