x4js
Version:
1,311 lines (1,004 loc) • 25.8 kB
text/typescript
/**
* ___ ___ __
* \ \/ / / _
* \ / /_| |_
* / \____ _|
* /__/\__\ |_|
*
* @file core_data.ts
* @author Etienne Cochard
*
* @copyright (c) 2024 R-libre ingenierie
*
* Use of this source code is governed by an MIT-style license
* that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
**/
import { EvChange } from './component';
import { CoreElement } from './core_element';
import { CoreEvent, EventMap, EventSource } from './core_events';
import { isArray, isString } from './core_tools';
export type DataRecordID = any;
export type DataFieldValue = string | Date | number | boolean;
export type ChangeCallback = (type: string, id?: DataRecordID) => void;
export type CalcCallback = () => string;
export type FieldType = 'string' | 'int' | 'float' | 'date' | 'bool' | 'array' | 'object' | 'any' | 'calc';
export type DataIndex = Uint32Array;
export interface EvDataChange extends CoreEvent {
change_type: 'create' | 'update' | 'delete' | 'data' | 'change';
id?: DataRecordID;
}
/**
* fields definition
* field with index=0 is record id
*/
export interface MetaData {
type?: FieldType;
prec?: number;
required?: boolean;
calc?: (rec: DataRecord) => any;
model?: DataModel; // in case of array of subtypes, the model
}
export interface FieldInfo extends MetaData {
name: string;
}
/**
*
*/
class MetaInfos {
name: string;
id: string; // field name holding 'id' record info
fields: FieldInfo[]; // field list
constructor( name: string ) {
this.name = name;
this.id = undefined;
this.fields = [];
}
}
const metaFields = Symbol( 'metaField' );
function _getMetas( obj: object, create = true ) : MetaInfos {
let ctor = obj.constructor as any;
let mfld = Object.prototype.hasOwnProperty.call(ctor,metaFields) ? ctor[metaFields] : undefined;
if( mfld===undefined ) {
if( !create ) {
console.assert( mfld!==undefined );
}
// construct our metas
mfld = new MetaInfos( ctor.name );
// merge with parent class metas
let pctor = Object.getPrototypeOf(ctor);
if( pctor!=DataModel ) {
let pmetas = pctor[metaFields];
mfld.fields = [...pmetas.fields, ...mfld.fields ]
console.assert( mfld.id===undefined, 'cannot define mutiple record id' );
if( !mfld.id ) {
mfld.id = pmetas.id;
}
}
(obj.constructor as any)[metaFields] = mfld;
}
return mfld;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace data {
/**
* define a model id
* @example
* \@data_id()
* id: string; // this field is the record id
**/
export function id( ) {
return ( ownerCls: any, fldName: string ) => {
let metas = _getMetas( ownerCls );
metas.fields.push( {
name: fldName,
type: 'any',
required: true,
});
metas.id = fldName;
}
}
/**
* @ignore
*/
export function field( data: MetaData ) {
return ( ownerCls: any, fldName: string ) => {
let metas = _getMetas( ownerCls );
metas.fields.push( {
name: fldName,
...data
} );
}
}
/**
* following member is a string field
* @example
* \@data_string()
* my_field: string; // this field will be seen as a string
*/
export function string( props?: MetaData ) {
return field( { ...props, type: 'string' } );
}
/**
* following member is an integer field
* @example
* \@data_string()
* my_field: number; // this field will be seen as an integer
*/
export function int( props?: MetaData ) {
return field( { ...props, type: 'int' } );
}
/**
* following member is a float field
* @example
* \@data_float()
* my_field: number; // this field will be seen as a float
*/
export function float( props?: MetaData ) {
return field( { ...props, type: 'float' } );
}
/**
* following member is a boolean field
* @example
* \@data_bool()
* my_field: boolean; // this field will be seen as a boolean
*/
export function bool( props?: MetaData ) {
return field( { ...props, type: 'bool' } );
}
/**
* following member is a date field
* @example
* \@data_date()
* my_field: date; // this field will be seen as a date
*/
export function date( props?: MetaData ) {
return field( { ...props, type: 'date' } );
}
/**
* following member is a calculated field
* @example
* \@data_calc( )
* get my_field(): string => {
* return 'hello';
* };
*/
export function calc( props?: MetaData ) {
return field( { ...props, type: 'calc'} )
}
/**
*
*/
interface ModelConstructor {
new ( data?: any, id?: any ): DataModel;
}
/**
* following member is a record array
* @example
* \@data_array( )
* my_field(): TypedRecord[];
*/
export function array( ctor: ModelConstructor, props?: MetaData ) {
return data.field( { ...props, type: 'array', model: ctor ? new ctor() : null } )
}
/**
* following member is unknown
* @example
* \@data.any( )
* my_field: TypedRecord[];
*/
export function any( props?: MetaData ) {
return field( { ...props, type: 'any' } );
}
}
/**
* record model
*/
export class DataModel {
/**
* MUST IMPLEMENT
* @returns fields descriptors
*/
getFields(): FieldInfo[] {
let metas = _getMetas( this, false );
return metas.fields;
}
/**
*
*/
validate( record: DataRecord ) : Error[] {
let errs: Error[] = null;
let fields = this.getFields( );
fields.forEach( (fi) => {
if( fi.required && !this.getField(fi.name,record) ) {
if( errs ) {
errs = [];
}
errs.push( new Error( `field ${fi.name} is required.` ) );
}
})
return errs;
}
/**
* return the field index by name
*/
getFieldIndex( name: string ) : number {
let fields = this.getFields( );
return fields.findIndex( (fd) => fd.name == name );
}
/**
* default serializer
* @returns an object with known record values
*/
serialize<T = any>( input: DataRecord ): T {
let rec: any = {};
this.getFields().forEach((f) => {
if( f.calc === undefined ) {
rec[f.name] = input[f.name];
}
});
return rec as T;
}
/**
* default unserializer
* @param data - input data
* @returns a new Record
*/
unSerialize(data: any, id?: DataRecordID ) : DataRecord {
const fields = this.getFields();
const rec = new DataRecord( );
fields.forEach( (sf) => {
let value = data[sf.name];
if (value !== undefined) {
rec[sf.name] = this._convertField( sf, value );
}
});
if( id!==undefined ) {
rec[fields[0].name] = id;
}
else {
console.assert( this.getID(rec)!==undefined ); // store do not have ID field
}
return rec;
}
/**
* field conversion
* @param field - field descriptor
* @param input - value to convert
* @returns the field value in it's original form
*/
protected _convertField( field: FieldInfo, input: any ) : any {
//TODO: boolean
switch( field.type ) {
case 'float': {
let ffv: number = typeof (input) === 'number' ? input : parseFloat(input);
if (field.prec !== undefined) {
let mul = Math.pow(10, field.prec);
ffv = Math.round(ffv * mul) / mul;
}
return ffv;
}
case 'int': {
return typeof (input) === 'number' ? input : parseInt(input);
}
case 'date': {
return isString(input) ? new Date(input) : input;
}
case 'array': {
debugger;
/*
let result: any[] = [];
if( field.model ) {
input.forEach( ( v: any ) => {
result.push( field.model.clone( v ) );
})
return result;
}
*/
break;
}
}
return input;
}
/**
* get the record unique identifier
* by default the return value is the first field
* @return unique identifier
*/
getID( rec: DataRecord ): any {
if( !rec ) return null;
let metas = _getMetas( this, false );
return rec[metas.id];
}
/**
* get raw value of a field
* @param name - field name or field index
*/
getRaw( name: string | number, rec: DataRecord ) : any {
let idx;
let fields = this.getFields( );
if( typeof(name) === 'string' ) {
idx = fields.findIndex( ( fi: FieldInfo) => fi.name == name );
if( idx < 0 ) {
console.assert( false, 'unknown field: '+name);
return undefined;
}
}
else if( name<fields.length ) {
if( name<0 ) {
return undefined
}
idx = name;
}
else {
console.assert( false, 'bad field name: '+name);
return undefined;
}
let fld = fields[idx];
if( fld.calc!==undefined ) {
return fld.calc( rec );
}
return rec[fld.name];
}
/**
* get field value (as string)
* @param name - field name
* @example
* let value = record.get('field1');
*/
getField( name: string, rec: DataRecord ): string {
let v = this.getRaw( name, rec );
return (v===undefined || v===null) ? '' : ''+v;
}
}
/**
*
*/
export class DataRecord {
[ key: string ]: DataFieldValue;
/*
/ **
* @returns fields descriptors
* /
getFields(): FieldInfo[] {
let metas = _getMetas( this, false );
return metas.fields;
}
/ **
*
* @param name
* @param data
* /
setRaw( name: string, data: string ) {
this[name] = data;
}
/ **
* set field value
* @param name - field name
* @param value - value to set
* @example
* record.set( 'field1', 7 );
* /
setField(name: string, value: any) {
let fields = this.getFields( );
let idx = fields.findIndex( fi => fi.name == name );
if( idx < 0 ) {
console.assert( false, 'unknown field: '+name);
return;
}
let fld = fields[idx];
if( fld.calc!==undefined ) {
console.assert( false, 'cannot set calc field: '+name);
return;
}
this.setRaw( fld.name, value );
}
*/
}
/**
*
*/
interface DataEventMap extends EventMap {
change?: EvChange;
}
type DataSolver = ( data: any ) => DataRecord[];
export interface DataProxyProps {
url: string;
params?: string[];
solver?: DataSolver;
}
export class DataProxy extends CoreElement<DataEventMap> {
protected m_props: DataProxyProps;
constructor( props: DataProxyProps ) {
super( );
this.m_props = props;
}
async load( url?: string ) {
if( url ) {
this.m_props.url = url;
}
else {
url = this.m_props.url;
}
if( this.m_props.params ) {
url += '?' + this.m_props.params.join( '&' );
}
const r = await fetch( url );
if( r.ok ) {
const raw = await r.json( );
let json = raw;
if( this.m_props.solver ) {
json = this.m_props.solver( json );
}
this.fire( 'change', {value:json,context:raw} );
}
}
}
/**
*
*/
interface DataStoreProps {
model: DataModel;
data?: any[];
url?: string;
autoload?: false;
solver?: DataSolver;
}
interface DataStoreEventMap extends EventMap {
data_change: EvDataChange;
}
/**
*
*/
export class DataStore extends EventSource<DataStoreEventMap> {
protected m_model: DataModel;
protected m_fields: FieldInfo[];
protected m_records: DataRecord[];
protected m_proxy: DataProxy;
protected m_rec_index: DataIndex;
constructor(props: DataStoreProps ) {
super( );
this.m_fields = undefined;
this.m_records = [];
this.m_rec_index = null;
this.m_model = props.model;
this.m_fields = props.model.getFields();
if (props.data) {
this.setRawData( props.data );
}
else if( props.url ) {
this.m_proxy = new DataProxy( {
url: props.url,
solver: props.solver,
});
this.m_proxy.on( 'change', ( ev: EvChange) => {
this.setData( ev.value );
});
if( props.autoload!=false ) {
this.m_proxy.load( );
}
}
}
/**
*
* @param records
*/
async load( url?: string ) {
return this.m_proxy.load( url );
}
async reload( ) {
return this.m_proxy.load( );
}
/**
* convert raw objects to real records from model
* @param records
*/
public setData( records: any[] ) {
const realRecords: DataRecord[] = new Array( records.length );
records.forEach( (rec,idx) => {
realRecords[idx] = this.m_model.unSerialize(rec);
});
this.setRawData( realRecords );
}
/**
* just set the records
* @param records - must be of the same type as model
*/
public setRawData(records: DataRecord[]) {
this.m_records = records;
this._rebuildIndex( );
this.fire( 'data_change', { change_type: 'change'} );
}
private _rebuildIndex( ) {
this.m_rec_index = null; // null to signal that we have to run on records instead of index
this.m_rec_index = this.createIndex( null ); // prepare index (remove deleted)
this.m_rec_index = this.sortIndex( this.m_rec_index, null ); // sort by id
}
/**
*
*/
public update( rec: DataRecord ) {
let id = this.m_model.getID( rec );
let index = this.indexOfId(id);
if (index < 0) {
return false;
}
this.m_records[this.m_rec_index[index]] = rec;
this.fire( 'data_change', {change_type: 'update', id } );
return true;
}
/**
*
* @param data
*/
public append( rec: DataRecord | any ) {
if( !(rec instanceof DataRecord) ) {
rec = this.m_model.unSerialize( rec );
}
const id = this.m_model.getID(rec);
console.assert( id!==undefined );
this.m_records.push( rec );
this._rebuildIndex( );
this.fire( 'data_change', {change_type: 'create', id } );
}
/**
*
*/
getMaxId( ) {
let maxID: number = undefined;
const m = this.m_model;
this.m_records.forEach( (r) => {
let rid = m.getID( r );
if( maxID===undefined || maxID<rid ) {
maxID = rid;
}
});
return maxID;
}
/**
*
* @param id
*/
public delete(id: DataRecordID ): boolean {
let idx = this.indexOfId( id );
if( idx<0 ) {
return false;
}
idx = this.m_rec_index[idx];
// mark as deleted
this.m_records.splice( idx, 1 );
this._rebuildIndex( );
this.fire( 'data_change', { change_type: 'delete', id } );
return true;
}
/**
* return the number of records
*/
get count( ) : number {
return this.m_rec_index ? this.m_rec_index.length : this.m_records.length;
}
/**
* return the fields
*/
get fields( ) : FieldInfo [] {
return this.m_fields;
}
/**
* find the index of the element with the given id
*/
public indexOfId(id: DataRecordID ): number {
//if( this.count<10 ) {
// this.forEach( (rec) => rec.getID() == id );
//}
const m = this.m_model;
for( let lim = this.count, base = 0; lim != 0; lim >>= 1 ) {
const p = base + (lim >> 1); // int conversion
const idx = this.m_rec_index[p];
const rid = m.getID( this.m_records[idx] );
if( rid==id ) {
return p;
}
if( rid<id ) {
base = p+1;
lim--;
}
}
return -1;
}
/**
* return the record by it's id
* @returns record or null
*/
public getById(id: DataRecordID): DataRecord {
let idx = this.indexOfId( id );
if( idx<0 ) {
return null;
}
idx = this.m_rec_index[idx];
return this.m_records[idx];
}
/**
* return a record by it's index
* @returns record or null
*/
public getByIndex( index: number ): DataRecord {
let idx = this.m_rec_index[index];
return this._getRecord( idx );
}
private _getRecord( index: number ) : DataRecord {
return this.m_records[index] ?? null;
}
public moveTo( other: DataStore ) {
other.setRawData( this.m_records );
}
/**
* create a new view on the DataStore
* @param opts
*/
createView( opts?: DataViewProps ) : DataView {
let eopts = { ...opts, store: this };
return new DataView( eopts );
}
/**
*
*/
createIndex( filter: FilterInfo ) : DataIndex {
if( filter && filter.op==='empty-result' ) {
return new Uint32Array(0);
}
let index = new Uint32Array( this.m_records.length );
let iidx = 0;
if( !filter ) {
// reset filter
this.forEach( (rec, idx) => {
index[iidx++] = idx;
} );
}
else {
if( typeof(filter.op)==='function' ) {
let fn = filter.op as FilterFunc;
// scan all records and append only interesting ones
this.forEach( (rec, idx) => {
// skip deleted
if( !rec ) {
return;
}
if( fn(rec) ) {
index[iidx++] = idx;
}
} );
}
else {
let filterFld = this.m_model.getFieldIndex( filter.field ); // field index to filter on
if( filterFld<0 ) {
// unknown filter field, nothing inside
console.assert( false, 'unknown field name in filter' )
return new Uint32Array(0);
}
let filterValue = filter.value;
if( isString(filterValue) && !filter.caseSensitive ) {
filterValue = filterValue.toUpperCase( );
}
function _lt( recval: string ) : boolean {
return recval < filterValue;
}
function _le( recval: string ) : boolean {
return recval <= filterValue;
}
function _eq( recval: string ) : boolean {
return recval == filterValue;
}
function _neq( recval: string ) : boolean {
return recval != filterValue;
}
function _ge( recval: string ) : boolean {
return recval >= filterValue;
}
function _gt( recval: string ) : boolean {
return recval > filterValue;
}
function _re( recval: string ) : boolean {
filterRe.lastIndex = -1;
return filterRe.test( recval );
}
let filterFn: ( rec: string ) => boolean; // filter fn
let filterRe: RegExp; // if fielter is regexp
if( filterValue instanceof RegExp ) {
filterRe = filterValue;
filterFn = _re;
}
else {
switch( filter.op ) {
case '<': { filterFn = _lt; break; }
case '<=': { filterFn = _le; break; }
case '=': { filterFn = _eq; break; }
case '>=': { filterFn = _ge; break; }
case '>': { filterFn = _gt; break; }
case '<>': { filterFn = _neq; break; }
}
}
// scan all records and append only interesting ones
const m = this.m_model;
this.forEach( (rec, idx) => {
// skip deleted
if( !rec ) {
return;
}
let field = m.getRaw( filterFld, rec );
if( field===null || field===undefined ) {
field = '';
}
else {
field = ''+field;
if( !filter.caseSensitive ) {
field = field.toUpperCase( );
}
}
let keep = filterFn( field );
if( keep ) {
index[iidx++] = idx;
};
});
}
}
return index.slice( 0, iidx );
}
sortIndex( index: DataIndex, sort: SortProp[] ) {
interface sort_info {
fidx: number,
asc: boolean
}
let bads = 0; // unknown fields
let fidxs: sort_info[] = []; // fields indexes
// if no fields are given, reset sort by id
if ( sort===null ) {
fidxs.push( { fidx: 0, asc: true } );
}
else {
fidxs = sort.map( (si) => {
let fi = this.m_model.getFieldIndex( si.field );
if (fi == -1) {
console.assert( false, 'unknown field name in sort' )
bads++;
}
return { fidx: fi, asc: si.ascending };
});
}
// unknown field or nothing to sort on ??
if( bads || fidxs.length==0 ) {
return index;
}
// sort only by one field : optimize it
const m = this.m_model;
if( fidxs.length==1 ) {
const field = fidxs[0].fidx;
index.sort( ( ia, ib ) => {
let va = m.getRaw( field, this.getByIndex(ia) ) ?? '';
let vb = m.getRaw( field, this.getByIndex(ib) ) ?? '';
if (va > vb) { return 1; }
if (va < vb) { return -1; }
return 0;
} );
// just reverse if
if( !fidxs[0].asc ) {
index.reverse( );
}
}
else {
index.sort( ( ia, ib ) => {
for( let fi=0; fi<fidxs.length; fi++ ) {
let fidx = fidxs[fi].fidx;
let mul = fidxs[fi].asc ? 1 : -1;
let va = m.getRaw( fidx, this.getByIndex(ia) ) ?? '';
let vb = m.getRaw( fidx, this.getByIndex(ib) ) ?? '';
if (va > vb) { return mul; }
if (va < vb) { return -mul; }
}
return 0;
} );
}
return index
}
/**
*
*/
forEach( cb: ( rec: DataRecord, index: number ) => any ) {
if( this.m_rec_index ) {
this.m_rec_index.some( (ri,index) => {
if( cb( this.m_records[ri], index ) ) {
return index;
}
});
}
else {
this.m_records.some( ( rec, index ) => {
if( rec ) {
if( cb( rec, index ) ) {
return index;
}
}
} );
}
}
export( ) {
return this.m_records;
}
changed( ) {
this.fire( 'data_change', { change_type: 'change'} );
}
getModel( ) {
return this.m_model;
}
}
// :: VIEWS ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
export interface EvViewChange extends CoreEvent {
change_type: "change" | "filter" | "sort";
}
interface DataViewEventMap extends EventMap {
view_change: EvViewChange;
}
interface DataViewProps {
store?: DataStore;
filter?: FilterInfo;
order?: string | SortProp[] | SortProp;
}
export type FilterFunc = ( rec: DataRecord ) => boolean;
export interface FilterInfo {
op: '<' | '<=' | '=' | '>=' | '>' | '<>' | 'empty-result' | FilterFunc, // emptydb mean return an empty result always
field?: string;
value?: string | RegExp; // if regexp then operator is =
caseSensitive?: boolean;
}
export interface SortProp {
field: string; //
ascending: boolean; //
numeric?: boolean; // numeric sort
}
/**
* Dataview allow different views of the DataStore.
* You can sort the columns & filter data
* You can have multiple views for a single DataStore
*/
export class DataView extends CoreElement<DataViewEventMap>
{
protected m_index: DataIndex;
protected m_store: DataStore;
protected m_model: DataModel;
protected m_sort: SortProp[];
protected m_filter: FilterInfo;
protected m_props: DataViewProps;
constructor( props: DataViewProps ) {
super( );
this.m_props = props;
this.m_store = props.store;
this.m_index = null;
this.m_filter = null;
this.m_sort = null;
this.m_model = this.m_store.getModel();
this.filter( props.filter );
if( props.order ) {
if( isString(props.order) ) {
this.sort( [ { field: props.order, ascending: true } ] );
}
else if( isArray(props.order) ) {
this.sort( props.order );
}
else {
this.sort( [props.order] );
}
}
else {
this.sort( null );
}
this.m_store.addListener( 'data_change', ( e ) => this._storeChange(e) );
}
private _storeChange( ev: EvDataChange ) {
this._filter( this.m_filter, ev.type!='change' );
this._sort( this.m_sort, ev.type!='change' );
this.fire( 'view_change', { change_type: 'change' } );
}
/**
*
* @param filter
*/
public filter( filter?: FilterInfo ) : number {
this.m_index = null; // null to signal that we have to run on records instead of index
return this._filter( filter, true );
}
private _filter( filter: FilterInfo, notify: boolean) : number {
this.m_index = this.m_store.createIndex( filter );
this.m_filter = filter;
// need to sort again:
if( this.m_sort ) {
this.sort( this.m_sort );
}
if( notify ) {
this.fire( 'view_change', { change_type: 'filter' } );
}
return this.m_index.length;
}
/**
*
* @param columns
* @param ascending
*/
public sort( props: SortProp[] ) {
this._sort( props, true );
}
private _sort( props: SortProp[], notify: boolean ) {
this.m_index = this.m_store.sortIndex( this.m_index, props );
this.m_sort = props;
if( notify ) {
this.fire( 'view_change', { change_type: 'sort' } );
}
}
/**
*
*/
getStore ( ) {
return this.m_store;
}
/**
*
*/
public getCount() {
return this.m_index.length;
}
/**
*
* @param id
*/
public indexOfId(id: DataRecordID): number {
let ridx = this.m_store.indexOfId( id );
return this.m_index.findIndex( (rid) => rid === ridx );
}
/**
*
* @param index
*/
public getByIndex(index: number): DataRecord {
if (index >= 0 && index < this.m_index.length) {
let rid = this.m_index[index];
return this.m_store.getByIndex( rid );
}
return null;
}
public getIdByIndex( index: number ) : DataRecordID {
const rec = this.getByIndex( index );
return this.m_model.getID( rec );
}
public getRecId( rec: DataRecord ): DataRecordID {
return this.m_model.getID( rec );
}
/**
*
* @param id
*/
public getById( id: DataRecordID): DataRecord {
return this.m_store.getById( id );
}
/**
*
*/
getModel( ) {
return this.m_model;
}
/**
*
*/
changed( ) {
this.fire( 'view_change', {change_type:'change'} );
}
/**
*
*/
forEach( cb: ( rec: DataRecord, index: number ) => any ) {
this.m_index.some( ( index ) => {
let rec = this.m_store.getByIndex( index );
if( rec ) {
if( cb( rec, index ) ) {
return index;
}
}
} );
}
}