@navette-js/database 0.0.0-stage → 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +8 -2
- package/lib/index.d.ts +148 -0
- package/lib/index.js +204 -0
- package/package.json +47 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NavetteJS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @navette-js/database
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Data adapters (Objection/Knex) for Navette panels.
|
|
4
|
+
|
|
5
|
+
Part of [Navette](https://github.com/NavetteJS/navette), a Node.js port of [Filament](https://filamentphp.com). Install the whole framework with `npm install @navette-js/panels`.
|
|
6
|
+
|
|
7
|
+
**Beta:** the API may still change between beta releases.
|
|
8
|
+
|
|
9
|
+
License: MIT. Third-party notices: see `THIRD-PARTY-NOTICES.md` where present.
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { type Knex } from 'knex';
|
|
2
|
+
import { Model, type ModelClass, type QueryBuilder } from 'objection';
|
|
3
|
+
export { Model };
|
|
4
|
+
export type { Knex };
|
|
5
|
+
export type SortDirection = 'asc' | 'desc';
|
|
6
|
+
export type ListQuery = {
|
|
7
|
+
/** Recherche globale : chaque mot doit apparaître dans au moins une des colonnes cherchables. */
|
|
8
|
+
search?: string | null;
|
|
9
|
+
searchColumns?: string[];
|
|
10
|
+
/** Recherche par colonne : toutes les conditions doivent être vraies. */
|
|
11
|
+
columnSearches?: Record<string, string>;
|
|
12
|
+
/** Enregistrements supprimés (suppression douce) : exclus par défaut, inclus, ou seuls. */
|
|
13
|
+
trashed?: 'without' | 'with' | 'only';
|
|
14
|
+
sort?: {
|
|
15
|
+
column: string;
|
|
16
|
+
direction: SortDirection;
|
|
17
|
+
} | null;
|
|
18
|
+
/** Regroupement de table : ce tri passe avant `sort`. */
|
|
19
|
+
groupSort?: {
|
|
20
|
+
column: string;
|
|
21
|
+
direction: SortDirection;
|
|
22
|
+
} | null;
|
|
23
|
+
/** Conditions supplémentaires posées par le serveur (onglet de liste, filtre sur mesure) : jamais construites à partir de données du navigateur non validées. */
|
|
24
|
+
modifiers?: ((query: any) => void)[];
|
|
25
|
+
page: number;
|
|
26
|
+
perPage: number;
|
|
27
|
+
};
|
|
28
|
+
export type Page<T> = {
|
|
29
|
+
records: T[];
|
|
30
|
+
total: number;
|
|
31
|
+
page: number;
|
|
32
|
+
perPage: number;
|
|
33
|
+
lastPage: number;
|
|
34
|
+
from: number | null;
|
|
35
|
+
to: number | null;
|
|
36
|
+
};
|
|
37
|
+
/** Ce dont le framework a besoin pour lister, lire et écrire des enregistrements, sans connaître l'ORM. */
|
|
38
|
+
export interface DataAdapter<T = any> {
|
|
39
|
+
getKey(record: T): string;
|
|
40
|
+
/** Filtre des clés reçues du navigateur : seules celles de la portée de l'adaptateur sont rendues. */
|
|
41
|
+
keysInScope(keys: (string | number)[]): Promise<string[]>;
|
|
42
|
+
sum(query: Omit<ListQuery, 'page' | 'perPage'>, columns: string[]): Promise<Record<string, number>>;
|
|
43
|
+
/** Agrégats sur les enregistrements d'une requête de liste : nombre, sommes et moyennes de colonnes. */
|
|
44
|
+
aggregate(query: Omit<ListQuery, 'page' | 'perPage'>, columns?: {
|
|
45
|
+
sum?: string[];
|
|
46
|
+
avg?: string[];
|
|
47
|
+
min?: string[];
|
|
48
|
+
max?: string[];
|
|
49
|
+
}): Promise<{
|
|
50
|
+
count: number;
|
|
51
|
+
sum: Record<string, number>;
|
|
52
|
+
avg: Record<string, number>;
|
|
53
|
+
min: Record<string, unknown>;
|
|
54
|
+
max: Record<string, unknown>;
|
|
55
|
+
}>;
|
|
56
|
+
getLabel(): string;
|
|
57
|
+
count(): Promise<number>;
|
|
58
|
+
paginate(query: ListQuery): Promise<Page<T>>;
|
|
59
|
+
find(key: string | number): Promise<T | null>;
|
|
60
|
+
create(data: Record<string, unknown>): Promise<T>;
|
|
61
|
+
update(key: string | number, data: Record<string, unknown>): Promise<T | null>;
|
|
62
|
+
delete(key: string | number): Promise<boolean>;
|
|
63
|
+
/** Suppression douce : prise en charge, état d'un enregistrement, restauration, suppression définitive. */
|
|
64
|
+
supportsSoftDeletes(): boolean;
|
|
65
|
+
isTrashed(record: T): boolean;
|
|
66
|
+
restore(key: string | number): Promise<boolean>;
|
|
67
|
+
forceDelete(key: string | number): Promise<boolean>;
|
|
68
|
+
/** Clés de tous les enregistrements correspondant à la requête (sans pagination), pour « tout sélectionner ». */
|
|
69
|
+
listKeys(query: Omit<ListQuery, 'page' | 'perPage'>): Promise<string[]>;
|
|
70
|
+
deleteMany(keys: (string | number)[]): Promise<number>;
|
|
71
|
+
/** Vrai si un autre enregistrement a déjà cette valeur (règle `unique`). */
|
|
72
|
+
exists(column: string, value: unknown, exceptKey?: string | number | null): Promise<boolean>;
|
|
73
|
+
}
|
|
74
|
+
export declare function createConnection(config: Knex.Config): Knex;
|
|
75
|
+
/** Connexion SQLite (pilote better-sqlite3), pour le développement et la démo. */
|
|
76
|
+
export declare function sqlite(filename: string): Knex;
|
|
77
|
+
export declare class ObjectionAdapter<M extends Model> implements DataAdapter<M> {
|
|
78
|
+
private model;
|
|
79
|
+
private modify;
|
|
80
|
+
private softDeletes;
|
|
81
|
+
private findsTrashed;
|
|
82
|
+
/** `softDeletes` : colonne d'horodatage de suppression (équivalent du trait SoftDeletes). `findsTrashed` : `find()` voit aussi les supprimés
|
|
83
|
+
* (équivalent de `getRecordRouteBindingEloquentQuery()->withoutGlobalScopes([SoftDeletingScope::class])`). */
|
|
84
|
+
constructor(model: ModelClass<M>, options?: {
|
|
85
|
+
modify?: (query: QueryBuilder<M, M[]>) => void;
|
|
86
|
+
softDeletes?: string;
|
|
87
|
+
findsTrashed?: boolean;
|
|
88
|
+
});
|
|
89
|
+
private tenantScope;
|
|
90
|
+
/**
|
|
91
|
+
* Cloisonne l'adaptateur par locataire : toute lecture filtre sur `column`, toute création la renseigne,
|
|
92
|
+
* avec la clé rendue par `key()` (celle du locataire de la requête ; `null` : pas de filtre). Posé par le panel (`panel.tenant()`).
|
|
93
|
+
*/
|
|
94
|
+
scopeToTenant(column: string, key: () => string | number | null): this;
|
|
95
|
+
private base;
|
|
96
|
+
private now;
|
|
97
|
+
private get keyColumn();
|
|
98
|
+
getKey(record: M): string;
|
|
99
|
+
getLabel(): string;
|
|
100
|
+
count(): Promise<number>;
|
|
101
|
+
listKeys(query: Omit<ListQuery, 'page' | 'perPage'>): Promise<string[]>;
|
|
102
|
+
/**
|
|
103
|
+
* Parmi des clés reçues du navigateur, celles qui appartiennent à la portée de l'adaptateur (`modify`, suppression douce comprise).
|
|
104
|
+
* Une sélection forgée ne peut ainsi pas viser un enregistrement que la table n'affiche pas.
|
|
105
|
+
*/
|
|
106
|
+
keysInScope(keys: (string | number)[]): Promise<string[]>;
|
|
107
|
+
/** Somme de colonnes sur tous les enregistrements de la requête (pour les lignes de totaux). */
|
|
108
|
+
aggregate(query: Omit<ListQuery, 'page' | 'perPage'>, columns?: {
|
|
109
|
+
sum?: string[];
|
|
110
|
+
avg?: string[];
|
|
111
|
+
min?: string[];
|
|
112
|
+
max?: string[];
|
|
113
|
+
}): Promise<{
|
|
114
|
+
count: number;
|
|
115
|
+
sum: Record<string, number>;
|
|
116
|
+
avg: Record<string, number>;
|
|
117
|
+
min: Record<string, unknown>;
|
|
118
|
+
max: Record<string, unknown>;
|
|
119
|
+
}>;
|
|
120
|
+
/**
|
|
121
|
+
* Tendance mensuelle sur la requête filtrée (équivalent de `Trend::query(...)->between()->perMonth()->count()` ou `->average()`
|
|
122
|
+
* de flowframe/laravel-trend) : une valeur par mois de `start` à `end`, 0 pour un mois sans enregistrement.
|
|
123
|
+
* SQL de date écrit pour SQLite ; dates en UTC, au format `Y-m-d H:i:s`.
|
|
124
|
+
*/
|
|
125
|
+
trendPerMonth(query: Omit<ListQuery, 'page' | 'perPage'>, options: {
|
|
126
|
+
start: Date;
|
|
127
|
+
end: Date;
|
|
128
|
+
dateColumn?: string;
|
|
129
|
+
average?: string;
|
|
130
|
+
modify?: (q: any) => void;
|
|
131
|
+
}): Promise<number[]>;
|
|
132
|
+
sum(query: Omit<ListQuery, 'page' | 'perPage'>, columns: string[]): Promise<Record<string, number>>;
|
|
133
|
+
deleteMany(keys: (string | number)[]): Promise<number>;
|
|
134
|
+
paginate(query: ListQuery): Promise<Page<M>>;
|
|
135
|
+
/** Requête de base avec recherche globale et recherches par colonne appliquées. */
|
|
136
|
+
private filtered;
|
|
137
|
+
private finishPagination;
|
|
138
|
+
find(key: string | number): Promise<M | null>;
|
|
139
|
+
create(data: Record<string, unknown>): Promise<M>;
|
|
140
|
+
update(key: string | number, data: Record<string, unknown>): Promise<M | null>;
|
|
141
|
+
/** Avec suppression douce, l'enregistrement est horodaté, pas retiré. */
|
|
142
|
+
delete(key: string | number): Promise<boolean>;
|
|
143
|
+
supportsSoftDeletes(): boolean;
|
|
144
|
+
isTrashed(record: M): boolean;
|
|
145
|
+
restore(key: string | number): Promise<boolean>;
|
|
146
|
+
forceDelete(key: string | number): Promise<boolean>;
|
|
147
|
+
exists(column: string, value: unknown, exceptKey?: string | number | null): Promise<boolean>;
|
|
148
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @navette-js/database — contrat d'accès aux données (ADR 0005) et premier adaptateur : Knex + Objection.
|
|
2
|
+
import knexFactory from 'knex';
|
|
3
|
+
import { Model } from 'objection';
|
|
4
|
+
export { Model };
|
|
5
|
+
export function createConnection(config) {
|
|
6
|
+
const knex = knexFactory(config);
|
|
7
|
+
Model.knex(knex);
|
|
8
|
+
return knex;
|
|
9
|
+
}
|
|
10
|
+
/** Connexion SQLite (pilote better-sqlite3), pour le développement et la démo. */
|
|
11
|
+
export function sqlite(filename) {
|
|
12
|
+
return createConnection({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true });
|
|
13
|
+
}
|
|
14
|
+
const escapeLike = (s) => s.replace(/[\\%_]/g, (c) => '\\' + c);
|
|
15
|
+
export class ObjectionAdapter {
|
|
16
|
+
model;
|
|
17
|
+
modify;
|
|
18
|
+
softDeletes;
|
|
19
|
+
findsTrashed;
|
|
20
|
+
/** `softDeletes` : colonne d'horodatage de suppression (équivalent du trait SoftDeletes). `findsTrashed` : `find()` voit aussi les supprimés
|
|
21
|
+
* (équivalent de `getRecordRouteBindingEloquentQuery()->withoutGlobalScopes([SoftDeletingScope::class])`). */
|
|
22
|
+
constructor(model, options = {}) {
|
|
23
|
+
this.model = model;
|
|
24
|
+
this.modify = options.modify ?? null;
|
|
25
|
+
this.softDeletes = options.softDeletes ?? null;
|
|
26
|
+
this.findsTrashed = options.findsTrashed === true;
|
|
27
|
+
}
|
|
28
|
+
tenantScope = null;
|
|
29
|
+
/**
|
|
30
|
+
* Cloisonne l'adaptateur par locataire : toute lecture filtre sur `column`, toute création la renseigne,
|
|
31
|
+
* avec la clé rendue par `key()` (celle du locataire de la requête ; `null` : pas de filtre). Posé par le panel (`panel.tenant()`).
|
|
32
|
+
*/
|
|
33
|
+
scopeToTenant(column, key) { this.tenantScope = { column, key }; return this; }
|
|
34
|
+
base(trashed = 'without') {
|
|
35
|
+
const q = this.model.query();
|
|
36
|
+
const tenantKey = this.tenantScope?.key() ?? null;
|
|
37
|
+
if (tenantKey !== null)
|
|
38
|
+
q.where(`${this.model.tableName}.${this.tenantScope.column}`, tenantKey);
|
|
39
|
+
if (this.modify)
|
|
40
|
+
this.modify(q);
|
|
41
|
+
if (this.softDeletes) {
|
|
42
|
+
if (trashed === 'without')
|
|
43
|
+
q.whereNull(this.softDeletes);
|
|
44
|
+
else if (trashed === 'only')
|
|
45
|
+
q.whereNotNull(this.softDeletes);
|
|
46
|
+
}
|
|
47
|
+
return q;
|
|
48
|
+
}
|
|
49
|
+
now() { return new Date().toISOString().slice(0, 19).replace('T', ' '); }
|
|
50
|
+
get keyColumn() { return String(this.model.idColumn); }
|
|
51
|
+
getKey(record) { return String(record[this.keyColumn]); }
|
|
52
|
+
getLabel() { return this.model.tableName; }
|
|
53
|
+
async count() { return this.base().resultSize(); }
|
|
54
|
+
async listKeys(query) {
|
|
55
|
+
const rows = await this.filtered(query).select(this.keyColumn);
|
|
56
|
+
return rows.map((r) => this.getKey(r));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parmi des clés reçues du navigateur, celles qui appartiennent à la portée de l'adaptateur (`modify`, suppression douce comprise).
|
|
60
|
+
* Une sélection forgée ne peut ainsi pas viser un enregistrement que la table n'affiche pas.
|
|
61
|
+
*/
|
|
62
|
+
async keysInScope(keys) {
|
|
63
|
+
if (!keys.length)
|
|
64
|
+
return [];
|
|
65
|
+
const rows = await this.base('with').whereIn(`${this.model.tableName}.${this.keyColumn}`, keys.map(String).slice(0, 10000));
|
|
66
|
+
return rows.map((r) => this.getKey(r));
|
|
67
|
+
}
|
|
68
|
+
/** Somme de colonnes sur tous les enregistrements de la requête (pour les lignes de totaux). */
|
|
69
|
+
async aggregate(query, columns = {}) {
|
|
70
|
+
const q = this.model.knex().from(this.filtered(query).toKnexQuery().as('t')).count({ __count: '*' });
|
|
71
|
+
for (const column of columns.sum ?? [])
|
|
72
|
+
q.sum({ [`sum_${column}`]: `t.${column}` });
|
|
73
|
+
for (const column of columns.avg ?? [])
|
|
74
|
+
q.avg({ [`avg_${column}`]: `t.${column}` });
|
|
75
|
+
for (const column of columns.min ?? [])
|
|
76
|
+
q.min({ [`min_${column}`]: `t.${column}` });
|
|
77
|
+
for (const column of columns.max ?? [])
|
|
78
|
+
q.max({ [`max_${column}`]: `t.${column}` });
|
|
79
|
+
const row = ((await q.first()) ?? {});
|
|
80
|
+
return {
|
|
81
|
+
count: Number(row.__count ?? 0),
|
|
82
|
+
sum: Object.fromEntries((columns.sum ?? []).map((column) => [column, Number(row[`sum_${column}`] ?? 0)])),
|
|
83
|
+
avg: Object.fromEntries((columns.avg ?? []).map((column) => [column, Number(row[`avg_${column}`] ?? 0)])),
|
|
84
|
+
min: Object.fromEntries((columns.min ?? []).map((column) => [column, row[`min_${column}`] ?? null])),
|
|
85
|
+
max: Object.fromEntries((columns.max ?? []).map((column) => [column, row[`max_${column}`] ?? null])),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Tendance mensuelle sur la requête filtrée (équivalent de `Trend::query(...)->between()->perMonth()->count()` ou `->average()`
|
|
90
|
+
* de flowframe/laravel-trend) : une valeur par mois de `start` à `end`, 0 pour un mois sans enregistrement.
|
|
91
|
+
* SQL de date écrit pour SQLite ; dates en UTC, au format `Y-m-d H:i:s`.
|
|
92
|
+
*/
|
|
93
|
+
async trendPerMonth(query, options) {
|
|
94
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
95
|
+
const stamp = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
96
|
+
const column = options.dateColumn ?? 'created_at';
|
|
97
|
+
const q = this.model.knex().from(this.filtered(query).toKnexQuery().as('t')).select(this.model.knex().raw(`strftime('%Y-%m', t.??) as month`, [column])).whereBetween(`t.${column}`, [stamp(options.start), stamp(options.end)]).groupBy('month');
|
|
98
|
+
if (options.average)
|
|
99
|
+
q.avg({ aggregate: `t.${options.average}` });
|
|
100
|
+
else
|
|
101
|
+
q.count({ aggregate: '*' });
|
|
102
|
+
options.modify?.(q);
|
|
103
|
+
const rows = new Map((await q).map((row) => [row.month, Number(row.aggregate ?? 0)]));
|
|
104
|
+
const values = [];
|
|
105
|
+
// Comme CarbonPeriod par pas d'un mois : même jour du mois que `start`, jusqu'à `end` compris.
|
|
106
|
+
for (let i = 0;; i++) {
|
|
107
|
+
const month = new Date(Date.UTC(options.start.getUTCFullYear(), options.start.getUTCMonth() + i, 1));
|
|
108
|
+
if (month.getTime() > options.end.getTime())
|
|
109
|
+
break;
|
|
110
|
+
values.push(rows.get(`${month.getUTCFullYear()}-${pad(month.getUTCMonth() + 1)}`) ?? 0);
|
|
111
|
+
}
|
|
112
|
+
return values;
|
|
113
|
+
}
|
|
114
|
+
async sum(query, columns) {
|
|
115
|
+
if (!columns.length)
|
|
116
|
+
return {};
|
|
117
|
+
// La requête filtrée sert de sous-requête : ses colonnes calculées (alias) restent utilisables par la recherche.
|
|
118
|
+
const q = this.model.knex().from(this.filtered(query).toKnexQuery().as('t'));
|
|
119
|
+
for (const column of columns)
|
|
120
|
+
q.sum({ [column]: `t.${column}` });
|
|
121
|
+
const row = ((await q.first()) ?? {});
|
|
122
|
+
return Object.fromEntries(columns.map((column) => [column, Number(row[column] ?? 0)]));
|
|
123
|
+
}
|
|
124
|
+
async deleteMany(keys) {
|
|
125
|
+
keys = await this.keysInScope(keys);
|
|
126
|
+
if (!keys.length)
|
|
127
|
+
return 0;
|
|
128
|
+
if (this.softDeletes)
|
|
129
|
+
return this.model.query().whereIn(this.keyColumn, keys).whereNull(this.softDeletes).patch({ [this.softDeletes]: this.now() });
|
|
130
|
+
return this.model.query().delete().whereIn(this.keyColumn, keys);
|
|
131
|
+
}
|
|
132
|
+
async paginate(query) {
|
|
133
|
+
return this.finishPagination(this.filtered(query), query);
|
|
134
|
+
}
|
|
135
|
+
/** Requête de base avec recherche globale et recherches par colonne appliquées. */
|
|
136
|
+
filtered(query) {
|
|
137
|
+
const q = this.base(query.trashed ?? 'without');
|
|
138
|
+
for (const modifier of query.modifiers ?? [])
|
|
139
|
+
modifier(q);
|
|
140
|
+
const words = (query.search ?? '').trim().split(/\s+/).filter(Boolean);
|
|
141
|
+
const columns = query.searchColumns ?? [];
|
|
142
|
+
if (words.length && columns.length) {
|
|
143
|
+
// Comme Filament : chaque mot doit correspondre à au moins une colonne, sans tenir compte de la casse.
|
|
144
|
+
for (const word of words) {
|
|
145
|
+
q.where((sub) => {
|
|
146
|
+
for (const column of columns)
|
|
147
|
+
sub.orWhereRaw("lower(??) like ? escape '\\'", [column, `%${escapeLike(word.toLowerCase())}%`]);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const [column, value] of Object.entries(query.columnSearches ?? {})) {
|
|
152
|
+
for (const word of String(value ?? '').trim().split(/\s+/).filter(Boolean))
|
|
153
|
+
q.whereRaw("lower(??) like ? escape '\\'", [column, `%${escapeLike(word.toLowerCase())}%`]);
|
|
154
|
+
}
|
|
155
|
+
return q;
|
|
156
|
+
}
|
|
157
|
+
async finishPagination(q, query) {
|
|
158
|
+
const total = await q.clone().resultSize();
|
|
159
|
+
if (query.groupSort)
|
|
160
|
+
q.orderBy(query.groupSort.column, query.groupSort.direction);
|
|
161
|
+
if (query.sort)
|
|
162
|
+
q.orderBy(query.sort.column, query.sort.direction);
|
|
163
|
+
q.orderBy(this.keyColumn, 'asc');
|
|
164
|
+
const perPage = Math.max(1, query.perPage);
|
|
165
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
|
166
|
+
const page = Math.min(Math.max(1, query.page), lastPage);
|
|
167
|
+
const records = await q.offset((page - 1) * perPage).limit(perPage);
|
|
168
|
+
return { records, total, page, perPage, lastPage, from: records.length ? (page - 1) * perPage + 1 : null, to: records.length ? (page - 1) * perPage + records.length : null };
|
|
169
|
+
}
|
|
170
|
+
async find(key) { return (await this.base(this.findsTrashed ? 'with' : 'without').findById(key)) ?? null; }
|
|
171
|
+
async create(data) {
|
|
172
|
+
const tenantKey = this.tenantScope?.key() ?? null;
|
|
173
|
+
return (await this.model.query().insertAndFetch((tenantKey !== null ? { ...data, [this.tenantScope.column]: tenantKey } : data)));
|
|
174
|
+
}
|
|
175
|
+
async update(key, data) {
|
|
176
|
+
// Un enregistrement hors du locataire courant n'est pas modifiable, et la colonne du locataire ne change pas.
|
|
177
|
+
if (this.tenantScope && this.tenantScope.key() !== null) {
|
|
178
|
+
if (!(await this.base('with').findById(key)))
|
|
179
|
+
return null;
|
|
180
|
+
data = Object.fromEntries(Object.entries(data).filter(([column]) => column !== this.tenantScope.column));
|
|
181
|
+
}
|
|
182
|
+
return (await this.model.query().patchAndFetchById(key, data)) ?? null;
|
|
183
|
+
}
|
|
184
|
+
/** Avec suppression douce, l'enregistrement est horodaté, pas retiré. */
|
|
185
|
+
async delete(key) {
|
|
186
|
+
if (this.softDeletes)
|
|
187
|
+
return (await this.model.query().findById(key).whereNull(this.softDeletes).patch({ [this.softDeletes]: this.now() })) > 0;
|
|
188
|
+
return (await this.model.query().deleteById(key)) > 0;
|
|
189
|
+
}
|
|
190
|
+
supportsSoftDeletes() { return this.softDeletes !== null; }
|
|
191
|
+
isTrashed(record) { return this.softDeletes !== null && record?.[this.softDeletes] != null; }
|
|
192
|
+
async restore(key) {
|
|
193
|
+
if (!this.softDeletes)
|
|
194
|
+
return false;
|
|
195
|
+
return (await this.model.query().findById(key).whereNotNull(this.softDeletes).patch({ [this.softDeletes]: null })) > 0;
|
|
196
|
+
}
|
|
197
|
+
async forceDelete(key) { return (await this.model.query().deleteById(key)) > 0; }
|
|
198
|
+
async exists(column, value, exceptKey = null) {
|
|
199
|
+
const q = this.model.query().where(column, value);
|
|
200
|
+
if (exceptKey !== null)
|
|
201
|
+
q.whereNot(this.keyColumn, exceptKey);
|
|
202
|
+
return (await q.resultSize()) > 0;
|
|
203
|
+
}
|
|
204
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/database",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "Data adapters (Objection/Knex) for Navette panels.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.ts",
|
|
10
|
+
"default": "./lib/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"types": "./lib/index.d.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=24"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/NavetteJS/navette.git",
|
|
25
|
+
"directory": "packages/database"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/NavetteJS/navette#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/NavetteJS/navette/issues"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"navette",
|
|
33
|
+
"filament",
|
|
34
|
+
"admin",
|
|
35
|
+
"panel",
|
|
36
|
+
"livewire",
|
|
37
|
+
"express",
|
|
38
|
+
"node"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"knex": "^3.1.0",
|
|
42
|
+
"objection": "^3.1.5",
|
|
43
|
+
"better-sqlite3": "^12.0.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"tag": "beta"
|
|
48
|
+
}
|
|
49
|
+
}
|