@kubun/db-adapter 0.12.0 → 0.13.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/lib/postgres.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Expression, Kysely, RawBuilder } from 'kysely';
2
- import type { AbstractAdapter, AdapterTypes, ColumnTypes, Functions, NullOrderingTerm, OrderDirection, SearchHit, SearchIndexConfig, SearchQuery } from './types.js';
2
+ import type { AbstractAdapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, Functions, NullOrderingTerm, OrderDirection, SearchHit, SearchIndexConfig, SearchQuery } from './types.js';
3
3
  export type PostgresTypes = {
4
4
  Binary: Uint8Array;
5
5
  JSON: unknown;
@@ -15,6 +15,10 @@ export declare class AbstractPostgresAdapter<T extends AdapterTypes = PostgresTy
15
15
  coerceFilterValue(value: unknown): unknown;
16
16
  numericCast(expression: Expression<unknown>): Expression<number>;
17
17
  nullOrdering(_expression: Expression<unknown>, _direction: OrderDirection): NullOrderingTerm;
18
+ containsPredicate(expression: Expression<unknown>, pattern: string): RawBuilder<boolean>;
19
+ arrayIncludesAllPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
20
+ arrayIncludesAnyPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
21
+ arrayPresencePredicate(expression: Expression<unknown>, mode: ArrayPresenceMode): RawBuilder<boolean>;
18
22
  readAccessPredicate(params: {
19
23
  viewerDID: string;
20
24
  groupIDs: Array<string>;
package/lib/postgres.js CHANGED
@@ -41,6 +41,40 @@ export class AbstractPostgresAdapter {
41
41
  kind: 'native'
42
42
  };
43
43
  }
44
+ containsPredicate(expression, pattern) {
45
+ return sql`${expression} ILIKE ${pattern} ESCAPE '\\'`;
46
+ }
47
+ arrayIncludesAllPredicate(expression, values) {
48
+ // Native jsonb containment: works for string and numeric elements alike. NULL node
49
+ // coalesces to an empty array, which is a subset only of another empty set.
50
+ // Cast via ::text::jsonb, not a direct ::jsonb: postgres.js infers the parameter's
51
+ // wire type from a trailing ::jsonb cast and re-encodes the JS string as a jsonb
52
+ // *string scalar* instead of sending it as text for the server to parse, so a
53
+ // direct ::jsonb cast silently never matches. The ::text hop forces text encoding.
54
+ return sql`COALESCE(${expression}, '[]'::jsonb) @> ${JSON.stringify(values)}::text::jsonb`;
55
+ }
56
+ arrayIncludesAnyPredicate(expression, values) {
57
+ // Elements, not `?|`, so numeric arrays are covered too (`?|` only matches string keys).
58
+ return sql`EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(${expression}, '[]'::jsonb)) AS _t WHERE _t IN (${sql.join(values.map((v)=>sql`${v}`))}))`;
59
+ }
60
+ arrayPresencePredicate(expression, mode) {
61
+ // null/nonNull/empty/nonEmpty must see the true NULL, so the node is never coalesced
62
+ // there — jsonb_array_length(NULL) is NULL, and NULL > 0 is not-true (excludes it).
63
+ switch(mode){
64
+ case 'null':
65
+ return sql`${expression} IS NULL`;
66
+ case 'nonNull':
67
+ return sql`${expression} IS NOT NULL`;
68
+ case 'empty':
69
+ return sql`jsonb_array_length(${expression}) = 0`;
70
+ case 'nonEmpty':
71
+ return sql`jsonb_array_length(${expression}) > 0`;
72
+ case 'nullOrEmpty':
73
+ return sql`COALESCE(jsonb_array_length(${expression}), 0) = 0`;
74
+ default:
75
+ throw new Error(`Invalid array presence mode: ${mode}`);
76
+ }
77
+ }
44
78
  readAccessPredicate(params, modelID) {
45
79
  const { viewerDID, groupIDs, circleIDs, serverDefault } = params;
46
80
  const docRestrictedParts = [
package/lib/sqlite.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Expression, Kysely, RawBuilder } from 'kysely';
2
- import type { AbstractAdapter, AdapterTypes, ColumnTypes, Functions, NullOrderingTerm, OrderDirection, SearchHit, SearchIndexConfig, SearchQuery } from './types.js';
2
+ import type { AbstractAdapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, Functions, NullOrderingTerm, OrderDirection, SearchHit, SearchIndexConfig, SearchQuery } from './types.js';
3
3
  export type SQLiteTypes = {
4
4
  Binary: Uint8Array;
5
5
  JSON: string;
@@ -15,6 +15,10 @@ export declare class AbstractSQLiteAdapter<T extends AdapterTypes = SQLiteTypes>
15
15
  coerceFilterValue(value: unknown): unknown;
16
16
  numericCast(expression: Expression<unknown>): Expression<number>;
17
17
  nullOrdering(expression: Expression<unknown>, direction: OrderDirection): NullOrderingTerm;
18
+ containsPredicate(expression: Expression<unknown>, pattern: string): RawBuilder<boolean>;
19
+ arrayIncludesAllPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
20
+ arrayIncludesAnyPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
21
+ arrayPresencePredicate(expression: Expression<unknown>, mode: ArrayPresenceMode): RawBuilder<boolean>;
18
22
  readAccessPredicate(params: {
19
23
  viewerDID: string;
20
24
  groupIDs: Array<string>;
package/lib/sqlite.js CHANGED
@@ -1,4 +1,12 @@
1
1
  import { sql } from 'kysely';
2
+ const SEARCH_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+ function assertSearchFieldNames(fields) {
4
+ for (const field of fields){
5
+ if (!SEARCH_FIELD_NAME.test(field)) {
6
+ throw new Error(`invalid search field name: ${JSON.stringify(field)}`);
7
+ }
8
+ }
9
+ }
2
10
  export class AbstractSQLiteAdapter {
3
11
  functions = {
4
12
  now: sql`(unixepoch())`
@@ -45,6 +53,36 @@ export class AbstractSQLiteAdapter {
45
53
  expression: sql`CASE WHEN ${expression} IS NULL THEN ${sql.lit(nullRank)} ELSE ${sql.lit(valueRank)} END`
46
54
  };
47
55
  }
56
+ containsPredicate(expression, pattern) {
57
+ // SQLite's LIKE is already ASCII case-insensitive by default.
58
+ return sql`${expression} LIKE ${pattern} ESCAPE '\\'`;
59
+ }
60
+ arrayIncludesAllPredicate(expression, values) {
61
+ // NULL/absent node -> json_each yields zero rows -> COUNT(DISTINCT value) = 0, never
62
+ // equal to a positive set size.
63
+ return sql`(SELECT COUNT(DISTINCT value) FROM json_each(COALESCE(${expression}, '[]')) WHERE value IN (${sql.join(values.map((v)=>sql`${v}`))})) = ${values.length}`;
64
+ }
65
+ arrayIncludesAnyPredicate(expression, values) {
66
+ return sql`EXISTS (SELECT 1 FROM json_each(COALESCE(${expression}, '[]')) WHERE value IN (${sql.join(values.map((v)=>sql`${v}`))}))`;
67
+ }
68
+ arrayPresencePredicate(expression, mode) {
69
+ // null/nonNull/empty/nonEmpty must see the true NULL, so the node is never coalesced
70
+ // there — json_array_length(NULL) is NULL, and NULL > 0 is not-true (excludes it).
71
+ switch(mode){
72
+ case 'null':
73
+ return sql`${expression} IS NULL`;
74
+ case 'nonNull':
75
+ return sql`${expression} IS NOT NULL`;
76
+ case 'empty':
77
+ return sql`json_array_length(${expression}) = 0`;
78
+ case 'nonEmpty':
79
+ return sql`json_array_length(${expression}) > 0`;
80
+ case 'nullOrEmpty':
81
+ return sql`COALESCE(json_array_length(${expression}), 0) = 0`;
82
+ default:
83
+ throw new Error(`Invalid array presence mode: ${mode}`);
84
+ }
85
+ }
48
86
  readAccessPredicate(params, modelID) {
49
87
  const { viewerDID, groupIDs, circleIDs, serverDefault } = params;
50
88
  const docRestrictedParts = [
@@ -91,29 +129,31 @@ export class AbstractSQLiteAdapter {
91
129
  )`;
92
130
  }
93
131
  async createSearchIndex(db, config) {
132
+ assertSearchFieldNames(config.fields);
94
133
  const tableName = `fts_${config.modelID}`;
95
134
  const columns = [
96
- 'document_id UNINDEXED',
97
- ...config.fields
98
- ].join(', ');
99
- await sql`CREATE VIRTUAL TABLE IF NOT EXISTS ${sql.ref(tableName)} USING fts5(${sql.raw(columns)})`.execute(db);
135
+ sql`${sql.ref('document_id')} UNINDEXED`,
136
+ ...config.fields.map((f)=>sql.ref(f))
137
+ ];
138
+ await sql`CREATE VIRTUAL TABLE IF NOT EXISTS ${sql.ref(tableName)} USING fts5(${sql.join(columns)})`.execute(db);
100
139
  }
101
140
  async dropSearchIndex(db, modelID) {
102
141
  const tableName = `fts_${modelID}`;
103
142
  await sql`DROP TABLE IF EXISTS ${sql.ref(tableName)}`.execute(db);
104
143
  }
105
144
  async updateSearchEntry(db, modelID, documentID, fields, fieldNames) {
145
+ assertSearchFieldNames(fieldNames);
106
146
  const tableName = `fts_${modelID}`;
107
147
  await sql`DELETE FROM ${sql.ref(tableName)} WHERE document_id = ${documentID}`.execute(db);
108
148
  const columns = [
109
- 'document_id',
110
- ...fieldNames
111
- ].join(', ');
149
+ sql.ref('document_id'),
150
+ ...fieldNames.map((f)=>sql.ref(f))
151
+ ];
112
152
  const values = [
113
153
  documentID,
114
154
  ...fieldNames.map((f)=>fields[f] ?? '')
115
- ].map((v)=>sql.lit(v));
116
- await sql`INSERT INTO ${sql.ref(tableName)} (${sql.raw(columns)}) VALUES (${sql.join(values)})`.execute(db);
155
+ ];
156
+ await sql`INSERT INTO ${sql.ref(tableName)} (${sql.join(columns)}) VALUES (${sql.join(values.map((v)=>sql`${v}`))})`.execute(db);
117
157
  }
118
158
  async removeSearchEntry(db, modelID, documentID) {
119
159
  const tableName = `fts_${modelID}`;
package/lib/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ColumnDataType, ColumnType, Dialect, Expression, Kysely, RawBuilder } from 'kysely';
2
2
  export type OrderDirection = 'asc' | 'desc';
3
+ export type ArrayPresenceMode = 'null' | 'nonNull' | 'empty' | 'nonEmpty' | 'nullOrEmpty';
3
4
  export type NullOrderingTerm = {
4
5
  kind: 'lead';
5
6
  expression: Expression<number>;
@@ -53,6 +54,10 @@ export type AbstractAdapter<T extends AdapterTypes = AdapterTypes> = {
53
54
  coerceFilterValue(value: unknown): unknown;
54
55
  numericCast(expression: Expression<unknown>): Expression<number>;
55
56
  nullOrdering(expression: Expression<unknown>, direction: OrderDirection): NullOrderingTerm;
57
+ containsPredicate(expression: Expression<unknown>, pattern: string): RawBuilder<boolean>;
58
+ arrayIncludesAllPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
59
+ arrayIncludesAnyPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
60
+ arrayPresencePredicate(expression: Expression<unknown>, mode: ArrayPresenceMode): RawBuilder<boolean>;
56
61
  readAccessPredicate(params: {
57
62
  viewerDID: string;
58
63
  groupIDs: Array<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/db-adapter",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,7 +15,10 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "kysely": "^0.29.4"
18
+ "kysely": "^0.29.5"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
19
22
  },
20
23
  "scripts": {
21
24
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",