goby-database 1.0.9 → 2.0.9
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/dist/index.d.ts +125 -0
- package/dist/index.js +969 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox.d.ts +1 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/types.d.ts +170 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/unit-tests.d.ts +1 -0
- package/dist/unit-tests.js +82 -0
- package/dist/unit-tests.js.map +1 -0
- package/dist/utils.d.ts +13 -0
- package/dist/utils.js +106 -0
- package/dist/utils.js.map +1 -0
- package/package.json +11 -3
- package/src/index.ts +1251 -0
- package/src/sandbox.ts +107 -0
- package/src/types.ts +214 -0
- package/src/unit-tests.ts +117 -0
- package/src/utils.ts +133 -0
- package/tsconfig.json +111 -0
- package/index.js +0 -840
package/src/index.ts
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import {defined,partial_relation_match,full_relation_match,can_have_multiple_values,junction_col_name, side_match,two_way,edit_has_valid_sides,readable_edit} from './utils.js';
|
|
3
|
+
import type { Database as DatabaseType, Statement } from 'better-sqlite3';
|
|
4
|
+
import type {
|
|
5
|
+
SQLTableType,
|
|
6
|
+
SQLClassListRow,
|
|
7
|
+
SQLJunctonListRow,
|
|
8
|
+
JunctionSides,
|
|
9
|
+
RelationshipSideBase,
|
|
10
|
+
JunctionList,
|
|
11
|
+
ClassList,
|
|
12
|
+
ClassMetadata,
|
|
13
|
+
Property,
|
|
14
|
+
DataType,
|
|
15
|
+
ItemRelationSide,
|
|
16
|
+
SQLApplicationWindow,
|
|
17
|
+
ApplicationWindow,
|
|
18
|
+
SQLWorkspaceBlockRow,
|
|
19
|
+
WorkspaceBlock,
|
|
20
|
+
ClassData,
|
|
21
|
+
ClassRow,
|
|
22
|
+
ClassEdit,
|
|
23
|
+
RelationEdit,
|
|
24
|
+
PropertyEdit,
|
|
25
|
+
MaxValues,
|
|
26
|
+
PropertyType,
|
|
27
|
+
RelationProperty,
|
|
28
|
+
DataProperty,
|
|
29
|
+
RelationEditValidSides
|
|
30
|
+
} from './types.js';
|
|
31
|
+
|
|
32
|
+
const text_data_types=['string','resource'];
|
|
33
|
+
const real_data_types=['number'];
|
|
34
|
+
|
|
35
|
+
export default class Project{
|
|
36
|
+
db:DatabaseType;
|
|
37
|
+
|
|
38
|
+
run:{
|
|
39
|
+
[key:string]:Statement;
|
|
40
|
+
get_all_classes:Statement<[],SQLClassListRow>;
|
|
41
|
+
get_junctionlist:Statement<[],SQLJunctonListRow>;
|
|
42
|
+
get_junctions_matching_property:Statement<{class_id:number,prop_id:number | null},{id:number,sides:string}>;
|
|
43
|
+
get_windows:Statement<[],SQLApplicationWindow>;
|
|
44
|
+
get_class_id:Statement<[string],{id:number}>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
class_cache:ClassList=[];
|
|
48
|
+
item_cache:{
|
|
49
|
+
class_id:number,
|
|
50
|
+
items:ClassRow[]
|
|
51
|
+
}[]=[]
|
|
52
|
+
junction_cache:JunctionList=[];
|
|
53
|
+
|
|
54
|
+
constructor(source:string){
|
|
55
|
+
this.db= new Database(source);
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
//checks if goby has been initialized, initializes if not
|
|
59
|
+
const goby_init=this.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='system_root'`).get();
|
|
60
|
+
if(!goby_init){
|
|
61
|
+
console.log('initializing goby database');
|
|
62
|
+
this.init();
|
|
63
|
+
}else{
|
|
64
|
+
console.log('opened goby database');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//prepared statements with arguments so my code isn't as verbose elsewhere
|
|
68
|
+
this.run={
|
|
69
|
+
begin:this.db.prepare('BEGIN IMMEDIATE'),
|
|
70
|
+
commit:this.db.prepare('COMMIT'),
|
|
71
|
+
rollback:this.db.prepare('ROLLBACK'),
|
|
72
|
+
create_item:this.db.prepare('INSERT INTO system_root(type,value) VALUES (@type, @value)'),
|
|
73
|
+
get_junctionlist:this.db.prepare<[],SQLJunctonListRow>(`
|
|
74
|
+
SELECT
|
|
75
|
+
id,
|
|
76
|
+
json_array(
|
|
77
|
+
json_object('class_id',side_0_class_id,'prop_id',side_0_prop_id),
|
|
78
|
+
json_object('class_id',side_1_class_id,'prop_id',side_1_prop_id)
|
|
79
|
+
) AS sides,
|
|
80
|
+
metadata FROM system_junctionlist`),
|
|
81
|
+
get_junctions_matching_property:this.db.prepare<{class_id:number,prop_id:number | null},{id:number,sides:string}>(`
|
|
82
|
+
SELECT
|
|
83
|
+
id,
|
|
84
|
+
json_array(
|
|
85
|
+
json_object('class_id',side_0_class_id,'prop_id',side_0_prop_id),
|
|
86
|
+
json_object('class_id',side_1_class_id,'prop_id',side_1_prop_id)
|
|
87
|
+
) AS sides,
|
|
88
|
+
metadata
|
|
89
|
+
FROM system_junctionlist
|
|
90
|
+
WHERE (side_0_class_id = @class_id AND side_0_prop_id = @prop_id)
|
|
91
|
+
OR (side_1_class_id = @class_id AND side_1_prop_id = @prop_id)
|
|
92
|
+
`),
|
|
93
|
+
get_class:this.db.prepare(`SELECT name, metadata FROM system_classlist WHERE id = ?`),
|
|
94
|
+
get_class_id:this.db.prepare<[string],{id:number}>(`SELECT id FROM system_classlist WHERE name = ?`),
|
|
95
|
+
get_all_classes:this.db.prepare<[],SQLClassListRow>(`SELECT id, name, metadata FROM system_classlist`),
|
|
96
|
+
save_class_meta:this.db.prepare(`UPDATE system_classlist set metadata = ? WHERE id = ?`),
|
|
97
|
+
update_window:this.db.prepare(`UPDATE system_windows set open = @open, type=@type, metadata = @meta WHERE id = @id`),
|
|
98
|
+
create_window: this.db.prepare(`INSERT INTO system_windows (type,open, metadata) VALUES (@type, @open, @meta)`),
|
|
99
|
+
get_windows:this.db.prepare<[],SQLApplicationWindow>(`SELECT id, type, open, metadata FROM system_windows`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
this.refresh_caches(['classlist','items','junctions']);
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
// commenting this out until I figure out my transaction / one-step-undo functionality
|
|
110
|
+
//if I understand transactions correctly, a new one will begin with every user action while committing the one before, meaning I'll need to have the first begin here
|
|
111
|
+
// this.run.begin.run();
|
|
112
|
+
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get_latest_table_row_id(table_name:string):number | null{
|
|
116
|
+
let db_get=this.db.prepare<[],{id:number}>(`SELECT last_insert_rowid() AS id FROM ${table_name}`).get();
|
|
117
|
+
// if no row found, silently return null
|
|
118
|
+
if(!db_get) return null;
|
|
119
|
+
let id=db_get.id;
|
|
120
|
+
return id;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
init(){
|
|
124
|
+
//System table to contain all items in the project.
|
|
125
|
+
this.create_table('system','root',[
|
|
126
|
+
'id INTEGER NOT NULL PRIMARY KEY',
|
|
127
|
+
'type TEXT',
|
|
128
|
+
'value TEXT'
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
//System table to contain metadata for all classes created by user
|
|
132
|
+
this.create_table('system','classlist',['id INTEGER NOT NULL PRIMARY KEY','name TEXT','metadata TEXT']);
|
|
133
|
+
//System table to contain all the junction tables and aggregate info about relations
|
|
134
|
+
this.create_table('system','junctionlist',[
|
|
135
|
+
'id INTEGER NOT NULL PRIMARY KEY',
|
|
136
|
+
'side_0_class_id INTEGER NOT NULL',
|
|
137
|
+
'side_0_prop_id INTEGER',
|
|
138
|
+
'side_1_class_id INTEGER NOT NULL',
|
|
139
|
+
'side_1_prop_id INTEGER',
|
|
140
|
+
'metadata TEXT'
|
|
141
|
+
]);
|
|
142
|
+
//System table to contain generated image data
|
|
143
|
+
this.create_table('system','images',['file_path TEXT','img_type TEXT','img BLOB']);
|
|
144
|
+
|
|
145
|
+
// window "open" is a boolean stored as 0 or 1
|
|
146
|
+
this.create_table('system','windows',[
|
|
147
|
+
'id INTEGER NOT NULL PRIMARY KEY',
|
|
148
|
+
'type TEXT',
|
|
149
|
+
'open INTEGER',
|
|
150
|
+
'metadata TEXT'
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
this.db.prepare(`INSERT INTO system_windows
|
|
155
|
+
(type, open, metadata)
|
|
156
|
+
VALUES
|
|
157
|
+
('home',0,'${JSON.stringify({pos:[null,null], size:[540,400]})}'),
|
|
158
|
+
('hopper',0,'${JSON.stringify({pos:[null,null], size:[300,400]})}')`).run();
|
|
159
|
+
|
|
160
|
+
// [TO ADD: special junction table for root items to reference themselves in individual relation]
|
|
161
|
+
this.create_table('system','junction_root',[
|
|
162
|
+
'id_1 INTEGER',
|
|
163
|
+
'id_2 INTEGER',
|
|
164
|
+
'metadata TEXT'
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
refresh_caches(caches:('classlist' | 'items' | 'junctions')[]){
|
|
175
|
+
if(caches.includes('classlist')){
|
|
176
|
+
this.class_cache=this.retrieve_all_classes();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if(caches.includes('items')){
|
|
180
|
+
let refreshed_items=[];
|
|
181
|
+
for(let class_data of this.class_cache){
|
|
182
|
+
let items=this.retrieve_class_items({class_id:class_data.id});
|
|
183
|
+
refreshed_items.push({
|
|
184
|
+
class_id:class_data.id,
|
|
185
|
+
items
|
|
186
|
+
})
|
|
187
|
+
class_data.items=items;
|
|
188
|
+
}
|
|
189
|
+
this.item_cache=refreshed_items;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if(caches.includes('junctions')){
|
|
193
|
+
this.junction_cache=this.get_junctions();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
create_table(type:SQLTableType,name:string | number,columns:string[]){
|
|
199
|
+
//type will pass in 'class', 'system', or 'junction' to use as a name prefix
|
|
200
|
+
//columns is an array of raw SQL column strings
|
|
201
|
+
|
|
202
|
+
let columns_string=columns.join(',');
|
|
203
|
+
|
|
204
|
+
//brackets to allow special characters in user-defined names
|
|
205
|
+
// validation test: what happens if there are brackets in the name?
|
|
206
|
+
const sqlname=type=='class'?`[class_${name}]`:type=='properties'?`class_${name}_properties`:`${type}_${name}`;
|
|
207
|
+
let create_statement=`CREATE TABLE ${sqlname}(
|
|
208
|
+
${columns_string}
|
|
209
|
+
)`;
|
|
210
|
+
this.db.prepare(create_statement).run();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
action_create_class(name:string ):number{
|
|
214
|
+
|
|
215
|
+
//a class starts with these basic columns
|
|
216
|
+
let columns=[
|
|
217
|
+
'system_id INTEGER UNIQUE',
|
|
218
|
+
'system_order REAL',
|
|
219
|
+
'user_name TEXT',
|
|
220
|
+
`FOREIGN KEY(system_id) REFERENCES system_root(id)`
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
this.create_table('class',name,columns);
|
|
224
|
+
|
|
225
|
+
const class_meta:ClassMetadata={
|
|
226
|
+
style:{
|
|
227
|
+
color: '#b5ffd5'
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// create entry for class in classlist
|
|
232
|
+
this.db.prepare(`INSERT INTO system_classlist (name, metadata) VALUES ('${name}','${JSON.stringify(class_meta)}')`).run();
|
|
233
|
+
|
|
234
|
+
//get the id of the newest value
|
|
235
|
+
const class_id=this.db.prepare<[],{id:number}>('SELECT id FROM system_classlist ORDER BY id DESC').get()?.id;
|
|
236
|
+
if(class_id==undefined) throw new Error('Something went wrong when generating new class.');
|
|
237
|
+
|
|
238
|
+
// create a table to record properties of this class
|
|
239
|
+
this.create_table('properties',class_id,[
|
|
240
|
+
`id INTEGER NOT NULL PRIMARY KEY`,
|
|
241
|
+
`system_order REAL`,
|
|
242
|
+
`name TEXT NOT NULL`,
|
|
243
|
+
`type`,
|
|
244
|
+
`data_type TEXT`,
|
|
245
|
+
`max_values INTEGER`,
|
|
246
|
+
`metadata TEXT`
|
|
247
|
+
])
|
|
248
|
+
|
|
249
|
+
this.refresh_caches(['classlist']);
|
|
250
|
+
|
|
251
|
+
// add an entry in the property table for the default "name" property.
|
|
252
|
+
this.action_add_data_property({class_id,name:'name',data_type:'string',max_values:1,create_column:false})
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
return class_id;
|
|
256
|
+
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
action_add_data_property({
|
|
261
|
+
class_id,
|
|
262
|
+
name,
|
|
263
|
+
data_type,
|
|
264
|
+
max_values,
|
|
265
|
+
create_column=true
|
|
266
|
+
}:{
|
|
267
|
+
class_id:number,
|
|
268
|
+
name:string,
|
|
269
|
+
data_type:DataType,
|
|
270
|
+
max_values:MaxValues,
|
|
271
|
+
create_column?:boolean
|
|
272
|
+
}){
|
|
273
|
+
// 1. Add property to property table ---------------------------------------------------
|
|
274
|
+
|
|
275
|
+
let property_table=`class_${class_id}_properties`;
|
|
276
|
+
const system_order=this.get_next_order(property_table);
|
|
277
|
+
|
|
278
|
+
this.db.prepare(`INSERT INTO ${property_table} (name,type,data_type,max_values,metadata,system_order) VALUES (@name,@type,@data_type,@max_values,@metadata,@system_order)`).run({
|
|
279
|
+
name,
|
|
280
|
+
type:'data',
|
|
281
|
+
data_type,
|
|
282
|
+
max_values,
|
|
283
|
+
metadata:'{}',
|
|
284
|
+
system_order
|
|
285
|
+
})
|
|
286
|
+
// let prop_id=this.get_latest_table_row_id(property_table);
|
|
287
|
+
|
|
288
|
+
// 2. Add column to class table ------------------------------------------------
|
|
289
|
+
if(create_column){
|
|
290
|
+
let class_data=this.class_cache.find(a=>a.id==class_id);
|
|
291
|
+
if(class_data == undefined) throw new Error('Cannot find class in class list.');
|
|
292
|
+
let class_name=class_data.name;
|
|
293
|
+
|
|
294
|
+
let sql_data_type='';
|
|
295
|
+
|
|
296
|
+
if(can_have_multiple_values(max_values)||text_data_types.includes(data_type)){
|
|
297
|
+
//multiple for data means a stringified array no matter what it is
|
|
298
|
+
sql_data_type='TEXT';
|
|
299
|
+
}else if(real_data_types.includes(data_type)){
|
|
300
|
+
sql_data_type='REAL';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
//create property in table
|
|
304
|
+
let command_string=`ALTER TABLE [class_${class_name}] ADD COLUMN [user_${name}] ${sql_data_type};`;
|
|
305
|
+
this.db.prepare(command_string).run();
|
|
306
|
+
}
|
|
307
|
+
this.refresh_caches(['classlist']);
|
|
308
|
+
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
action_add_relation_property(class_id:number,name:string,max_values:MaxValues){
|
|
312
|
+
|
|
313
|
+
let property_table=`class_${class_id}_properties`;
|
|
314
|
+
|
|
315
|
+
const system_order=this.get_next_order(property_table);
|
|
316
|
+
|
|
317
|
+
this.db.prepare(`INSERT INTO ${property_table} (name,type,max_values,metadata,system_order) VALUES (@name,@type,@max_values,@metadata,@system_order)`).run({
|
|
318
|
+
name,
|
|
319
|
+
type:'relation',
|
|
320
|
+
max_values,
|
|
321
|
+
metadata:'{}',
|
|
322
|
+
system_order
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
let prop_id=this.get_latest_table_row_id(property_table);
|
|
326
|
+
|
|
327
|
+
if(defined(prop_id)){
|
|
328
|
+
this.refresh_caches(['classlist']);
|
|
329
|
+
return prop_id;
|
|
330
|
+
}else{
|
|
331
|
+
throw Error('Something went wrong registering a property for the class')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// WONDERING WHERE THE RELATIONSHIP TARGET LOGIC IS?
|
|
335
|
+
// this info is not stored directly on the property, but as a relationship/junction record
|
|
336
|
+
// this is processed in action_edit_class_schema, which handles relation changes/additions concurrently for all the classes they affect.
|
|
337
|
+
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
delete_property(class_id:number,prop_id:number){
|
|
342
|
+
// NOTE: I need to enforce that you can’t delete the default "name" property
|
|
343
|
+
|
|
344
|
+
// this function is meant to be used within a flow where the relations that need to change as a result of this deletion are already kept track of
|
|
345
|
+
|
|
346
|
+
let class_data=this.class_cache.find(a=>a.id==class_id);
|
|
347
|
+
if(!class_data) throw new Error('Cannot locate class to delete property from.');
|
|
348
|
+
let property=class_data.properties.find(a=>a.id==prop_id);
|
|
349
|
+
if(!property) throw new Error('Cannot locate property to delete.');
|
|
350
|
+
|
|
351
|
+
// delete it from property record
|
|
352
|
+
this.db.prepare(`DELETE FROM class_${class_id}_properties WHERE id = ${prop_id}`).run();
|
|
353
|
+
|
|
354
|
+
if(property.type=='data'){
|
|
355
|
+
// drop column from class table if of type data
|
|
356
|
+
this.db.prepare(`ALTER TABLE class_${class_id} DROP COLUMN [user_${property.name}]`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
this.refresh_caches(['classlist']);
|
|
360
|
+
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
get_junctions(){
|
|
365
|
+
let junction_list_sql=this.run.get_junctionlist.all();
|
|
366
|
+
let junction_list_parsed=junction_list_sql.map(a=>{
|
|
367
|
+
let sides=JSON.parse(a.sides) as JunctionSides;
|
|
368
|
+
return {
|
|
369
|
+
...a,
|
|
370
|
+
sides
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
return junction_list_parsed;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
action_edit_class_schema({
|
|
377
|
+
class_edits =[],
|
|
378
|
+
property_edits = [],
|
|
379
|
+
relationship_edits = []
|
|
380
|
+
}:{
|
|
381
|
+
class_edits?:ClassEdit[],
|
|
382
|
+
property_edits?:PropertyEdit[],
|
|
383
|
+
relationship_edits?:RelationEdit[]
|
|
384
|
+
}){
|
|
385
|
+
|
|
386
|
+
// get the list of existing relationships
|
|
387
|
+
|
|
388
|
+
// loop over class changes and make/queue them as needed
|
|
389
|
+
for(let class_edit of class_edits){
|
|
390
|
+
switch(class_edit.type){
|
|
391
|
+
case 'create':
|
|
392
|
+
// NOTE: in the future, check and enforce that the class name is unique
|
|
393
|
+
|
|
394
|
+
// register the class and get the ID
|
|
395
|
+
let class_id=this.action_create_class(class_edit.class_name);
|
|
396
|
+
// find all the properties which reference this new class name, and set the class_id.
|
|
397
|
+
for(let prop_edit of property_edits){
|
|
398
|
+
// only a newly created prop would be missing a class id
|
|
399
|
+
if(prop_edit.type=='create'){
|
|
400
|
+
if(
|
|
401
|
+
(!defined(prop_edit.class_id)) &&
|
|
402
|
+
prop_edit.class_name== class_edit.class_name
|
|
403
|
+
){
|
|
404
|
+
prop_edit.class_id=class_id;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// do the same for relations
|
|
409
|
+
for(let relationship_edit of relationship_edits){
|
|
410
|
+
if(relationship_edit.type=='create' || relationship_edit.type=='transfer'){
|
|
411
|
+
for(let side of relationship_edit.sides){
|
|
412
|
+
if(!side.class_id && side.class_name == class_edit.class_name){
|
|
413
|
+
side.class_id=class_id;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
case 'delete':
|
|
421
|
+
if(class_edit.class_id){
|
|
422
|
+
this.action_delete_class(class_edit.class_id);
|
|
423
|
+
// look for any relationships which will be affected by the deletion of this class, and queue deletion
|
|
424
|
+
for(let junction of this.junction_cache){
|
|
425
|
+
if(junction.sides.some(s=>s.class_id==class_edit.class_id)){
|
|
426
|
+
relationship_edits.push({
|
|
427
|
+
type:'delete',
|
|
428
|
+
id:junction.id
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}else{
|
|
433
|
+
throw Error("ID for class to delete not provided");
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
case 'modify_attribute':
|
|
437
|
+
// TBD, will come back to this after relation stuff is sorted
|
|
438
|
+
// this should be harmless, just key into the attribute of metadata and set the value as desired
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
// loop over property changes
|
|
445
|
+
for(let prop_edit of property_edits){
|
|
446
|
+
|
|
447
|
+
switch(prop_edit.type){
|
|
448
|
+
case 'create':
|
|
449
|
+
// class ID should be defined in class creation loop
|
|
450
|
+
if(defined(prop_edit.class_id)){
|
|
451
|
+
|
|
452
|
+
// NOTE: in the future, check and enforce that the prop name is unique
|
|
453
|
+
|
|
454
|
+
// register the property
|
|
455
|
+
if(prop_edit.config.type == 'relation'){
|
|
456
|
+
const prop_id=this.action_add_relation_property(
|
|
457
|
+
prop_edit.class_id,
|
|
458
|
+
prop_edit.prop_name,
|
|
459
|
+
prop_edit.config.max_values
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
// look for any relations which match the class id and prop name
|
|
463
|
+
// set their prop ID to the newly created one.
|
|
464
|
+
for(let relationship_edit of relationship_edits){
|
|
465
|
+
if(relationship_edit.type=='create' || relationship_edit.type=='transfer'){
|
|
466
|
+
for(let side of relationship_edit.sides){
|
|
467
|
+
if(
|
|
468
|
+
side.class_id==prop_edit.class_id &&
|
|
469
|
+
!defined(side.prop_id) &&
|
|
470
|
+
side.prop_name==prop_edit.prop_name
|
|
471
|
+
){
|
|
472
|
+
side.prop_id=prop_id;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
}
|
|
478
|
+
}else if(prop_edit.config.type == 'data'){
|
|
479
|
+
// if it's a data prop, it just has to be registered in the class table and metadata
|
|
480
|
+
this.action_add_data_property({
|
|
481
|
+
class_id:prop_edit.class_id,
|
|
482
|
+
name:prop_edit.prop_name,
|
|
483
|
+
data_type:prop_edit.config.data_type,
|
|
484
|
+
max_values:prop_edit.config.max_values
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
break;
|
|
490
|
+
case 'delete':
|
|
491
|
+
const prop=this.class_cache.find(a=>a.id==prop_edit.class_id)?.properties?.find((a)=>a.id==prop_edit.prop_id);
|
|
492
|
+
if(prop&&prop.type=='relation'){
|
|
493
|
+
// queue the deletion or transfer of relations involving this prop
|
|
494
|
+
|
|
495
|
+
for(let junction of this.junction_cache){
|
|
496
|
+
let includes_prop=junction.sides.find(s=>{
|
|
497
|
+
return s.class_id==prop_edit.class_id&&s.prop_id==prop_edit.prop_id;
|
|
498
|
+
})
|
|
499
|
+
if(includes_prop){
|
|
500
|
+
let non_matching=junction.sides.find(s=>!(s.class_id==prop_edit.class_id&&s.prop_id==prop_edit.prop_id));
|
|
501
|
+
|
|
502
|
+
if(non_matching){
|
|
503
|
+
|
|
504
|
+
if(defined(non_matching?.prop_id)){
|
|
505
|
+
// if there is a prop on the other side of the relation,
|
|
506
|
+
// and (to add) if there is not a partial match create or transfer in relationship_edits
|
|
507
|
+
// queue a transfer to a one-sided relation
|
|
508
|
+
|
|
509
|
+
// NOTE: I need to see if this can create any kind of conflict with existing relationship edits
|
|
510
|
+
relationship_edits.push({
|
|
511
|
+
type:'transfer',
|
|
512
|
+
id:junction.id,
|
|
513
|
+
sides:junction.sides,
|
|
514
|
+
new_sides:[
|
|
515
|
+
non_matching,
|
|
516
|
+
{class_id:prop_edit.class_id}
|
|
517
|
+
]
|
|
518
|
+
})
|
|
519
|
+
}else{
|
|
520
|
+
// if not, no reason to keep that relation around
|
|
521
|
+
relationship_edits.push({
|
|
522
|
+
type:'delete',
|
|
523
|
+
id:junction.id
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// NOTE: I might un-encapsulate the create and delete functions, given they should only be used from within this function
|
|
534
|
+
this.delete_property(prop_edit.class_id,prop_edit.prop_id);
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
break;
|
|
538
|
+
case 'modify':
|
|
539
|
+
// TBD, will come back to this after relation stuff is sorted
|
|
540
|
+
// changing property metadata, not including relationship targets
|
|
541
|
+
// and making any necessary changes to cell values
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
this.refresh_caches(['classlist']);
|
|
549
|
+
|
|
550
|
+
// find cases where relationships getting deleted can transfer their connections to relationships getting created
|
|
551
|
+
let consolidated_relationship_edits:RelationEditValidSides[]=this.consolidate_relationship_edits(relationship_edits);
|
|
552
|
+
|
|
553
|
+
for(let relationship_edit of consolidated_relationship_edits){
|
|
554
|
+
switch(relationship_edit.type){
|
|
555
|
+
case 'create':{
|
|
556
|
+
// create the corresponding junction table
|
|
557
|
+
let new_sides=relationship_edit.sides;
|
|
558
|
+
this.create_junction_table(new_sides);
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
case 'delete':
|
|
562
|
+
// delete the corresponding junction table
|
|
563
|
+
this.delete_junction_table(relationship_edit.id);
|
|
564
|
+
break;
|
|
565
|
+
case 'transfer':{
|
|
566
|
+
let old_sides=relationship_edit.sides;
|
|
567
|
+
let new_sides=relationship_edit.new_sides;
|
|
568
|
+
// 1. create the new junction table
|
|
569
|
+
const junction_id=this.create_junction_table(new_sides);
|
|
570
|
+
|
|
571
|
+
// 2. copy over the rows from the old table
|
|
572
|
+
this.transfer_connections({
|
|
573
|
+
id:relationship_edit.id,
|
|
574
|
+
sides:old_sides
|
|
575
|
+
},{
|
|
576
|
+
id:junction_id,
|
|
577
|
+
sides:new_sides
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
this.refresh_caches(['classlist','items','junctions']);
|
|
585
|
+
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
consolidate_relationship_edits(relationship_edits:RelationEdit[]):RelationEditValidSides[]{
|
|
590
|
+
let class_cache=this.class_cache;
|
|
591
|
+
|
|
592
|
+
let consolidated_relationship_edits:(RelationEditValidSides&{exclude?:boolean})[]=[];
|
|
593
|
+
const relation_order={transfer:1,create:2,delete:3};
|
|
594
|
+
const sort_edits=(a:RelationEdit,b:RelationEdit)=>relation_order[a.type] - relation_order[b.type];
|
|
595
|
+
|
|
596
|
+
let source_array:RelationEditValidSides[]=[...relationship_edits.filter(edit_has_valid_sides)];
|
|
597
|
+
source_array.sort(sort_edits);
|
|
598
|
+
for(let i=0;i<source_array.length;i++){
|
|
599
|
+
let relationship_edit=source_array[i];
|
|
600
|
+
switch(relationship_edit.type){
|
|
601
|
+
// all of these are added before anything else
|
|
602
|
+
case 'transfer':{
|
|
603
|
+
console.log(`Queing ${readable_edit(relationship_edit,class_cache)}`);
|
|
604
|
+
|
|
605
|
+
push_if_valid(relationship_edit);
|
|
606
|
+
// transferring the connections implies deleting the source, so we queue that deletion
|
|
607
|
+
// deletions only happen after all the transfers,
|
|
608
|
+
// so that multiple properties can copy from the same source.
|
|
609
|
+
let delete_queued=source_array.find(a=>a.type=='delete' && a.id==relationship_edit.id);
|
|
610
|
+
if(!delete_queued){
|
|
611
|
+
let del:RelationEditValidSides={
|
|
612
|
+
type:'delete',
|
|
613
|
+
id:relationship_edit.id
|
|
614
|
+
};
|
|
615
|
+
console.log(`Queuing ${readable_edit(del,class_cache)} after transfer`)
|
|
616
|
+
source_array.push(del)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
break;
|
|
621
|
+
|
|
622
|
+
// these are processed after the transfers but before the deletes.
|
|
623
|
+
case 'create':{
|
|
624
|
+
let new_sides=relationship_edit.sides;
|
|
625
|
+
// check if there’s an existing relation that matches both classes and one property
|
|
626
|
+
let existing = this.junction_cache.find((r)=>{
|
|
627
|
+
return partial_relation_match(new_sides,r.sides);
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
// if there is an existing match
|
|
631
|
+
if(existing){
|
|
632
|
+
// look for a type:"delete" which deletes this relation
|
|
633
|
+
let delete_queued=source_array.find(a=>a.type=='delete' && a.id==existing.id);
|
|
634
|
+
|
|
635
|
+
if(delete_queued){
|
|
636
|
+
let new_transfer:RelationEditValidSides={
|
|
637
|
+
type:'transfer',
|
|
638
|
+
id:existing.id,
|
|
639
|
+
sides:existing.sides,
|
|
640
|
+
new_sides:new_sides
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
console.log(`Found valid ${readable_edit(new_transfer,class_cache)}`)
|
|
644
|
+
// if there’s a delete, push a transfer instead
|
|
645
|
+
push_if_valid(new_transfer)
|
|
646
|
+
}else{
|
|
647
|
+
console.log(`Ignoring ${readable_edit(relationship_edit,class_cache)}; Cannot create a second relationship between two classes involving the same property.`)
|
|
648
|
+
}
|
|
649
|
+
// if there’s not a delete, we ignore this edit because it’s invalid
|
|
650
|
+
}else{
|
|
651
|
+
// if it does not exist, add the type:"create" normally
|
|
652
|
+
push_if_valid(relationship_edit);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
break;
|
|
656
|
+
|
|
657
|
+
// these are processed last, after the creates and transfers.
|
|
658
|
+
case 'delete':
|
|
659
|
+
// these are always processed at the very end
|
|
660
|
+
push_if_valid(relationship_edit);
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// lastly, filter out duplicates of the same partial match (has to be separate because it picks the most specific);
|
|
666
|
+
consolidated_relationship_edits=filter_best_of_partial_matches(consolidated_relationship_edits);
|
|
667
|
+
|
|
668
|
+
return consolidated_relationship_edits;
|
|
669
|
+
|
|
670
|
+
// ignores if it already exists in the consolidated list (deduplication)
|
|
671
|
+
// ignores if it targets a class/property that no longer exists
|
|
672
|
+
function push_if_valid(edit:RelationEditValidSides){
|
|
673
|
+
let edit_already_added;
|
|
674
|
+
let targets_exist=true;
|
|
675
|
+
switch(edit.type){
|
|
676
|
+
case 'create':{
|
|
677
|
+
edit_already_added=consolidated_relationship_edits.some(a=>{
|
|
678
|
+
return a.type=='create'
|
|
679
|
+
&& full_relation_match(a.sides,edit.sides)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
targets_exist=check_if_targets_exist(edit.sides);
|
|
683
|
+
}
|
|
684
|
+
break;
|
|
685
|
+
case 'delete':{
|
|
686
|
+
edit_already_added=consolidated_relationship_edits.some(a=>a.type=='delete'&&a.id==edit.id);
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
case 'transfer':{
|
|
690
|
+
edit_already_added=consolidated_relationship_edits.some(a=>{
|
|
691
|
+
return a.type=='transfer'
|
|
692
|
+
&& a.id==edit.id
|
|
693
|
+
&& full_relation_match(a.new_sides,edit.new_sides);
|
|
694
|
+
})
|
|
695
|
+
targets_exist=check_if_targets_exist(edit.sides);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if(!(targets_exist && !edit_already_added)) console.log('Skipped invalid',edit,'\n targeting deleted class/property:',!targets_exist,'\n edit already added:',edit_already_added);
|
|
700
|
+
if(targets_exist && !edit_already_added) consolidated_relationship_edits.push(edit);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function check_if_targets_exist(sides:[RelationshipSideBase,RelationshipSideBase]){
|
|
704
|
+
for(let side of sides){
|
|
705
|
+
let class_for_side=class_cache.find((c)=>c.id==side.class_id);
|
|
706
|
+
if(!class_for_side){
|
|
707
|
+
return false;
|
|
708
|
+
}else if(defined(side.prop_id)){
|
|
709
|
+
let prop=class_for_side.properties.find(a=>a.id==side.prop_id);
|
|
710
|
+
if(!prop) return false;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// allow no more than one of each partial match (partial match = shares both classes and one property)
|
|
718
|
+
// privilege relations with properties on both sides (two way); else accept the first one in the list.
|
|
719
|
+
function filter_best_of_partial_matches(edits:(RelationEditValidSides & {exclude?:boolean})[]){
|
|
720
|
+
return edits.filter(edit=>{
|
|
721
|
+
|
|
722
|
+
if(edit.type=='delete') return true;
|
|
723
|
+
if(edit.exclude) return false;
|
|
724
|
+
|
|
725
|
+
// keeps track of whether or not to keep this item in filtered selection
|
|
726
|
+
let include=true;
|
|
727
|
+
|
|
728
|
+
let sides=edit.type=='transfer'?edit.new_sides:edit.sides;
|
|
729
|
+
let is_two_way=two_way(sides);
|
|
730
|
+
|
|
731
|
+
// look for partial matches in the array
|
|
732
|
+
for(let comparison of consolidated_relationship_edits){
|
|
733
|
+
if(comparison==edit || comparison.type == 'delete' || comparison.exclude){
|
|
734
|
+
// ignore if not applicable
|
|
735
|
+
continue;
|
|
736
|
+
}else{
|
|
737
|
+
let comparison_sides=comparison.type=='transfer'?comparison.new_sides:comparison.sides;
|
|
738
|
+
// check if it’s a partial match
|
|
739
|
+
if(partial_relation_match(comparison_sides,sides)){
|
|
740
|
+
// if so, check if the compared edit is two-way
|
|
741
|
+
let comparison_is_two_way = two_way(comparison_sides);
|
|
742
|
+
|
|
743
|
+
if(!is_two_way && comparison_is_two_way){
|
|
744
|
+
// if the comparison is two-way and this item is not, we know it should not be included
|
|
745
|
+
// because there is something higher priority
|
|
746
|
+
console.log(`Excluding ${readable_edit(edit,class_cache)} as there is a higher priority relation`)
|
|
747
|
+
edit.exclude=true;
|
|
748
|
+
include = false;
|
|
749
|
+
}else{
|
|
750
|
+
console.log(`Excluding ${readable_edit(comparison,class_cache)} as there is a higher priority relation`)
|
|
751
|
+
// we know the current item is higher priority, and so we ought to exclude the compared item for the rest of the loop
|
|
752
|
+
comparison.exclude=true;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return include;
|
|
759
|
+
|
|
760
|
+
})
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
action_delete_class(class_id:number){
|
|
766
|
+
// TBD
|
|
767
|
+
console.log('TBD, class deletion not yet implemented')
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
create_junction_table(sides:JunctionSides){
|
|
775
|
+
|
|
776
|
+
// adds new record to junction table
|
|
777
|
+
this.db.prepare(`
|
|
778
|
+
INSERT INTO system_junctionlist
|
|
779
|
+
(side_0_class_id, side_0_prop_id, side_1_class_id, side_1_prop_id)
|
|
780
|
+
VALUES (@side_0_class_id,@side_0_prop_id,@side_1_class_id,@side_1_prop_id)
|
|
781
|
+
`).run({
|
|
782
|
+
side_0_class_id:sides[0].class_id,
|
|
783
|
+
side_0_prop_id:sides[0].prop_id || null,
|
|
784
|
+
side_1_class_id:sides[1].class_id,
|
|
785
|
+
side_1_prop_id:sides[1].prop_id || null
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
//gets id of new record
|
|
789
|
+
let id=this.db.prepare<[],{id:number}>('SELECT id FROM system_junctionlist ORDER BY id DESC').get()?.id;
|
|
790
|
+
if(typeof id !== 'number') throw new Error('Something went wrong creating a new relationship');
|
|
791
|
+
|
|
792
|
+
// creates table
|
|
793
|
+
this.create_table('junction',id,[
|
|
794
|
+
`"${junction_col_name(sides[0].class_id,sides[0].prop_id)}" INTEGER`,
|
|
795
|
+
`"${junction_col_name(sides[1].class_id,sides[1].prop_id)}" INTEGER`
|
|
796
|
+
]);
|
|
797
|
+
|
|
798
|
+
return id;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
transfer_connections(source:{sides:JunctionSides,id:number},target:{sides:JunctionSides,id:number}){
|
|
803
|
+
|
|
804
|
+
let source_match_index=side_match(target.sides[0],source.sides[0])?0:1;
|
|
805
|
+
|
|
806
|
+
// flip the order if needed to maximally match the sides
|
|
807
|
+
let source_ordered=[
|
|
808
|
+
source.sides[source_match_index],
|
|
809
|
+
source.sides[Math.abs(source_match_index-1)]
|
|
810
|
+
]
|
|
811
|
+
|
|
812
|
+
let source_col_names=[
|
|
813
|
+
junction_col_name(source_ordered[0].class_id,source_ordered[0].prop_id),
|
|
814
|
+
junction_col_name(source_ordered[1].class_id,source_ordered[1].prop_id)
|
|
815
|
+
]
|
|
816
|
+
|
|
817
|
+
let target_col_names=[
|
|
818
|
+
junction_col_name(target.sides[0].class_id,target.sides[0].prop_id),
|
|
819
|
+
junction_col_name(target.sides[1].class_id,target.sides[1].prop_id)
|
|
820
|
+
]
|
|
821
|
+
|
|
822
|
+
this.db.prepare(`
|
|
823
|
+
INSERT INTO junction_${target.id} (${target_col_names[0]},${target_col_names[1]})
|
|
824
|
+
SELECT ${source_col_names[0]}, ${source_col_names[1]} FROM junction_${source.id}`
|
|
825
|
+
).run();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
delete_junction_table(id:number){
|
|
829
|
+
this.db.prepare(`DELETE FROM system_junctionlist WHERE id = ${id}`).run();
|
|
830
|
+
this.db.prepare(`DROP TABLE junction_${id}`).run();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
check_conditions({class_id,prop_id,property,class_data}:{class_id?:number,prop_id?:number,property?:Property,class_data:ClassData}){
|
|
834
|
+
/*
|
|
835
|
+
(some early ideas for how the conditions look;for now not gonna deal with filters or rules, just going to check max_values)
|
|
836
|
+
conditions={
|
|
837
|
+
filters:[
|
|
838
|
+
|
|
839
|
+
],
|
|
840
|
+
rules:[
|
|
841
|
+
|
|
842
|
+
]
|
|
843
|
+
}
|
|
844
|
+
*/
|
|
845
|
+
|
|
846
|
+
// if(class_id!==undefined&&!class_data){
|
|
847
|
+
// class_data=this.retrieve_class_items({class_id});
|
|
848
|
+
// }
|
|
849
|
+
// if(prop_id!==undefined&&!property){
|
|
850
|
+
// // NOTE: change this in the future when properties moved to table
|
|
851
|
+
// property=class_data.metadata.properties.find(a=>a.id==prop_id);
|
|
852
|
+
// }
|
|
853
|
+
|
|
854
|
+
// if(property==undefined) throw new Error('Could not locate property')
|
|
855
|
+
// let prop_name='user_'+property.name;
|
|
856
|
+
|
|
857
|
+
// for(let item of class_data.items){
|
|
858
|
+
// let prop_values=item[prop_name];
|
|
859
|
+
// // check if they follow the conditions, and adjust if not.
|
|
860
|
+
// // for now I think just check max_values, and trim the values if not
|
|
861
|
+
// // I think (?) I can just read from the output of the cached class data,
|
|
862
|
+
// // and then use a prepare statement to modify data props on this table, and relation props on the corresponding junction table
|
|
863
|
+
// console.log(prop_name,prop_values);
|
|
864
|
+
// }
|
|
865
|
+
|
|
866
|
+
// after everything is done I should probably refresh the cache to get any changes to the items; maybe that can happen in the function where this is invoked though.
|
|
867
|
+
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
action_save(){
|
|
871
|
+
if(this.db.inTransaction) this.run.commit.run();
|
|
872
|
+
this.db.close();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
action_create_item_in_root({type=null,value=''}:{type:string|null,value?:string}){
|
|
876
|
+
// this.db.prepare('INSERT INTO system_root VALUES (null)').run();
|
|
877
|
+
this.run.create_item.run({type,value});
|
|
878
|
+
let id=this.db.prepare<[],{id:number}>('SELECT id FROM system_root ORDER BY id DESC').get()?.id;
|
|
879
|
+
if(typeof id !== 'number') throw new Error('Something went wrong creating a new item');
|
|
880
|
+
return id;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
action_delete_item_from_root(id:number){
|
|
884
|
+
this.db.prepare(`DELETE FROM system_root WHERE id = ${id}`).run();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
action_set_root_item_value(id:number,value:string){
|
|
888
|
+
this.db.prepare(`UPDATE system_root set value = ? WHERE id = ?`).run(value,id);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
action_add_row(class_id:number){
|
|
892
|
+
let class_data=this.class_cache.find(a=>a.id==class_id);
|
|
893
|
+
if(class_data == undefined) throw new Error('Cannot find class in class list.');
|
|
894
|
+
let class_name=class_data.name;
|
|
895
|
+
|
|
896
|
+
//first add new row to root and get id
|
|
897
|
+
const root_id=this.action_create_item_in_root({type:'class_'+class_id});
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
//get the last item in class table order and use it to get the order for the new item
|
|
901
|
+
const new_order=this.get_next_order(`[class_${class_name}]`);
|
|
902
|
+
|
|
903
|
+
this.db.prepare(`INSERT INTO [class_${class_name}] (system_id, system_order) VALUES (${root_id},${new_order})`).run();
|
|
904
|
+
|
|
905
|
+
return root_id;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
get_next_order(table_name:string){
|
|
909
|
+
const last_ordered_item=this.db.prepare<[],{system_order:number}>(`SELECT system_order FROM ${table_name} ORDER BY system_order DESC`).get();
|
|
910
|
+
const new_order=last_ordered_item?last_ordered_item.system_order+1000:0;
|
|
911
|
+
return new_order;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
action_make_relation(input_1:ItemRelationSide,input_2:ItemRelationSide){
|
|
915
|
+
// NOTE: changes to make to this in the future:
|
|
916
|
+
// - for input readability, allow class_name and prop_name as input options, assuming they’re enforced as unique, and use them to look up IDs
|
|
917
|
+
// - enforce max_values here
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
let column_names={
|
|
921
|
+
input_1:junction_col_name(input_1.class_id,input_1.prop_id),
|
|
922
|
+
input_2:junction_col_name(input_2.class_id,input_2.prop_id)
|
|
923
|
+
}
|
|
924
|
+
let junction_id=this.junction_cache.find(j=>full_relation_match(j.sides,[input_1,input_2]))?.id;
|
|
925
|
+
|
|
926
|
+
if(junction_id){
|
|
927
|
+
this.db.prepare(`
|
|
928
|
+
INSERT INTO junction_${junction_id}
|
|
929
|
+
("${column_names.input_1}", "${column_names.input_2}")
|
|
930
|
+
VALUES (${input_1.item_id},${input_2.item_id})
|
|
931
|
+
`).run();
|
|
932
|
+
}else{
|
|
933
|
+
throw Error('Something went wrong - junction table for relationship not found')
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// NOTE: should this trigger a refresh to items?
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
retrieve_class_items({class_id,class_name,class_data}:{class_id:number,class_name?:string,class_data?:ClassData}):ClassRow[]{
|
|
941
|
+
if(class_name==undefined || class_data == undefined){
|
|
942
|
+
class_data=this.class_cache.find(a=>a.id==class_id);
|
|
943
|
+
if(class_data == undefined) throw new Error('Cannot find class in class list.');
|
|
944
|
+
class_name=class_data.name;
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const class_string=`[class_${class_name}]`;
|
|
948
|
+
|
|
949
|
+
// //joined+added at beginning of the query, built from relations
|
|
950
|
+
const cte_strings=[];
|
|
951
|
+
|
|
952
|
+
// //joined+added near the end of the query, built from relations
|
|
953
|
+
const cte_joins=[];
|
|
954
|
+
|
|
955
|
+
// //joined+added between SELECT and FROM, built from relations
|
|
956
|
+
const relation_selections=[];
|
|
957
|
+
|
|
958
|
+
let relation_properties=class_data.properties.filter(a=>a.type=='relation');
|
|
959
|
+
|
|
960
|
+
for (let prop of relation_properties){
|
|
961
|
+
const target_selects=[];
|
|
962
|
+
let property_junction_column_name=junction_col_name(class_id,prop.id);
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
if(prop.relation_targets.length>0){
|
|
967
|
+
for(let i = 0; i < prop.relation_targets.length; i++){
|
|
968
|
+
|
|
969
|
+
// find the side that does not match both the class and prop IDs
|
|
970
|
+
let target=prop.relation_targets[i]
|
|
971
|
+
if(target){
|
|
972
|
+
let target_junction_column_name=junction_col_name(target.class_id,target.prop_id);
|
|
973
|
+
let junction_id=target.junction_id;
|
|
974
|
+
let target_select=`SELECT "${property_junction_column_name}", json_object('class_id',${target.class_id},'id',"${target_junction_column_name}") AS target_data FROM junction_${junction_id}`
|
|
975
|
+
target_selects.push(target_select);
|
|
976
|
+
}else{
|
|
977
|
+
throw Error('Something went wrong trying to retrieve relationship data')
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// uses built-in aggregate json function instead of group_concat craziness
|
|
983
|
+
const cte=`[${prop.id}_cte] AS (
|
|
984
|
+
SELECT "${property_junction_column_name}", json_group_array( json(target_data) ) AS [user_${prop.name}]
|
|
985
|
+
FROM
|
|
986
|
+
(
|
|
987
|
+
${target_selects.join(`
|
|
988
|
+
UNION
|
|
989
|
+
`)}
|
|
990
|
+
)
|
|
991
|
+
GROUP BY "${property_junction_column_name}"
|
|
992
|
+
)`
|
|
993
|
+
|
|
994
|
+
cte_strings.push(cte);
|
|
995
|
+
relation_selections.push(`[${prop.id}_cte].[user_${prop.name}]`);
|
|
996
|
+
cte_joins.push(`LEFT JOIN [${prop.id}_cte] ON [${prop.id}_cte]."${property_junction_column_name}" = ${class_string}.system_id`)
|
|
997
|
+
}else{
|
|
998
|
+
relation_selections.push(`'[]' AS [user_${prop.name}]`);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
let orderby=`ORDER BY ${class_string}.system_order`;
|
|
1005
|
+
|
|
1006
|
+
let comma_break=`,
|
|
1007
|
+
`
|
|
1008
|
+
|
|
1009
|
+
let query=`
|
|
1010
|
+
${cte_strings.length>0?"WITH "+cte_strings.join(comma_break):''}
|
|
1011
|
+
SELECT [class_${class_name}].* ${relation_selections.length>0?', '+relation_selections.join(`, `):''}
|
|
1012
|
+
FROM [class_${class_name}]
|
|
1013
|
+
${cte_joins.join(' ')}
|
|
1014
|
+
${orderby}`;
|
|
1015
|
+
|
|
1016
|
+
// possibly elaborate this any type a little more in the future, e.g. a CellValue or SQLCellValue type that expects some wildcards
|
|
1017
|
+
let items=this.db.prepare<[],ClassRow>(query).all();
|
|
1018
|
+
|
|
1019
|
+
let stringified_properties=class_data.properties.filter(a=>a.type=='relation'||can_have_multiple_values(a.max_values));
|
|
1020
|
+
items.map((row)=>{
|
|
1021
|
+
if(row && typeof row == 'object'){
|
|
1022
|
+
for (let prop of stringified_properties){
|
|
1023
|
+
let prop_sql_name='user_'+prop.name;
|
|
1024
|
+
if(prop_sql_name in row){
|
|
1025
|
+
row[prop_sql_name]=JSON.parse(row[prop_sql_name]);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
return items;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
retrieve_all_classes():ClassData[]{
|
|
1034
|
+
const classes_data=this.run.get_all_classes.all();
|
|
1035
|
+
return classes_data.map(({id,name,metadata})=>{
|
|
1036
|
+
let existing_items=this.item_cache.find((itemlist)=>itemlist.class_id==id);
|
|
1037
|
+
|
|
1038
|
+
let properties_sql=this.db.prepare<[],{id:number,type:PropertyType,data_type:'string'| null,max_values:MaxValues,name:string,metadata:string}>(`SELECT * FROM class_${id}_properties`).all() || [];
|
|
1039
|
+
|
|
1040
|
+
let properties=properties_sql.map((sql_prop)=>this.parse_sql_prop(id,sql_prop));
|
|
1041
|
+
|
|
1042
|
+
return {
|
|
1043
|
+
id,
|
|
1044
|
+
name,
|
|
1045
|
+
items:existing_items?.items ?? [],
|
|
1046
|
+
properties,
|
|
1047
|
+
metadata:JSON.parse(metadata)
|
|
1048
|
+
};
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
parse_sql_prop(
|
|
1053
|
+
class_id:number,
|
|
1054
|
+
sql_prop:{
|
|
1055
|
+
id:number,
|
|
1056
|
+
type:PropertyType,
|
|
1057
|
+
data_type:'string'| null,
|
|
1058
|
+
max_values:MaxValues,
|
|
1059
|
+
name:string,
|
|
1060
|
+
metadata:string
|
|
1061
|
+
}):(DataProperty | RelationProperty){
|
|
1062
|
+
|
|
1063
|
+
if(sql_prop.type=='data'&&defined(sql_prop.data_type)){
|
|
1064
|
+
return {
|
|
1065
|
+
type:'data',
|
|
1066
|
+
id:sql_prop.id,
|
|
1067
|
+
name:sql_prop.name,
|
|
1068
|
+
max_values:sql_prop.max_values,
|
|
1069
|
+
data_type:sql_prop.data_type
|
|
1070
|
+
};
|
|
1071
|
+
}else if(sql_prop.type=='relation'){
|
|
1072
|
+
let associated_junctions=this.run.get_junctions_matching_property.all({class_id:class_id,prop_id:sql_prop.id}) || [];
|
|
1073
|
+
let relation_targets=associated_junctions.map((j)=>{
|
|
1074
|
+
let sides:JunctionSides=JSON.parse(j.sides);
|
|
1075
|
+
// find the side that does not match both the class and prop IDs
|
|
1076
|
+
let target=sides.find(a=>!(a.class_id==class_id&&a.prop_id==sql_prop.id));
|
|
1077
|
+
if(!target) throw Error('Something went wrong locating target of relationship')
|
|
1078
|
+
return {...target,junction_id:j.id};
|
|
1079
|
+
})
|
|
1080
|
+
return {
|
|
1081
|
+
type:'relation',
|
|
1082
|
+
id:sql_prop.id,
|
|
1083
|
+
name:sql_prop.name,
|
|
1084
|
+
max_values:sql_prop.max_values,
|
|
1085
|
+
relation_targets
|
|
1086
|
+
};
|
|
1087
|
+
}else{
|
|
1088
|
+
throw Error('property type does not match known types')
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
retrieve_windows(){
|
|
1093
|
+
let windows=this.run.get_windows.all();
|
|
1094
|
+
windows.map(a=>a.metadata=JSON.parse(a.metadata));
|
|
1095
|
+
return windows;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
retrieve_workspace_contents(id:number){
|
|
1099
|
+
// get the workspace table
|
|
1100
|
+
let blocks=this.db.prepare<[],SQLWorkspaceBlockRow>(`SELECT * FROM workspace_${id}`).all();
|
|
1101
|
+
let blocks_parsed:WorkspaceBlock[]=blocks.map(a=>({
|
|
1102
|
+
...a,
|
|
1103
|
+
metadata:JSON.parse(a.metadata)
|
|
1104
|
+
}))
|
|
1105
|
+
|
|
1106
|
+
// for(let block of blocks) block.metadata=JSON.parse(block.metadata);
|
|
1107
|
+
// get any relevant root items
|
|
1108
|
+
let items=this.db.prepare(`SELECT system_root.*
|
|
1109
|
+
FROM system_root
|
|
1110
|
+
LEFT JOIN workspace_${id}
|
|
1111
|
+
ON system_root.id = workspace_${id}.thing_id
|
|
1112
|
+
WHERE workspace_${id}.type = 'item';
|
|
1113
|
+
`).all();
|
|
1114
|
+
// get any relevant classes (going to hold off from this for now)
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
blocks_parsed,
|
|
1118
|
+
items
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
action_config_window({type,open,metadata={pos:[null,null], size:[1000,700]},id}:{
|
|
1123
|
+
type:ApplicationWindow["type"],
|
|
1124
|
+
open:ApplicationWindow["open"],
|
|
1125
|
+
metadata:ApplicationWindow["metadata"],
|
|
1126
|
+
id:number
|
|
1127
|
+
}){
|
|
1128
|
+
if(id!==undefined){
|
|
1129
|
+
this.run.update_window.run({
|
|
1130
|
+
id,
|
|
1131
|
+
open,
|
|
1132
|
+
type,
|
|
1133
|
+
meta:JSON.stringify(metadata)
|
|
1134
|
+
})
|
|
1135
|
+
}else{
|
|
1136
|
+
|
|
1137
|
+
let id=this.create_workspace(open,metadata)
|
|
1138
|
+
|
|
1139
|
+
return id;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
create_workspace(open:ApplicationWindow["open"],metadata:ApplicationWindow["metadata"]){
|
|
1146
|
+
|
|
1147
|
+
this.run.create_window.run({
|
|
1148
|
+
type:'workspace',
|
|
1149
|
+
open,
|
|
1150
|
+
meta:JSON.stringify(metadata)
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
let id=this.get_latest_table_row_id('system_windows')
|
|
1154
|
+
if(!id) throw Error('Something went wrong creating the window.');
|
|
1155
|
+
|
|
1156
|
+
this.create_table('workspace',id,[
|
|
1157
|
+
'block_id INTEGER NOT NULL PRIMARY KEY',
|
|
1158
|
+
'type TEXT',
|
|
1159
|
+
'metadata TEXT',
|
|
1160
|
+
'thing_id INTEGER'
|
|
1161
|
+
]);
|
|
1162
|
+
|
|
1163
|
+
return id;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
action_create_workspace_block({workspace_id,thing_type,block_metadata,thing_id}:{
|
|
1167
|
+
workspace_id:ApplicationWindow["id"],
|
|
1168
|
+
thing_type:WorkspaceBlock["thing_type"],
|
|
1169
|
+
block_metadata:WorkspaceBlock["metadata"],
|
|
1170
|
+
thing_id:WorkspaceBlock["thing_id"]
|
|
1171
|
+
}){
|
|
1172
|
+
// should return block id
|
|
1173
|
+
this.db.prepare<{type:string,metadata:string,thing_id:number}>(`INSERT INTO workspace_${workspace_id}(type,metadata,thing_id) VALUES (@type,@metadata,@thing_id)`).run({
|
|
1174
|
+
type:thing_type,
|
|
1175
|
+
metadata:JSON.stringify(block_metadata),
|
|
1176
|
+
thing_id
|
|
1177
|
+
});
|
|
1178
|
+
let block_id=this.db.prepare<[],{block_id:number}>(`SELECT block_id FROM workspace_${workspace_id} ORDER BY block_id DESC`).get()?.block_id;
|
|
1179
|
+
if(block_id==undefined) throw Error("Problem adding block to workspace");
|
|
1180
|
+
return block_id;
|
|
1181
|
+
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
action_remove_workspace_block({workspace_id,block_id}:{workspace_id:number,block_id:number}){
|
|
1185
|
+
this.db.prepare(`DELETE FROM workspace_${workspace_id} WHERE block_id = ${block_id}`).run();
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
action_create_and_add_to_workspace({
|
|
1189
|
+
workspace_id,
|
|
1190
|
+
thing_type,
|
|
1191
|
+
block_metadata,
|
|
1192
|
+
thing_data
|
|
1193
|
+
}:{
|
|
1194
|
+
workspace_id:number,
|
|
1195
|
+
thing_type:WorkspaceBlock["thing_type"],
|
|
1196
|
+
block_metadata:WorkspaceBlock["metadata"],
|
|
1197
|
+
thing_data:any // NOTE: fix this in the future when I define class and standalone item types
|
|
1198
|
+
}){
|
|
1199
|
+
let thing_id;
|
|
1200
|
+
// thing creation
|
|
1201
|
+
switch(thing_type){
|
|
1202
|
+
case 'item':
|
|
1203
|
+
let {
|
|
1204
|
+
value:item_value,
|
|
1205
|
+
type:item_type
|
|
1206
|
+
} = thing_data;
|
|
1207
|
+
thing_id=this.action_create_item_in_root({type:item_type,value:item_value});
|
|
1208
|
+
break;
|
|
1209
|
+
// add cases for class and anything else in the future
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if(!thing_id) throw Error('Something went wrong saving an item from a workspace');
|
|
1213
|
+
|
|
1214
|
+
let block_id=this.action_create_workspace_block({
|
|
1215
|
+
workspace_id,
|
|
1216
|
+
thing_type,
|
|
1217
|
+
block_metadata,
|
|
1218
|
+
thing_id
|
|
1219
|
+
})
|
|
1220
|
+
|
|
1221
|
+
return {
|
|
1222
|
+
thing_id,
|
|
1223
|
+
block_id
|
|
1224
|
+
}
|
|
1225
|
+
// should return the block id and item id
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
action_remove_from_workspace_and_delete(workspace_id:number,block_id:number,thing_type:WorkspaceBlock["thing_type"],thing_id:number){
|
|
1229
|
+
this.action_remove_workspace_block({workspace_id,block_id});
|
|
1230
|
+
switch(thing_type){
|
|
1231
|
+
case 'item':
|
|
1232
|
+
this.action_delete_item_from_root(thing_id);
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
// // match both classes
|
|
1243
|
+
// // match at least one prop
|
|
1244
|
+
// let a0_match_i=b.findIndex(side=>a[0].class_id==side.class_id);
|
|
1245
|
+
// let a1_match_i=b.findIndex(side=>a[1].class_id==side.class_id);
|
|
1246
|
+
// if(a0_match_i>=0&&a1_match_i>=0&&a0_match_i!==a1_match_i){
|
|
1247
|
+
// return b[a0_match_i].prop_id==a[0].prop_id||
|
|
1248
|
+
// b[a1_match_i].prop_id==a[1].prop_id
|
|
1249
|
+
// }else{
|
|
1250
|
+
// return false;
|
|
1251
|
+
// }
|