dead-simple-orm
Version:
A simple ORM package.
545 lines • 23.7 kB
JavaScript
function randomUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function parseRawRecord(raw, tableDecl) {
const { id, metadata, createdAt, updatedAt, ...rest } = raw;
let parsedMetadata = {};
try {
parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
}
catch (e) {
//@ts-ignore
console.warn(`Failed to parse metadata for record ${id}: ${e.message}`);
// Use empty object as fallback
}
const parsedRecord = {
id,
metadata: parsedMetadata,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
};
for (const key in tableDecl) {
const fieldType = tableDecl[key];
const value = rest[key];
if (typeof fieldType === "object") {
parsedRecord[key] = value;
}
else {
switch (fieldType) {
case "text":
case "long text":
case "image":
parsedRecord[key] = value ?? "";
break;
case "date":
parsedRecord[key] = value ? new Date(value) : null;
break;
case "json":
if (typeof value === "string") {
try {
parsedRecord[key] = JSON.parse(value);
}
catch {
parsedRecord[key] = {};
}
}
else if (typeof value === "object" && value !== null) {
parsedRecord[key] = value;
}
else {
parsedRecord[key] = {};
}
break;
case "number":
parsedRecord[key] = typeof value === "number" ? value : value !== undefined ? Number(value) : 0;
break;
case "bool":
parsedRecord[key] = value === 1 || value === true;
break;
default:
parsedRecord[key] = value;
}
}
}
return parsedRecord;
}
function defaultValue(column) {
if (typeof column === "object")
return null;
switch (column) {
case "text":
case "long text":
case "image":
return "";
case "date":
return new Date();
case "json":
return {};
case "number":
return 0;
case "bool":
return false;
default:
return null;
}
}
function validateRecord(tableDecl, record) {
for (const key in record) {
// Skip system fields
if (["id", "metadata", "createdAt", "updatedAt"].includes(key)) {
continue;
}
const expected = tableDecl[key];
const value = record[key];
if (!expected) {
throw new Error(`Unexpected field "${key}"`);
}
if (typeof expected === "object") {
// Handle relationship fields - allow string references, null, or arrays of objects (for one-to-many)
if (typeof value === "string" || value === null) {
// This is fine - string reference or null
continue;
}
else if (Array.isArray(value) && expected.type === "one-to-many") {
// This is fine - array of related objects for one-to-many relationship
continue;
}
else if (typeof value === "object" && value !== null && expected.type === "one-to-one") {
// This is fine - related object for one-to-one relationship
continue;
}
else {
throw new Error(`Field "${key}" should be a string reference id, null, or a valid relationship object`);
}
}
else {
switch (expected) {
case "text":
case "long text":
case "image":
if (typeof value !== "string")
throw new Error(`Field "${key}" should be a string`);
break;
case "date":
if (!(value instanceof Date))
throw new Error(`Field "${key}" should be a Date`);
break;
case "json":
if (typeof value !== "object" || value === null)
throw new Error(`Field "${key}" should be an object`);
break;
case "number":
if (typeof value !== "number")
throw new Error(`Field "${key}" should be a number`);
break;
case "bool":
if (typeof value !== "boolean")
throw new Error(`Field "${key}" should be a boolean`);
break;
}
}
}
}
function fillMissingFields(tableDecl, record) {
const newRecord = { ...record };
for (const key in tableDecl) {
if (!(key in newRecord)) {
newRecord[key] = defaultValue(tableDecl[key]);
}
}
return newRecord;
}
function stripExtraFields(tableDecl, record) {
const stripped = {};
for (const key in record) {
if (key in tableDecl || ["id", "metadata", "createdAt", "updatedAt"].includes(key)) {
stripped[key] = record[key];
}
}
return stripped;
}
function prepareRecordForStorage(tableDecl, record) {
const prepared = { ...record };
for (const key in tableDecl) {
const fieldType = tableDecl[key];
const value = record[key];
if (typeof fieldType === "object") {
if (value === null) {
// Keep null as is
prepared[key] = null;
}
else if (typeof value === "string") {
// Keep string references as is
prepared[key] = value;
}
else if (Array.isArray(value) && fieldType.type === "one-to-many") {
// Convert array of objects to array of IDs, then to JSON string
const ids = value.map(item => typeof item === "string" ? item : item.id);
prepared[key] = JSON.stringify(ids);
}
else if (typeof value === "object" && fieldType.type === "one-to-one") {
// Convert object to its ID
prepared[key] = value.id;
}
}
else if (fieldType === "bool") {
prepared[key] = value ? 1 : 0;
}
else {
if (value === null || value === undefined) {
prepared[key] = null;
}
else if (fieldType === "date" && value instanceof Date) {
prepared[key] = value.getTime();
}
else if (fieldType === "json") {
prepared[key] = JSON.stringify(value);
}
}
}
return prepared;
}
export class SimpleORM {
db;
declaration;
constructor(db, declaration) {
this.db = db;
this.declaration = declaration;
this.initializeTables();
this.checkAndRunMigrations();
}
async checkAndRunMigrations() {
for (const tableName in this.declaration) {
const tableDecl = this.declaration[tableName];
const result = await this.db.query(`PRAGMA table_info(${tableName})`).all();
const existingColumns = new Set(result.map((row) => row.name));
for (const fieldName in tableDecl) {
if (!existingColumns.has(fieldName)) {
const fieldType = tableDecl[fieldName];
let sqlType;
if (typeof fieldType === "object") {
sqlType = "TEXT";
}
else {
switch (fieldType) {
case "text":
case "long text":
case "image":
case "json":
sqlType = "TEXT";
break;
case "date":
sqlType = "INTEGER";
break;
case "number":
sqlType = "REAL";
break;
case "bool":
sqlType = "INTEGER";
break;
default:
sqlType = "TEXT";
}
}
console.log(`🛠️ Adding missing column ${fieldName} to table ${tableName}`);
await this.db.query(`ALTER TABLE ${tableName} ADD COLUMN ${fieldName} ${sqlType}`).run();
await this.db.query("PRAGMA schema_version").all();
}
}
}
}
initializeTables() {
for (const tableName in this.declaration) {
const fields = [
`id TEXT PRIMARY KEY`,
`metadata TEXT`,
`createdAt INTEGER`,
`updatedAt INTEGER`
];
const tableDecl = this.declaration[tableName];
for (const fieldName in tableDecl) {
const fieldType = tableDecl[fieldName];
if (typeof fieldType === "object") {
// one-to-one, one-to-many just store as TEXT
fields.push(`${fieldName} TEXT`);
}
else {
switch (fieldType) {
case "text":
case "long text":
case "image":
case "json":
fields.push(`${fieldName} TEXT`);
break;
case "date":
fields.push(`${fieldName} INTEGER`);
break;
case "number":
fields.push(`${fieldName} REAL`);
break;
case "bool":
fields.push(`${fieldName} INTEGER`);
break;
}
}
}
const sql = `CREATE TABLE IF NOT EXISTS ${tableName} (${fields.join(", ")})`;
this.db.query(sql).run();
console.log(`✅ Table ready: ${tableName}`);
}
}
// Find fields in a table that reference another table
findReferenceFields(tableName, refTableName) {
const tableDecl = this.declaration[tableName];
if (!tableDecl)
return [];
const refFields = [];
for (const fieldName in tableDecl) {
const fieldType = tableDecl[fieldName];
if (typeof fieldType === "object" && fieldType.ref === refTableName) {
refFields.push(fieldName);
}
}
return refFields;
}
// Update the owner record when a reference is created
async updateReferenceOwner(tableName, recordId, data) {
for (const fieldName in data) {
const value = data[fieldName];
if (!value || typeof value !== "string" || !value.includes("_"))
continue;
const fieldType = this.declaration[tableName][fieldName];
if (typeof fieldType !== "object" || !fieldType.ref)
continue;
const [refTableName] = value.split("_");
if (refTableName !== fieldType.ref)
continue;
console.log(`Updating reference owner: ${tableName} ${recordId} ${fieldName} ${value} ${refTableName}`);
// Find fields in the referenced table that point back to this table
const backRefFields = this.findReferenceFields(fieldType.ref, tableName);
for (const backRefField of backRefFields) {
const refTableDecl = this.declaration[fieldType.ref];
const backRefType = refTableDecl[backRefField];
if (typeof backRefType === "object") {
try {
// Get the referenced record
const refRecord = await this.get(value, false);
// Check if refRecord has an id before proceeding
if (!refRecord || !refRecord.id) {
console.warn(`Referenced record ${value} is missing or has no ID. Skipping bidirectional update.`);
continue;
}
// Update the reference based on relationship type
if (backRefType.type === "one-to-one") {
// For one-to-one, set the reference to this record's ID
refRecord[backRefField] = recordId;
}
else if (backRefType.type === "one-to-many") {
// For one-to-many, we need to handle arrays
// This is stored as a JSON string in the database
let currentRefs = [];
try {
if (refRecord[backRefField] && typeof refRecord[backRefField] === "string") {
currentRefs = JSON.parse(refRecord[backRefField]);
}
}
catch (e) {
//@ts-ignore
console.warn(`Failed to parse references for ${backRefField}: ${e.message}`);
}
if (!Array.isArray(currentRefs)) {
currentRefs = [];
}
// Add this record's ID if not already present
if (!currentRefs.includes(recordId)) {
currentRefs.push(recordId);
refRecord[backRefField] = JSON.stringify(currentRefs);
}
}
// Update the referenced record
console.log(`Updating referenced record: ${fieldType.ref} ${refRecord.id} ${backRefField} ${recordId}`, refRecord);
await this.update(refRecord);
}
catch (e) {
//@ts-ignore
console.warn(`Error updating reference owner: ${e.message}`);
// Continue with other references instead of failing the entire operation
}
}
}
}
}
// Fetch related records up to specified depth
async fetchRelations(record, depth = 2, currentDepth = 0, visited = new Set()) {
if (currentDepth >= depth || record.id === undefined)
return record;
// Add this record to visited set to prevent infinite recursion
visited.add(record.id);
const [tableName] = record.id.split("_");
const tableDecl = this.declaration[tableName];
const result = { ...record };
for (const fieldName in tableDecl) {
const fieldType = tableDecl[fieldName];
if (typeof fieldType === "object" && fieldType.ref) {
const refValue = record[fieldName];
if (fieldType.type === "one-to-one" && typeof refValue === "string" && refValue) {
// Skip if we've already visited this record
if (visited.has(refValue))
continue;
try {
// Fetch the referenced record
const refRecord = await this.get(refValue, false); // Pass false to avoid recursive fetching
// Recursively fetch its relations with the same visited set
result[fieldName] = await this.fetchRelations(refRecord, depth, currentDepth + 1, visited);
}
catch (e) {
//@ts-ignore
console.warn(`Failed to fetch relation ${fieldName}: ${e.message}`);
}
}
else if (fieldType.type === "one-to-many" && typeof refValue === "string" && refValue) {
try {
// Parse the array of references
let refs = [];
try {
refs = JSON.parse(refValue);
}
catch {
// If not valid JSON, treat as a single ID
if (refValue)
refs = [refValue];
}
if (Array.isArray(refs)) {
const refRecords = [];
for (const refId of refs) {
if (typeof refId === "string" && refId) {
// Skip if we've already visited this record
if (visited.has(refId))
continue;
try {
const refRecord = await this.get(refId, false); // Pass false to avoid recursive fetching
// Recursively fetch relations with the same visited set
refRecords.push(await this.fetchRelations(refRecord, depth, currentDepth + 1, visited));
}
catch (e) {
//@ts-ignore
console.warn(`Failed to fetch relation item ${refId}: ${e.message}`);
}
}
}
result[fieldName] = refRecords;
}
}
catch (e) {
//@ts-ignore
console.warn(`Failed to process one-to-many relation ${fieldName}: ${e.message}`);
}
}
}
}
return result;
}
async get(id, fetchRelations = true) {
const [tableName] = id.split("_");
const tableDecl = this.declaration[tableName];
const result = await this.db.query(`SELECT * FROM ${tableName} WHERE id = ?`).get(id);
if (!result)
throw new Error(`Record with id ${id} not found`);
const complete = fillMissingFields(tableDecl, result);
const parsed = parseRawRecord(complete, tableDecl);
// Fetch relations up to 2 levels deep if requested
return fetchRelations ? this.fetchRelations(parsed) : parsed;
}
async list(tableName, fetchRelations = true) {
const results = await this.db.query(`SELECT * FROM ${tableName}`).all();
const tableDecl = this.declaration[tableName];
const parsedResults = results.map(row => {
const complete = fillMissingFields(tableDecl, row);
return parseRawRecord(complete, tableDecl);
});
// Fetch relations for each record if requested
if (fetchRelations) {
const withRelations = await Promise.all(parsedResults.map(record => this.fetchRelations(record)));
return withRelations;
}
return parsedResults;
}
async delete(id) {
const [tableName] = id.split("_");
this.db.query(`DELETE FROM ${tableName} WHERE id = ?`).run(id);
}
async insert(tableName, data) {
const tableDecl = this.declaration[tableName];
if (!tableDecl)
throw new Error(`Unknown table: ${tableName}`);
validateRecord(tableDecl, data);
const id = `${tableName}_${randomUUID()}`;
const now = Date.now();
const metadata = JSON.stringify({});
const preparedRecord = prepareRecordForStorage(tableDecl, data);
const fullData = {
id,
metadata,
createdAt: now,
updatedAt: now,
...fillMissingFields(tableDecl, preparedRecord)
};
const keys = Object.keys(fullData);
const values = keys.map(k => {
const val = fullData[k];
return val instanceof Date ? val.getTime() : typeof val === "object" && !(val instanceof Date) ? JSON.stringify(val) : val;
});
const placeholders = keys.map(() => "?").join(", ");
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`;
this.db.query(sql).run(...values);
// Update any referenced records to maintain bidirectional relationships
await this.updateReferenceOwner(tableName, id, data);
const record = parseRawRecord(fullData, tableDecl);
return this.fetchRelations(record);
}
async update(record) {
const id = record.id;
if (!id)
throw new Error("Missing id in record to update");
const [tableName] = id.split("_");
const tableDecl = this.declaration[tableName];
if (!tableDecl)
throw new Error(`Unknown table: ${tableName}`);
validateRecord(tableDecl, record);
// First strip extra fields, then prepare relations for storage
const strippedRecord = stripExtraFields(tableDecl, record);
const preparedRecord = prepareRecordForStorage(tableDecl, strippedRecord);
const cleanedRecord = fillMissingFields(tableDecl, preparedRecord);
cleanedRecord.updatedAt = Date.now();
const updates = Object.keys(cleanedRecord).map(key => `${key} = ?`).join(", ");
const values = Object.keys(cleanedRecord).map(k => {
const val = cleanedRecord[k];
return val instanceof Date ? val.getTime() : typeof val === "object" && !(val instanceof Date) ? JSON.stringify(val) : val;
});
const sql = `UPDATE ${tableName} SET ${updates} WHERE id = ?`;
this.db.query(sql).run(...values, id);
// Update any referenced records to maintain bidirectional relationships
// We need to extract string references from the original record for updateReferenceOwner
const referencesForUpdate = {};
for (const key in tableDecl) {
const fieldType = tableDecl[key];
if (typeof fieldType === "object") {
const value = record[key];
if (typeof value === "string") {
referencesForUpdate[key] = value;
}
else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
referencesForUpdate[key] = value.id;
}
}
}
await this.updateReferenceOwner(tableName, id, referencesForUpdate);
const parsedRecord = parseRawRecord(cleanedRecord, tableDecl);
return this.fetchRelations(parsedRecord);
}
}
//# sourceMappingURL=simpleOrm.js.map