From ec0b333ad71db58b00f35a888216c7f9a69ad02b Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:53:47 -0500 Subject: [PATCH] dev(data-access): implement `.read()` method Most of this implementation matched `.select()` which saved time. The common behavior can be factored out, but since the purpose of this refactor is to flatten everything as much as possible I'm going to leave the duplicated code and make a note to do this later. The challenging part was `#build_complex_id_where()`, which is the equivalent to the `fetch_based_on_complex_id_()` method in EntityStoreService. This allows `uid: app-UUID-OF-APP` and `id: { name: 'editor' }` to both work. --- .../src/modules/data-access/AppService.js | 163 +++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 28540864a..9302497f6 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -1,3 +1,4 @@ +import APIError from '../../api/APIError.js'; import BaseService from '../../services/BaseService.js'; import { DB_READ } from '../../services/database/consts.js'; import { Context } from '../../util/context.js'; @@ -27,7 +28,7 @@ export default class AppService extends BaseService { // TODO }, async read ({ uid, id, params = {} }) { - // TODO + return this.#read({ uid, id, params }); }, async select (options) { return this.#select(options); @@ -38,6 +39,9 @@ export default class AppService extends BaseService { }, }; + // value of require('om/mappings/app.js').redundant_identifiers + static REDUNDANT_IDENTIFIERS = ['name']; + async #select ({ predicate, params, ...rest }) { const db = this.db; @@ -146,4 +150,161 @@ export default class AppService extends BaseService { return client_safe_apps; } + + async #read ({ uid, id, params = {} }) { + const db = this.db; + + if ( uid === undefined && id === undefined ) { + throw new Error('read requires either uid or id'); + } + + const sql_associatedFiletypes = this.db.case({ + mysql: 'COALESCE(JSON_ARRAYAGG(afa.type), JSON_ARRAY())', + sqlite: "COALESCE(json_group_array(afa.type), json('[]'))", + }); + + // Build WHERE clause based on identifier type + let whereClause; + let whereValues; + + if ( uid !== undefined ) { + // Simple uid lookup + whereClause = 'apps.uid = ?'; + whereValues = [uid]; + } else if ( id !== null && typeof id === 'object' && !Array.isArray(id) ) { + // Complex id lookup (e.g., { name: 'editor' }) + const { clause, values } = this.#build_complex_id_where(id); + whereClause = clause; + whereValues = values; + } else { + throw APIError.create('invalid_id', null, { id }); + } + + const stmt = 'SELECT apps.*, ' + + 'owner_user.username AS owner_user_username, ' + + 'owner_user.uuid AS owner_user_uuid, ' + + 'app_owner.uid AS app_owner_uid, ' + + `${sql_associatedFiletypes} AS filetypes ` + + 'FROM apps ' + + 'LEFT JOIN user owner_user ON apps.owner_user_id = owner_user.id ' + + 'LEFT JOIN apps app_owner ON apps.app_owner = app_owner.id ' + + 'LEFT JOIN app_filetype_association afa ON apps.id = afa.app_id ' + + `WHERE ${whereClause} ` + + 'GROUP BY apps.id ' + + 'LIMIT 1'; + + const rows = await db.read(stmt, whereValues); + + if ( rows.length === 0 ) { + return undefined; + } + + const row = rows[0]; + const app = {}; + + app.approved_for_incentive_program = as_bool(row.approved_for_incentive_program); + app.approved_for_listing = as_bool(row.approved_for_listing); + app.approved_for_opening_items = as_bool(row.approved_for_opening_items); + app.background = as_bool(row.background); + app.created_at = row.created_at; + app.created_from_origin = row.created_from_origin; + app.description = row.description; + app.godmode = as_bool(row.godmode); + app.icon = row.icon; + app.index_url = row.index_url; + app.maximize_on_start = as_bool(row.maximize_on_start); + app.metadata = row.metadata; + app.name = row.name; + app.protected = as_bool(row.protected); + app.stats = row.stats; + app.title = row.title; + app.uid = row.uid; + + app.app_owner = { + uid: row.app_owner_uid, + }; + + { + const owner_user = extract_from_prefix(row, 'owner_user_'); + app.owner_user = user_to_client(owner_user); + } + + if ( typeof row.filetypes === 'string' ) { + try { + const filetypesAsJSON = JSON.parse(row.filetypes); + for ( let i = 0 ; i < filetypesAsJSON.length ; i++ ) { + if ( filetypesAsJSON[i] === null ) continue; + if ( typeof filetypesAsJSON[i] !== 'string' ) { + throw new Error(`expected filetypesAsJSON[${i}] to be a string, got: ${filetypesAsJSON[i]}`); + } + if ( String.prototype.startsWith.call(filetypesAsJSON[i], '.') ) { + filetypesAsJSON[i] = filetypesAsJSON[i].slice(1); + } + } + app.filetype_associations = filetypesAsJSON; + } catch (e) { + throw new Error(`failed to get app filetype associations: ${e.message}`, { cause: e }); + } + } + + if ( params.icon_size ) { + const icon_size = params.icon_size; + const svc_appIcon = this.context.get('services').get('app-icon'); + try { + const icon_result = await svc_appIcon.get_icon_stream({ + app_uid: row.uid, + app_icon: row.icon, + size: icon_size, + }); + app.icon = await icon_result.get_data_url(); + } catch (e) { + const svc_error = this.context.get('services').get('error-service'); + svc_error.report('AppES:read_transform', { source: e }); + } + } + + return app; + } + + #build_complex_id_where (id) { + const id_keys = Object.keys(id); + id_keys.sort(); + + // 1. Validate the identifier key from `id` + + const redundant_identifiers = this.constructor.REDUNDANT_IDENTIFIERS; + let match_found = false; + + for ( let key_set of redundant_identifiers ) { + key_set = Array.isArray(key_set) ? key_set : [key_set]; + const sorted_key_set = [...key_set].sort(); + + // Check if id_keys matches this key_set exactly + if ( id_keys.length === sorted_key_set.length && + id_keys.every((k, i) => k === sorted_key_set[i]) ) { + match_found = true; + break; + } + } + + if ( ! match_found ) { + throw new Error(`Invalid complex id keys: ${id_keys.join(', ')}. ` + + `Allowed: ${redundant_identifiers.join(', ')}`); + } + + // 2. Build the SQL string for the predicate + + const conditions = []; + const values = []; + + for ( const key of id_keys ) { + conditions.push(`apps.${key} = ?`); + values.push(id[key]); + } + + return { + clause: conditions.join(' AND '), + values, + }; + } }