diff --git a/README.md b/README.md index 08abc5d..8295fd5 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,7 @@ [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] [![npm-downloads]][npm-downloads] ![][typescript-image] [![license-image]][license-url] -## Introduction - -Adonis datatable is an inspiration from laravel datatable. This code is taken from some of those source codes. +Adonis datatable is an inspiration from laravel datatable. It is heavily inspired by the PHP library [Laravel Datatables](https://yajrabox.com/docs/laravel-datatables) and even share some code. ## Official Documentation diff --git a/package.json b/package.json index 74eabaa..432d49d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@adityadarma/adonis-datatables", "description": "Package server side datatables on AdonisJS", - "version": "1.1.7", + "version": "1.1.8", "engines": { "node": ">=20.6.0" }, @@ -46,7 +46,7 @@ "adonis", "adonisjs" ], - "author": "Aditya Darma", + "author": "Aditya Darma ", "license": "MIT", "devDependencies": { "@adonisjs/assembler": "^7.7.0", diff --git a/src/datatable_abstract.ts b/src/datatable_abstract.ts index 6d0ff99..f0adee7 100644 --- a/src/datatable_abstract.ts +++ b/src/datatable_abstract.ts @@ -55,6 +55,10 @@ export abstract class DataTableAbstract implements DataTable { protected $dataObject: boolean = true + constructor() { + this.config = new Config(app.config.get('datatables')) + } + static canCreate(_source: any) { return false } @@ -63,14 +67,15 @@ export abstract class DataTableAbstract implements DataTable { return new this(source) } - constructor() { - this.config = new Config(app.config.get('datatables')) - } + /** + * Implement function + */ + protected abstract defaultOrdering(): any /** * Implement function */ - abstract defaultOrdering(): any + protected abstract resolveCallback(): any /** * Implement function @@ -102,11 +107,6 @@ export abstract class DataTableAbstract implements DataTable { */ abstract globalSearch(keyword: string): void - /** - * Implement function - */ - protected abstract resolveCallback(): any - protected prepareContext(): void { if (this.ctx) { return @@ -170,7 +170,7 @@ export abstract class DataTableAbstract implements DataTable { } } - protected async processResults(results: Record[]): Promise[]> { + protected processResults(results: Record[]): Record[] { const processor = new DataProcessor( results, this.getColumnsDefinition(), @@ -179,7 +179,7 @@ export abstract class DataTableAbstract implements DataTable { this.config ) - return await processor.process(this.$dataObject) + return processor.process(this.$dataObject) } protected render(data: any[]): void { diff --git a/src/engines/database_datatable.ts b/src/engines/database_datatable.ts index 7135016..6198f75 100644 --- a/src/engines/database_datatable.ts +++ b/src/engines/database_datatable.ts @@ -14,8 +14,6 @@ export default class DatabaseDataTable extends DataTableAbstract { protected keepSelectBindings: boolean = false - protected ignoreSelectInCountQuery: boolean = false - protected disableUserOrdering: boolean = false constructor( @@ -27,110 +25,82 @@ export default class DatabaseDataTable extends DataTableAbstract { this.$columns = query.columns } - protected getConnection() { - return this.query.knexQuery.client - } - static canCreate(source: any): boolean { return source instanceof DatabaseQueryBuilder } - async results() { - try { - this.prepareContext() - - const query = await this.prepareQuery() - const results = await query.dataResults() - const processed = await this.processResults(results) - - return this.render(processed) - } catch (error) { - return this.errorResponse(error) - } - } - - async dataResults(): Promise[]> { - return await this.query + protected getConnection() { + return this.query.knexQuery.client } - async prepareQuery(): Promise { - if (!this.prepared) { - this.totalRecords = await this.totalCount() - - await this.filterRecords() - this.ordering() - this.paginate() - } + protected defaultOrdering(): any { + const self = this + collect(this.request.orderableColumns()) + .map((orderable: Record) => { + orderable['name'] = self.getColumnName(orderable['column'], true) - this.prepared = true + return orderable + }) + .reject( + (orderable: Record) => + self.isBlacklisted(orderable['name']) && !self.hasOrderColumn(orderable['name']) + ) + .each((orderable: Record) => { + const column = self.resolveRelationColumn(orderable['name']) - return this + if (self.hasOrderColumn(orderable['name'])) { + self.applyOrderColumn(orderable['name'], orderable) + } else if (self.hasOrderColumn(column)) { + self.applyOrderColumn(column, orderable) + } else { + const nullsLastSql = self.getNullsLastSql(column, orderable['direction']) + const normalSql = self.wrapColumn(column) + ' ' + orderable['direction'] + const sql = self.nullsLast ? nullsLastSql : normalSql + self.query.orderByRaw(sql) + } + }) } - async count(): Promise { - const builder = this.query.clone() as DatabaseQueryBuilder - const result = await builder.exec() - return result.length + protected hasOrderColumn(column: string): boolean { + return this.$columnDef['order'][column] !== undefined } - async filterRecords(): Promise { - const initialQuery = this.query.clone() - - if (this.autoFilter && this.request.isSearchable()) { - this.filtering() - } - - if (typeof this.filterCallback === 'function') { - this.filterCallback(this.query) + protected applyOrderColumn(column: string, orderable: Record): void { + let sql = this.$columnDef['order'][column]['sql'] + if (sql === false) { + return } - this.columnSearch() - - if (!this.$skipTotalRecords && this.query === initialQuery) { - this.filteredRecords ??= this.totalRecords + if (typeof sql === 'function') { + sql(this.query, orderable['direction']) } else { - await this.filteredCount() - - if (this.$skipTotalRecords) { - this.totalRecords = this.filteredRecords - } + sql = sql.replace('$1', orderable['direction']) + const bindings = this.$columnDef['order'][column]['bindings'] + this.query.orderByRaw(sql, bindings) } } - columnSearch(): void { - const columns = this.request.columns() - - for (let index = 0; index < columns.length; index++) { - let column = this.getColumnName(index) - - if (column === null) { - continue - } + protected getNullsLastSql(column: string, direction: string): string { + const sql = this.config.get('datatables.nulls_last_sql', '%s %s NULLS LAST') - if ( - !this.request.isColumnSearchable(index) || - (this.isBlacklisted(column) && !this.hasFilterColumn(column)) - ) { - continue - } + return sprintf(sql, column, direction) + .replace(':column', column) + .replace(':direction', direction) + } - if (this.hasFilterColumn(column)) { - const keyword = this.getColumnSearchKeyword(index, true) - this.applyFilterColumn(this.getBaseQueryBuilder(), column, keyword) + protected attachAppends(data: Record): Record { + const appends: Record = {} + for (const [key, value] of Object.entries(this.appends)) { + if (typeof value === 'function') { + appends[key] = Helper.value(value(this.getFilteredQuery())) } else { - column = this.resolveRelationColumn(column) - const keyword = this.getColumnSearchKeyword(index) - this.compileColumnSearch(index, column, keyword) + appends[key] = value } } - } - hasFilterColumn(columnName: string): boolean { - return this.$columnDef['filter'][columnName] !== undefined - } + appends['disableOrdering'] = this.disableUserOrdering - wrapColumn(column: string) { - return Helper.wrapColumn(column, true) + return { ...data, ...appends } } protected getColumnSearchKeyword(i: number, raw: boolean = false): string { @@ -155,29 +125,6 @@ export default class DatabaseDataTable extends DataTableAbstract { callback(query, keyword) } - getBaseQueryBuilder( - instance: - | DatabaseQueryBuilderContract> - | ModelQueryBuilderContract - | undefined = undefined - ): - | DatabaseQueryBuilderContract> - | ModelQueryBuilderContract { - if (!instance) { - instance = this.query as - | DatabaseQueryBuilderContract> - | ModelQueryBuilderContract - } - - return instance - } - - getQuery(): - | DatabaseQueryBuilderContract> - | ModelQueryBuilderContract { - return this.query - } - protected resolveRelationColumn(column: string): string { return column } @@ -216,19 +163,6 @@ export default class DatabaseDataTable extends DataTableAbstract { this.query.whereRaw(sql, [keyword]) } - castColumn(column: string): string { - const driverName = this.getConnection().driverName - - switch (driverName) { - case 'pgsql': - return `CAST(${column} AS TEXT)` - case 'firebird': - return `CAST(${column} AS VARCHAR(255))` - default: - return column - } - } - protected compileQuerySearch( query: | ModelQueryBuilderContract @@ -248,7 +182,103 @@ export default class DatabaseDataTable extends DataTableAbstract { ;(query as any)[method](sql, [this.prepareKeyword(keyword)]) } - addTablePrefix( + protected prepareKeyword(keyword: string): string { + if (this.config.isCaseInsensitive()) { + keyword = keyword.toLowerCase() + } + + if (this.config.isStartsWithSearch()) { + return `${keyword}%` + } + + if (this.config.isWildcard()) { + keyword = Helper.wildcardString(keyword, '%') + } + + if (this.config.isSmartSearch()) { + keyword = `%${keyword}%` + } + + return keyword + } + + protected async prepareQuery(): Promise { + if (!this.prepared) { + this.totalRecords = await this.totalCount() + + await this.filterRecords() + this.ordering() + this.paginate() + } + + this.prepared = true + + return this + } + + protected async filterRecords(): Promise { + const initialQuery = this.query.clone() + + if (this.autoFilter && this.request.isSearchable()) { + this.filtering() + } + + if (typeof this.filterCallback === 'function') { + this.filterCallback(this.query) + } + + this.columnSearch() + + if (!this.$skipTotalRecords && this.query === initialQuery) { + this.filteredRecords ??= this.totalRecords + } else { + await this.filteredCount() + + if (this.$skipTotalRecords) { + this.totalRecords = this.filteredRecords + } + } + } + + protected hasFilterColumn(columnName: string): boolean { + return this.$columnDef['filter'][columnName] !== undefined + } + + protected wrapColumn(column: string) { + return Helper.wrapColumn(column, true) + } + + protected getBaseQueryBuilder( + instance: + | DatabaseQueryBuilderContract> + | ModelQueryBuilderContract + | undefined = undefined + ): + | DatabaseQueryBuilderContract> + | ModelQueryBuilderContract { + if (!instance) { + instance = this.query as + | DatabaseQueryBuilderContract> + | ModelQueryBuilderContract + } + + return instance + } + + protected castColumn(column: string): string { + const driverName = this.getConnection().driverName + + switch (driverName) { + case 'pgsql': + return `CAST(${column} AS TEXT)` + case 'firebird': + return `CAST(${column} AS VARCHAR(255))` + default: + return column + } + } + + protected addTablePrefix( query: | DatabaseQueryBuilderContract> | ModelQueryBuilderContract, @@ -270,24 +300,69 @@ export default class DatabaseDataTable extends DataTableAbstract { return this.wrapColumn(column) } - protected prepareKeyword(keyword: string): string { - if (this.config.isCaseInsensitive()) { - keyword = keyword.toLowerCase() - } + protected getFilteredQuery(): + | DatabaseQueryBuilderContract> + | ModelQueryBuilderContract { + this.prepareQuery() - if (this.config.isStartsWithSearch()) { - return `${keyword}%` - } + return this.query + } - if (this.config.isWildcard()) { - keyword = Helper.wildcardString(keyword, '%') - } + protected resolveCallback(): any { + return this.query + } - if (this.config.isSmartSearch()) { - keyword = `%${keyword}%` + async results() { + try { + this.prepareContext() + + const query = await this.prepareQuery() + const results = await query.dataResults() + const processed = this.processResults(results) + + return this.render(processed) + } catch (error) { + return this.errorResponse(error) } + } - return keyword + async dataResults(): Promise[]> { + console.log(this.query.toQuery()) + return await this.query + } + + async count(): Promise { + const builder = this.query.clone() as DatabaseQueryBuilder + const result = await builder.exec() + return result.length + } + + columnSearch(): void { + const columns = this.request.columns() + + for (let index = 0; index < columns.length; index++) { + let column = this.getColumnName(index) + + if (column === null) { + continue + } + + if ( + !this.request.isColumnSearchable(index) || + (this.isBlacklisted(column) && !this.hasFilterColumn(column)) + ) { + continue + } + + if (this.hasFilterColumn(column)) { + const keyword = this.getColumnSearchKeyword(index, true) + this.applyFilterColumn(this.getBaseQueryBuilder(), column, keyword) + } else { + column = this.resolveRelationColumn(column) + const keyword = this.getColumnSearchKeyword(index) + this.compileColumnSearch(index, column, keyword) + } + } } filterColumn( @@ -353,61 +428,6 @@ export default class DatabaseDataTable extends DataTableAbstract { return super.addColumn(name, content, order) } - defaultOrdering(): any { - const self = this - collect(this.request.orderableColumns()) - .map((orderable: Record) => { - orderable['name'] = self.getColumnName(orderable['column'], true) - - return orderable - }) - .reject( - (orderable: Record) => - self.isBlacklisted(orderable['name']) && !self.hasOrderColumn(orderable['name']) - ) - .each((orderable: Record) => { - const column = self.resolveRelationColumn(orderable['name']) - - if (self.hasOrderColumn(orderable['name'])) { - self.applyOrderColumn(orderable['name'], orderable) - } else if (self.hasOrderColumn(column)) { - self.applyOrderColumn(column, orderable) - } else { - const nullsLastSql = self.getNullsLastSql(column, orderable['direction']) - const normalSql = self.wrapColumn(column) + ' ' + orderable['direction'] - const sql = self.nullsLast ? nullsLastSql : normalSql - self.query.orderByRaw(sql) - } - }) - } - - protected hasOrderColumn(column: string): boolean { - return this.$columnDef['order'][column] !== undefined - } - - protected applyOrderColumn(column: string, orderable: Record): void { - let sql = this.$columnDef['order'][column]['sql'] - if (sql === false) { - return - } - - if (typeof sql === 'function') { - sql(this.query, orderable['direction']) - } else { - sql = sql.replace('$1', orderable['direction']) - const bindings = this.$columnDef['order'][column]['bindings'] - this.query.orderByRaw(sql, bindings) - } - } - - protected getNullsLastSql(column: string, direction: string): string { - const sql = this.config.get('datatables.nulls_last_sql', '%s %s NULLS LAST') - - return sprintf(sql, column, direction) - .replace(':column', column) - .replace(':direction', direction) - } - globalSearch(keyword: string): void { const self = this @@ -429,35 +449,6 @@ export default class DatabaseDataTable extends DataTableAbstract { }) } - protected attachAppends(data: Record): Record { - const appends: Record = {} - for (const [key, value] of Object.entries(this.appends)) { - if (typeof value === 'function') { - appends[key] = Helper.value(value(this.getFilteredQuery())) - } else { - appends[key] = value - } - } - - appends['disableOrdering'] = this.disableUserOrdering - - return { ...data, ...appends } - } - - getFilteredQuery(): - | DatabaseQueryBuilderContract> - | ModelQueryBuilderContract { - this.prepareQuery() - - return this.query - } - - ignoreSelectsInCountQuery(): this { - this.ignoreSelectInCountQuery = true - - return this - } - ordering(): void { if (this.disableUserOrdering) { return @@ -465,8 +456,4 @@ export default class DatabaseDataTable extends DataTableAbstract { super.ordering() } - - resolveCallback(): any { - return this.query - } } diff --git a/src/engines/lucid_datatable.ts b/src/engines/lucid_datatable.ts index eb95f30..a94404f 100644 --- a/src/engines/lucid_datatable.ts +++ b/src/engines/lucid_datatable.ts @@ -9,20 +9,6 @@ export default class LucidDataTable extends DatabaseDataTable { super(query) } - getPrimaryKeyName(): any { - return this.query.model.primaryKey - } - - static canCreate(source: any): boolean { - return source instanceof ModelQueryBuilder - } - - async count(): Promise { - const builder = this.query.clone() as ModelQueryBuilder - const result = await builder.exec() - return result.length - } - protected compileQuerySearch( query: ModelQueryBuilderContract, columnName: string, @@ -95,4 +81,18 @@ export default class LucidDataTable extends DatabaseDataTable { protected performJoin(table: string, foreign: string, other: string): void { this.getBaseQueryBuilder().leftJoin(table, foreign, '=', other) } + + protected getPrimaryKeyName(): any { + return this.query.model.primaryKey + } + + static canCreate(source: any): boolean { + return source instanceof ModelQueryBuilder + } + + async count(): Promise { + const builder = this.query.clone() as ModelQueryBuilder + const result = await builder.exec() + return result.length + } } diff --git a/src/engines/object_datatable.ts b/src/engines/object_datatable.ts index d621ccc..4a6e6b7 100644 --- a/src/engines/object_datatable.ts +++ b/src/engines/object_datatable.ts @@ -5,12 +5,11 @@ import Helper from '../utils/helper.js' export default class ObjectDataTable extends DataTableAbstract { protected $offset: number = 0 + protected collection: Collection = collect() - constructor(protected collection: Collection) { + constructor(object: Record[]) { super() - if (!(collection instanceof Collection)) { - this.collection = new Collection(collection) - } + this.collection = new Collection(object) this.$columns = this.collection.keys() } @@ -30,20 +29,20 @@ export default class ObjectDataTable extends DataTableAbstract { return this } - defaultOrdering(): any { + protected defaultOrdering(): any { const self = this const orderable = this.request.orderableColumns() if (orderable.length) { this.collection = this.collection .map((data) => Helper.dot(data)) - .sort((a, b) => { - for (const value of Object.values(this.request.orderableColumns())) { + .sort((a: Record, b: Record) => { + for (const value of Object.values(orderable)) { const column = self.getColumnName(value['column']) as string const direction = value['direction'] let first: Record let second: Record - let cmp: number + let cmp: number = 0 if (direction === 'desc') { first = b @@ -60,10 +59,8 @@ export default class ObjectDataTable extends DataTableAbstract { cmp = -1 } else if (first[column] > second[column]) { cmp = 1 - } else { - cmp = 0 } - } else if (this.config.isCaseInsensitive()) { + } else if (self.config.isCaseInsensitive()) { const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }) cmp = collator.compare(first[column], second[column]) } else { @@ -73,9 +70,8 @@ export default class ObjectDataTable extends DataTableAbstract { }) cmp = collator.compare(first[column], second[column]) } - if (cmp !== 0) { - return cmp - } + + return cmp } return 0 }) @@ -90,10 +86,6 @@ export default class ObjectDataTable extends DataTableAbstract { } } - dataResults(): any { - return this.collection.all() - } - protected revertIndexColumn(): void { if (this.$columnDef['index']) { const indexColumn = this.config.get('datatables.index_column', 'DT_RowIndex') @@ -108,16 +100,52 @@ export default class ObjectDataTable extends DataTableAbstract { } } + async count(): Promise { + return this.collection.count() + } + + async results(): Promise | void> { + try { + this.prepareContext() + + this.totalRecords = await this.totalCount() + + if (this.totalRecords) { + const results = this.dataResults() + const processed = this.processResults(results) + const output = lodash.transform( + processed, + (result: Record, value: any, key: string) => { + if (value) { + result[key] = value + } + } + ) + + this.collection = collect(output) + this.ordering() + await this.filterRecords() + this.paginate() + + this.revertIndexColumn() + } + + return this.render(this.collection.all()) + } catch (error) { + return this.errorResponse(error) + } + } + + dataResults(): any { + return this.collection.all() + } + setOffset(offset: number): this { this.$offset = offset return this } - async count(): Promise { - return this.collection.count() - } - columnSearch(): void { const self = this for (let i = 0, c = this.request.columns().length; i < c; i++) { @@ -161,38 +189,6 @@ export default class ObjectDataTable extends DataTableAbstract { this.collection = this.collection.slice(offset, length) } - async results(): Promise | void> { - try { - this.prepareContext() - - this.totalRecords = await this.totalCount() - - if (this.totalRecords) { - const results = await this.dataResults() - const processed = await this.processResults(results) - const output = lodash.transform( - processed, - (result: Record, value: any, key: string) => { - if (value) { - result[key] = value - } - } - ) - - this.collection = collect(output) - this.ordering() - await this.filterRecords() - this.paginate() - - this.revertIndexColumn() - } - - return this.render(this.collection.all()) - } catch (error) { - return this.errorResponse(error) - } - } - globalSearch(keyword: string): void { keyword = this.config.isCaseInsensitive() ? keyword.toLowerCase() : keyword diff --git a/src/processor.ts b/src/processor.ts index e3ac19a..0872e52 100644 --- a/src/processor.ts +++ b/src/processor.ts @@ -37,7 +37,7 @@ export default class DataProcessor { this.$includeIndex = columnDef['index'] ?? false } - async process(isObject = true) { + process(isObject = true) { const indexColumn = this.config.get('index_column', 'DT_RowIndex') for (const row of Object.values(this.$results)) { @@ -120,7 +120,7 @@ export default class DataProcessor { return data } - convertToArray(array: Record): Record { + protected convertToArray(array: Record): Record { const data: Record = [] for (const [key, value] of Object.entries(array)) { if (!data.includes(value)) {