uql-orm 0.72.0 → 0.72.2
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/dist/browser/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +3 -3
- package/dist/dialect/abstractDialect.d.ts +6 -3
- package/dist/dialect/abstractDialect.js +22 -1
- package/dist/dialect/abstractSqlDialect.d.ts +15 -8
- package/dist/dialect/abstractSqlDialect.js +61 -28
- package/dist/dialect/aliases.d.ts +1 -1
- package/dist/dialect/aliases.js +1 -1
- package/dist/dialect/mysqlLikeSqlDialect.d.ts +6 -3
- package/dist/dialect/mysqlLikeSqlDialect.js +9 -6
- package/dist/dialect/pgLikeSqlDialect.d.ts +8 -5
- package/dist/dialect/pgLikeSqlDialect.js +14 -11
- package/dist/dialect/queryJoins.d.ts +11 -1
- package/dist/dialect/queryJoins.js +14 -0
- package/dist/dialect/vectorSqlDialect.d.ts +0 -5
- package/dist/dialect/vectorSqlDialect.js +0 -15
- package/dist/entity/metadata/definition.js +4 -2
- package/dist/migrate/cli.js +2 -1
- package/dist/migrate/ddl/indexDdl.d.ts +2 -0
- package/dist/migrate/ddl/indexDdl.js +4 -0
- package/dist/migrate/ddl/mysqlIndexDdl.d.ts +5 -0
- package/dist/migrate/ddl/mysqlIndexDdl.js +7 -0
- package/dist/migrate/generator/mongoCommand.d.ts +4 -0
- package/dist/migrate/generator/mongoSchemaGenerator.js +5 -1
- package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
- package/dist/migrate/introspection/mongoIntrospector.js +17 -6
- package/dist/migrate/schemaGenerator.d.ts +4 -1
- package/dist/migrate/schemaGenerator.js +14 -7
- package/dist/mongo/mongoDialect.d.ts +15 -2
- package/dist/mongo/mongoDialect.js +49 -8
- package/dist/mongo/mongodbQuerier.js +6 -7
- package/dist/mongo/mongodbQuerierPool.js +4 -1
- package/dist/mssql/mssqlDialect.js +1 -0
- package/dist/schema/indexDifferences.d.ts +2 -1
- package/dist/schema/indexDifferences.js +15 -1
- package/dist/schema/schemaASTBuilder.d.ts +2 -0
- package/dist/schema/schemaASTBuilder.js +23 -0
- package/dist/sqlite/sqliteDialect.d.ts +5 -5
- package/dist/sqlite/sqliteDialect.js +17 -7
- package/dist/type/dialect.d.ts +5 -0
- package/dist/type/entity.d.ts +5 -0
- package/dist/type/query.d.ts +11 -3
- package/dist/type/queryWhere.d.ts +3 -2
- package/dist/util/dialect.util.d.ts +25 -1
- package/dist/util/dialect.util.js +37 -0
- package/dist/util/wideNumber.d.ts +5 -3
- package/dist/util/wideNumber.js +8 -4
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IndexType } from '../schema/types.js';
|
|
2
|
-
import { type DriverCapabilities, type EntityMeta, type FieldOptions, type JsonColumnType, type Query, type QueryContext, type QueryTextSearchOptions, type SqlDialectFeatures, type
|
|
2
|
+
import { type DriverCapabilities, type EntityMeta, type FieldOptions, type JsonColumnType, type Query, type QueryContext, type QueryTextSearchOptions, type SqlDialectFeatures, type VectorDistance, type VectorMetric } from '../type/index.js';
|
|
3
3
|
import type { DialectOptions } from './abstractDialect.js';
|
|
4
4
|
import { AbstractSqlDialect, type RelationRows } from './abstractSqlDialect.js';
|
|
5
5
|
import { type JsonAccessMode, type JsonSlot } from './jsonSql.js';
|
|
@@ -63,10 +63,13 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
|
|
|
63
63
|
* index over these fields, which the planner serves it from. `WEBSEARCH_TO_TSQUERY` takes free-form
|
|
64
64
|
* input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
|
|
65
65
|
*/
|
|
66
|
-
protected appendTextSearch<E>(ctx: QueryContext,
|
|
67
|
-
/** `TS_RANK` of the document
|
|
68
|
-
protected
|
|
69
|
-
/**
|
|
66
|
+
protected appendTextSearch<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, prefix: string | undefined): void;
|
|
67
|
+
/** `TS_RANK` of the document over `keys` against the same search the match reads. */
|
|
68
|
+
protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, keys: readonly string[], prefix: string | undefined): void;
|
|
69
|
+
/**
|
|
70
|
+
* The document over `keys` and the search itself, open for its value, under the `$config` asked for,
|
|
71
|
+
* else that of the fulltext index over every field searched, which is what serves the match.
|
|
72
|
+
*/
|
|
70
73
|
private textSearchParts;
|
|
71
74
|
/**
|
|
72
75
|
* The document, `TO_TSVECTOR('english'::regconfig, COALESCE("a", '') || ' ' || COALESCE("b", ''))`, a
|
|
@@ -36,6 +36,7 @@ export const PG_FEATURES = {
|
|
|
36
36
|
rowLocks: true,
|
|
37
37
|
rowLockWithWindow: false,
|
|
38
38
|
rowLockOf: true,
|
|
39
|
+
textScoreIndexes: false,
|
|
39
40
|
orderedUpsertReturning: true,
|
|
40
41
|
orderedJsonAggregates: true,
|
|
41
42
|
narrowVectorTypes: false,
|
|
@@ -128,27 +129,29 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
|
|
|
128
129
|
* index over these fields, which the planner serves it from. `WEBSEARCH_TO_TSQUERY` takes free-form
|
|
129
130
|
* input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
|
|
130
131
|
*/
|
|
131
|
-
appendTextSearch(ctx,
|
|
132
|
-
const { document, query } = this.textSearchParts(meta, search);
|
|
132
|
+
appendTextSearch(ctx, meta, search, prefix) {
|
|
133
|
+
const { document, query } = this.textSearchParts(meta, search, prefix);
|
|
133
134
|
ctx.append(`${document} @@ ${query}`);
|
|
134
135
|
ctx.addValue(search.$value);
|
|
135
136
|
ctx.append(')');
|
|
136
137
|
}
|
|
137
|
-
/** `TS_RANK` of the document
|
|
138
|
-
|
|
139
|
-
const { document, query } = this.textSearchParts(meta, search);
|
|
138
|
+
/** `TS_RANK` of the document over `keys` against the same search the match reads. */
|
|
139
|
+
appendTextScore(ctx, meta, search, keys, prefix) {
|
|
140
|
+
const { document, query } = this.textSearchParts(meta, search, prefix, keys);
|
|
140
141
|
ctx.append(`TS_RANK(${document}, ${query}`);
|
|
141
142
|
ctx.addValue(search.$value);
|
|
142
143
|
ctx.append('))');
|
|
143
144
|
}
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The document over `keys` and the search itself, open for its value, under the `$config` asked for,
|
|
147
|
+
* else that of the fulltext index over every field searched, which is what serves the match.
|
|
148
|
+
*/
|
|
149
|
+
textSearchParts(meta, search, prefix, keys) {
|
|
150
|
+
const fields = textSearchFields(meta, search);
|
|
151
|
+
const index = fulltextIndexOver(meta, fields);
|
|
148
152
|
const config = search.$config ?? (index && fulltextConfig(index));
|
|
149
|
-
const columns = keys.map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
|
|
150
153
|
return {
|
|
151
|
-
document: this.textSearchTarget(
|
|
154
|
+
document: this.textSearchTarget(this.textColumns(meta, keys ?? fields, prefix), config),
|
|
152
155
|
query: `${this.textQueryFn}(${this.textConfigArg(config)}`,
|
|
153
156
|
};
|
|
154
157
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { EntityMeta, Query, QueryGroupMap, QuerySortMap, QueryWhere, RelationMeta, RelationQuery, Type } from '../type/index.js';
|
|
1
|
+
import type { EntityMeta, FieldMeta, Query, QueryGroupMap, QuerySortMap, QueryWhere, RelationMeta, RelationQuery, Type } from '../type/index.js';
|
|
2
|
+
import { type ParsedGroupEntry } from '../util/index.js';
|
|
2
3
|
/**
|
|
3
4
|
* One relation a statement joins, keyed by the alias its columns are addressed by (`tax`,
|
|
4
5
|
* `tax.category`). `projected` tells a `$populate` join, whose columns are selected, from one only
|
|
@@ -56,6 +57,15 @@ export declare function groupPathField(joins: QueryJoins, path: readonly string[
|
|
|
56
57
|
readonly key: string;
|
|
57
58
|
readonly join: QueryJoin | undefined;
|
|
58
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* The field an aggregate's column reads as, and the join it reads it through, none for the entity's own:
|
|
62
|
+
* a group key's, or the one a `$sum`, `$min` or `$max` aggregates. None at all for `$count` and `$avg`,
|
|
63
|
+
* which the engine widens to a number whatever they read.
|
|
64
|
+
*/
|
|
65
|
+
export declare function aggregateColumnField<E>(meta: EntityMeta<E>, joins: QueryJoins, entry: ParsedGroupEntry<E>): {
|
|
66
|
+
readonly field: FieldMeta | undefined;
|
|
67
|
+
readonly join?: QueryJoin;
|
|
68
|
+
} | undefined;
|
|
59
69
|
/**
|
|
60
70
|
* Whether a join drops parents that have no match, which is the one thing a join does to *how many*
|
|
61
71
|
* rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
|
|
@@ -53,6 +53,20 @@ export function groupPathField(joins, path) {
|
|
|
53
53
|
}
|
|
54
54
|
return { key, join };
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The field an aggregate's column reads as, and the join it reads it through, none for the entity's own:
|
|
58
|
+
* a group key's, or the one a `$sum`, `$min` or `$max` aggregates. None at all for `$count` and `$avg`,
|
|
59
|
+
* which the engine widens to a number whatever they read.
|
|
60
|
+
*/
|
|
61
|
+
export function aggregateColumnField(meta, joins, entry) {
|
|
62
|
+
if (entry.kind === 'fn') {
|
|
63
|
+
return entry.op === '$count' || entry.op === '$avg'
|
|
64
|
+
? undefined
|
|
65
|
+
: { field: meta.fields[entry.fieldRef] };
|
|
66
|
+
}
|
|
67
|
+
const { key, join } = groupPathField(joins, entry.path);
|
|
68
|
+
return join ? { field: join.meta.fields[key], join } : { field: meta.fields[key] };
|
|
69
|
+
}
|
|
56
70
|
/**
|
|
57
71
|
* Whether a join drops parents that have no match, which is the one thing a join does to *how many*
|
|
58
72
|
* rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
|
|
@@ -51,11 +51,6 @@ export declare abstract class VectorSqlDialect extends AbstractDialect {
|
|
|
51
51
|
* rather than naming a type the engine does not define.
|
|
52
52
|
*/
|
|
53
53
|
supportedVectorType(cast: VectorCast): VectorCast;
|
|
54
|
-
/**
|
|
55
|
-
* The distance a vector `$sort` projects, which the projection names after `$project`. Delegates to
|
|
56
|
-
* `appendVectorDistance` so each dialect's distance syntax is written once.
|
|
57
|
-
*/
|
|
58
|
-
protected appendVectorProjection<E>(ctx: QueryContext, meta: EntityMeta<E>, key: string, search: QueryVectorSearch): void;
|
|
59
54
|
/**
|
|
60
55
|
* The distance expression, in whichever of the two shapes this dialect spells it. One method for
|
|
61
56
|
* both, so the metric lookup and its refusal exist once rather than per shape.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_VECTOR_DISTANCE, unsupportedVectorMetric } from '../type/vector.js';
|
|
2
2
|
import { findVectorIndex, findVectorSort, vectorCandidates } from '../util/dialect.util.js';
|
|
3
|
-
import { entityName } from '../util/object.util.js';
|
|
4
3
|
import { AbstractDialect } from './abstractDialect.js';
|
|
5
4
|
import { encodeFloat32s } from './vectorCast.js';
|
|
6
5
|
/**
|
|
@@ -69,20 +68,6 @@ export class VectorSqlDialect extends AbstractDialect {
|
|
|
69
68
|
supportedVectorType(cast) {
|
|
70
69
|
return this.features.narrowVectorTypes ? cast : 'vector';
|
|
71
70
|
}
|
|
72
|
-
/**
|
|
73
|
-
* The distance a vector `$sort` projects, which the projection names after `$project`. Delegates to
|
|
74
|
-
* `appendVectorDistance` so each dialect's distance syntax is written once.
|
|
75
|
-
*/
|
|
76
|
-
appendVectorProjection(ctx, meta, key, search) {
|
|
77
|
-
const alias = search.$project;
|
|
78
|
-
// `$project` names a new column, so it cannot be one the entity already has: both come back
|
|
79
|
-
// under that name and the driver keeps whichever it read last. Checked here rather than in the
|
|
80
|
-
// type because TypeScript cannot say "any string except these".
|
|
81
|
-
if (meta.fields[alias]) {
|
|
82
|
-
throw new TypeError(`$project '${alias}' collides with a field of '${entityName(meta)}'`);
|
|
83
|
-
}
|
|
84
|
-
this.appendVectorDistance(ctx, meta, key, search);
|
|
85
|
-
}
|
|
86
71
|
/**
|
|
87
72
|
* The distance expression, in whichever of the two shapes this dialect spells it. One method for
|
|
88
73
|
* both, so the metric lookup and its refusal exist once rather than per shape.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RelationAggregate, SOFT_DELETE_FILTER } from '../../type/index.js';
|
|
2
2
|
import { isInlinedExpression } from '../../util/field.util.js';
|
|
3
|
-
import { entitySql, entityWhere, fieldOptionConflict, getKeys, hasKeys, isToManyRelation, memberRefs, normalizeIndexColumn, definedEntries, } from '../../util/index.js';
|
|
3
|
+
import { entitySql, entityWhere, fieldOptionConflict, getKeys, hasKeys, isToManyRelation, memberRefs, fulltextWeights, normalizeIndexColumn, definedEntries, } from '../../util/index.js';
|
|
4
4
|
import { ownRegistrations } from '../decorator/bag.js';
|
|
5
5
|
/**
|
|
6
6
|
* A map held on `globalThis` through the global symbol registry, so a single one survives multiple
|
|
@@ -101,11 +101,13 @@ export function defineHook(entity, methodName, event) {
|
|
|
101
101
|
export function defineIndex(entity, index) {
|
|
102
102
|
const meta = ensureWritableMeta(entity);
|
|
103
103
|
const refs = memberRefs();
|
|
104
|
+
const columns = index.columns(refs).map(normalizeIndexColumn);
|
|
105
|
+
fulltextWeights({ type: index.type, entries: columns });
|
|
104
106
|
(meta.indexes ??= []).push({
|
|
105
107
|
...index,
|
|
106
108
|
unique: index.unique ?? false,
|
|
107
109
|
where: index.where && entityWhere(index.where),
|
|
108
|
-
columns
|
|
110
|
+
columns,
|
|
109
111
|
include: index.include?.(refs).map((ref) => ref.key),
|
|
110
112
|
});
|
|
111
113
|
return meta;
|
package/dist/migrate/cli.js
CHANGED
|
@@ -239,7 +239,8 @@ export async function runDriftCheck(migrator, config) {
|
|
|
239
239
|
}
|
|
240
240
|
else {
|
|
241
241
|
console.log('\nChecking for schema drift...');
|
|
242
|
-
const
|
|
242
|
+
const generator = await migrator.getSchemaGenerator();
|
|
243
|
+
const expectedAST = generator.buildAST?.(config.entities) ?? buildEntityAST(generator, config.entities);
|
|
243
244
|
// Build actual schema from database
|
|
244
245
|
const actualAST = await migrator.schemaIntrospector.introspect();
|
|
245
246
|
// Detect drift. The dialect renders canonical types as SQL - without it every type formats as
|
|
@@ -15,6 +15,8 @@ export declare class IndexDdl<D extends AbstractSqlDialect = AbstractSqlDialect>
|
|
|
15
15
|
getCreateIndexStatement(tableName: string, index: IndexSchema, opts?: {
|
|
16
16
|
ifNotExists?: boolean;
|
|
17
17
|
}): string;
|
|
18
|
+
/** What an index added to a table that has rows needs run after it to serve queries; nothing, mostly. */
|
|
19
|
+
settleStatements(_tableName: string, _index: IndexSchema): string[];
|
|
18
20
|
/**
|
|
19
21
|
* Index features this dialect can express. Everything here is supported by at least one engine and
|
|
20
22
|
* refused by at least one other, so an index asking for a missing one is rejected rather than
|
|
@@ -50,6 +50,10 @@ export class IndexDdl {
|
|
|
50
50
|
`ON ${this.dialect.escapeId(tableName)}${this.indexAccessMethod(index)} (${columns})` +
|
|
51
51
|
`${this.indexInclude(index)}${this.indexTuning(index)}${this.indexPredicate(index)};`);
|
|
52
52
|
}
|
|
53
|
+
/** What an index added to a table that has rows needs run after it to serve queries; nothing, mostly. */
|
|
54
|
+
settleStatements(_tableName, _index) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
53
57
|
/**
|
|
54
58
|
* Index features this dialect can express. Everything here is supported by at least one engine and
|
|
55
59
|
* refused by at least one other, so an index asking for a missing one is rejected rather than
|
|
@@ -6,6 +6,11 @@ export declare class MysqlLikeIndexDdl extends IndexDdl {
|
|
|
6
6
|
protected readonly indexFeatures: Set<"expression" | "include" | "jsonArray" | "jsonPath" | "nullsOrder" | "opsClass" | "partial" | "prefixLength">;
|
|
7
7
|
protected readonly indexTypes: ReadonlySet<IndexType>;
|
|
8
8
|
protected readonly indexTypeKeywords: ReadonlyMap<IndexType, string>;
|
|
9
|
+
/**
|
|
10
|
+
* InnoDB fills a fulltext index added beside another on a loaded table only once the table is optimized:
|
|
11
|
+
* until then MariaDB scores it 0 and MySQL can fail a `MATCH` over it (MySQL 26.7, MariaDB 12.3).
|
|
12
|
+
*/
|
|
13
|
+
settleStatements(tableName: string, index: IndexSchema): string[];
|
|
9
14
|
/** ` USING btree|hash` trails the columns: between the table and them, it is a syntax error here. */
|
|
10
15
|
protected indexTuning(index: IndexSchema): string;
|
|
11
16
|
}
|
|
@@ -11,6 +11,13 @@ export class MysqlLikeIndexDdl extends IndexDdl {
|
|
|
11
11
|
indexFeatures = new Set(['expression', 'prefixLength']);
|
|
12
12
|
indexTypes = new Set(['btree', 'hash', 'fulltext']);
|
|
13
13
|
indexTypeKeywords = MYSQL_LIKE_INDEX_KEYWORDS;
|
|
14
|
+
/**
|
|
15
|
+
* InnoDB fills a fulltext index added beside another on a loaded table only once the table is optimized:
|
|
16
|
+
* until then MariaDB scores it 0 and MySQL can fail a `MATCH` over it (MySQL 26.7, MariaDB 12.3).
|
|
17
|
+
*/
|
|
18
|
+
settleStatements(tableName, index) {
|
|
19
|
+
return index.type === 'fulltext' ? [`OPTIMIZE TABLE ${this.dialect.escapeId(tableName)};`] : [];
|
|
20
|
+
}
|
|
14
21
|
/** ` USING btree|hash` trails the columns: between the table and them, it is a syntax error here. */
|
|
15
22
|
indexTuning(index) {
|
|
16
23
|
return index.type && !this.indexTypeKeywords.has(index.type) ? ` USING ${index.type}` : '';
|
|
@@ -5,6 +5,10 @@ export type MongoIndexOptions = {
|
|
|
5
5
|
readonly unique: boolean;
|
|
6
6
|
readonly name: string;
|
|
7
7
|
readonly partialFilterExpression?: Readonly<Record<string, unknown>>;
|
|
8
|
+
/** A text index's weight per field, which `textScore` multiplies a match in it by. */
|
|
9
|
+
readonly weights?: Readonly<Record<string, number>>;
|
|
10
|
+
/** The language a text index stems and drops stop words in, a search's own `$language` aside. */
|
|
11
|
+
readonly default_language?: string;
|
|
8
12
|
};
|
|
9
13
|
/** A field of an Atlas vector search index: the vector itself, or one its `filter` pre-filters on. */
|
|
10
14
|
export type MongoVectorSearchField = {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { getMeta } from '../../entity/index.js';
|
|
2
|
-
import { MongoDialect } from '../../mongo/mongoDialect.js';
|
|
2
|
+
import { MongoDialect, textLanguage } from '../../mongo/mongoDialect.js';
|
|
3
3
|
import { QueryRaw, } from '../../type/index.js';
|
|
4
4
|
import { indexDistance, unsupportedVectorMetric } from '../../type/vector.js';
|
|
5
5
|
import { declaredIndexes, declaredIndexName, renderIndexColumn } from '../../util/ddlExpression.util.js';
|
|
6
|
+
import { fulltextConfig, fulltextWeights } from '../../util/dialect.util.js';
|
|
6
7
|
import { assertIndexFeatures, assertIndexType } from '../ddl/indexDdl.js';
|
|
7
8
|
import { assertIndexPredicate, refusedIndexPredicate } from '../indexPredicate.js';
|
|
8
9
|
import { renderIndexDefinition } from './definitionToNode.js';
|
|
@@ -121,6 +122,7 @@ export class MongoSchemaGenerator extends MongoDialect {
|
|
|
121
122
|
for (const entry of index.entries) {
|
|
122
123
|
key[entry.column] = index.type === 'fulltext' ? 'text' : entry.order === 'desc' ? -1 : 1;
|
|
123
124
|
}
|
|
125
|
+
const weights = fulltextWeights(index);
|
|
124
126
|
return serializeMongoCommand({
|
|
125
127
|
action: 'createIndex',
|
|
126
128
|
collection: tableName,
|
|
@@ -130,6 +132,8 @@ export class MongoSchemaGenerator extends MongoDialect {
|
|
|
130
132
|
unique: index.unique,
|
|
131
133
|
name: index.name,
|
|
132
134
|
partialFilterExpression: index.where && JSON.parse(index.where),
|
|
135
|
+
weights: weights && Object.fromEntries(index.entries.map((entry, at) => [entry.column, weights[at]])),
|
|
136
|
+
default_language: index.type === 'fulltext' ? textLanguage(fulltextConfig(index)) : undefined,
|
|
133
137
|
},
|
|
134
138
|
});
|
|
135
139
|
}
|
|
@@ -7,7 +7,7 @@ import { type QuerierPool, type SchemaIntrospector, type TableSchema } from '../
|
|
|
7
7
|
*/
|
|
8
8
|
export declare class MongoSchemaIntrospector implements SchemaIntrospector {
|
|
9
9
|
private readonly pool;
|
|
10
|
-
/** `listIndexes` reports keys and
|
|
10
|
+
/** `listIndexes` reports keys, uniqueness and text weights; a `partialFilterExpression` is no SQL predicate. */
|
|
11
11
|
readonly indexFacets: ReadonlySet<IndexFacet>;
|
|
12
12
|
constructor(pool: QuerierPool);
|
|
13
13
|
introspect(tables?: readonly string[]): Promise<SchemaAST>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { textConfigOf } from '../../mongo/mongoDialect.js';
|
|
1
2
|
import { createTableNode, SchemaAST } from '../../schema/schemaAST.js';
|
|
2
3
|
import { isMongoQuerier, } from '../../type/index.js';
|
|
3
4
|
/** What a server without Atlas Search answers a search index command with. */
|
|
@@ -8,8 +9,8 @@ const SEARCH_NOT_ENABLED = 31082;
|
|
|
8
9
|
*/
|
|
9
10
|
export class MongoSchemaIntrospector {
|
|
10
11
|
pool;
|
|
11
|
-
/** `listIndexes` reports keys and
|
|
12
|
-
indexFacets = new Set();
|
|
12
|
+
/** `listIndexes` reports keys, uniqueness and text weights; a `partialFilterExpression` is no SQL predicate. */
|
|
13
|
+
indexFacets = new Set(['textIndex']);
|
|
13
14
|
constructor(pool) {
|
|
14
15
|
this.pool = pool;
|
|
15
16
|
}
|
|
@@ -38,10 +39,16 @@ export class MongoSchemaIntrospector {
|
|
|
38
39
|
name: tableName,
|
|
39
40
|
columns: [],
|
|
40
41
|
indexes: [
|
|
41
|
-
...indexes.map((
|
|
42
|
-
name:
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
...indexes.map(({ name, key, unique, weights, default_language }) => ({
|
|
43
|
+
name: name ?? Object.keys(key).join('_'),
|
|
44
|
+
unique: !!unique,
|
|
45
|
+
...(weights
|
|
46
|
+
? {
|
|
47
|
+
entries: Object.entries(weights).map(textIndexEntry),
|
|
48
|
+
type: 'fulltext',
|
|
49
|
+
config: default_language && textConfigOf(default_language),
|
|
50
|
+
}
|
|
51
|
+
: { entries: Object.keys(key).map((column) => ({ column })) }),
|
|
45
52
|
})),
|
|
46
53
|
...searchIndexes
|
|
47
54
|
.filter((idx) => idx.type === 'vectorSearch')
|
|
@@ -75,6 +82,10 @@ export class MongoSchemaIntrospector {
|
|
|
75
82
|
});
|
|
76
83
|
}
|
|
77
84
|
}
|
|
85
|
+
/** A text index field as an entity declares it: its weight stated only where it is not the default 1. */
|
|
86
|
+
function textIndexEntry([column, weight]) {
|
|
87
|
+
return weight === 1 ? { column } : { column, weight };
|
|
88
|
+
}
|
|
78
89
|
/** A collection's Atlas search indexes, none where the server has no Atlas Search. */
|
|
79
90
|
async function listSearchIndexes(collection) {
|
|
80
91
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AbstractSqlDialect } from '../dialect/index.js';
|
|
2
2
|
import type { SchemaAST } from '../schema/schemaAST.js';
|
|
3
|
+
import { type BuildSchemaASTOptions } from '../schema/schemaASTBuilder.js';
|
|
3
4
|
import { type DiffOptions } from '../schema/schemaASTDiffer.js';
|
|
4
5
|
import type { CanonicalType, ColumnNode, ForeignKeyAction, IndexNode, TableNode } from '../schema/types.js';
|
|
5
6
|
import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, EntityWhereMeta, FieldMeta, FieldOptions, ForeignKeySchema, IndexSchema, NamingStrategy, SchemaDiff, SchemaGenerator, Type } from '../type/index.js';
|
|
@@ -65,6 +66,8 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
|
|
|
65
66
|
generateCreateIndex(tableName: string, index: IndexSchema, options?: {
|
|
66
67
|
ifNotExists?: boolean;
|
|
67
68
|
}): string;
|
|
69
|
+
/** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
|
|
70
|
+
private addIndexStatements;
|
|
68
71
|
/**
|
|
69
72
|
* `schema` is the table's, because that is where its indexes live. MySQL takes it from the table
|
|
70
73
|
* operand instead, which is already qualified.
|
|
@@ -175,4 +178,4 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
|
|
|
175
178
|
* The entities as an AST, named by `generator`'s resolvers rather than a naming strategy, which would
|
|
176
179
|
* also rename an explicit `@Entity({ name })` and so compare each table under another name.
|
|
177
180
|
*/
|
|
178
|
-
export declare function buildEntityAST(generator: Pick<SchemaGenerator, 'resolveTableAlias' | 'resolveSchema' | 'resolveColumnName' | 'compileDdl' | 'compileIndexPredicate'>, entities: readonly Type<object>[],
|
|
181
|
+
export declare function buildEntityAST(generator: Pick<SchemaGenerator, 'resolveTableAlias' | 'resolveSchema' | 'resolveColumnName' | 'compileDdl' | 'compileIndexPredicate'>, entities: readonly Type<object>[], options?: Pick<BuildSchemaASTOptions, 'defaultForeignKeyAction' | 'textScoreIndexes'>): SchemaAST;
|
|
@@ -74,7 +74,10 @@ export class SqlSchemaGenerator {
|
|
|
74
74
|
}
|
|
75
75
|
/** The entity side as an AST, carrying this generator's default referential action. */
|
|
76
76
|
buildAST(entities) {
|
|
77
|
-
return buildEntityAST(this, entities,
|
|
77
|
+
return buildEntityAST(this, entities, {
|
|
78
|
+
defaultForeignKeyAction: this.defaultForeignKeyAction,
|
|
79
|
+
textScoreIndexes: this.dialect.features.textScoreIndexes,
|
|
80
|
+
});
|
|
78
81
|
}
|
|
79
82
|
/**
|
|
80
83
|
* Every `CREATE TABLE` for `entities`, then their foreign keys, since a relation graph is routinely
|
|
@@ -115,7 +118,7 @@ export class SqlSchemaGenerator {
|
|
|
115
118
|
* resolves instead of being silently dropped.
|
|
116
119
|
*/
|
|
117
120
|
orderedTables(entities, direction, only) {
|
|
118
|
-
const ast =
|
|
121
|
+
const ast = this.buildAST(entities);
|
|
119
122
|
const tables = direction === 'create' ? ast.getCreateOrder() : ast.getDropOrder();
|
|
120
123
|
if (!only) {
|
|
121
124
|
return tables;
|
|
@@ -166,7 +169,7 @@ export class SqlSchemaGenerator {
|
|
|
166
169
|
// Add indexes
|
|
167
170
|
if (diff.indexesToAdd?.length) {
|
|
168
171
|
for (const index of diff.indexesToAdd) {
|
|
169
|
-
statements.push(this.
|
|
172
|
+
statements.push(...this.addIndexStatements(diff.tableName, index));
|
|
170
173
|
}
|
|
171
174
|
}
|
|
172
175
|
// Drop indexes
|
|
@@ -243,6 +246,10 @@ export class SqlSchemaGenerator {
|
|
|
243
246
|
generateCreateIndex(tableName, index, options = {}) {
|
|
244
247
|
return this.indexDdl.getCreateIndexStatement(tableName, index, options);
|
|
245
248
|
}
|
|
249
|
+
/** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
|
|
250
|
+
addIndexStatements(tableName, index) {
|
|
251
|
+
return [this.generateCreateIndex(tableName, index), ...this.indexDdl.settleStatements(tableName, index)];
|
|
252
|
+
}
|
|
246
253
|
/**
|
|
247
254
|
* `schema` is the table's, because that is where its indexes live. MySQL takes it from the table
|
|
248
255
|
* operand instead, which is already qualified.
|
|
@@ -338,7 +345,7 @@ export class SqlSchemaGenerator {
|
|
|
338
345
|
}
|
|
339
346
|
// Keyed by the qualified name this generator resolves, which is the key the AST stores the table
|
|
340
347
|
// under.
|
|
341
|
-
const desired = (desiredAst ??
|
|
348
|
+
const desired = (desiredAst ?? this.buildAST([entity])).getTable(tableName);
|
|
342
349
|
if (!desired) {
|
|
343
350
|
return undefined;
|
|
344
351
|
}
|
|
@@ -556,7 +563,7 @@ export class SqlSchemaGenerator {
|
|
|
556
563
|
case 'alterColumn':
|
|
557
564
|
return this.generateAlterColumnSql(operation.tableName, operation.columnName, operation.changes);
|
|
558
565
|
case 'createIndex':
|
|
559
|
-
return
|
|
566
|
+
return this.addIndexStatements(operation.tableName, renderIndexDefinition(operation.index, (sql) => this.dialect.compileDdl(sql)));
|
|
560
567
|
case 'dropIndex':
|
|
561
568
|
return [this.generateDropIndex(operation.tableName, operation.indexName)];
|
|
562
569
|
case 'addForeignKey':
|
|
@@ -693,7 +700,7 @@ function foreignKeyOf(relation) {
|
|
|
693
700
|
* The entities as an AST, named by `generator`'s resolvers rather than a naming strategy, which would
|
|
694
701
|
* also rename an explicit `@Entity({ name })` and so compare each table under another name.
|
|
695
702
|
*/
|
|
696
|
-
export function buildEntityAST(generator, entities,
|
|
703
|
+
export function buildEntityAST(generator, entities, options = {}) {
|
|
697
704
|
return buildSchemaAST(entities, {
|
|
698
705
|
// The alias, not `resolveTableName`: a node holds its schema separately, so that a name derived
|
|
699
706
|
// from it stays a single identifier.
|
|
@@ -702,6 +709,6 @@ export function buildEntityAST(generator, entities, defaultForeignKeyAction) {
|
|
|
702
709
|
resolveColumnName: (key, field) => generator.resolveColumnName(key, field),
|
|
703
710
|
compileDdl: (sql, entity) => generator.compileDdl(sql, entity),
|
|
704
711
|
compileIndexPredicate: (where, entity, indexName) => generator.compileIndexPredicate(where, entity, indexName),
|
|
705
|
-
|
|
712
|
+
...options,
|
|
706
713
|
});
|
|
707
714
|
}
|
|
@@ -7,14 +7,25 @@ type MongoReadStages = {
|
|
|
7
7
|
/** Ordering, which runs after the lookups when it reads one of their fields. */
|
|
8
8
|
readonly sort?: Sort;
|
|
9
9
|
readonly pager?: MongoAggregationPipelineEntry<Document>[];
|
|
10
|
-
/**
|
|
11
|
-
readonly
|
|
10
|
+
/** A score the read answers as a field, a vector search's or a text search's; a temporary one leaves again. */
|
|
11
|
+
readonly score?: {
|
|
12
|
+
readonly field: string;
|
|
13
|
+
readonly meta: 'vectorSearchScore' | 'textScore';
|
|
14
|
+
readonly temporary?: boolean;
|
|
15
|
+
};
|
|
12
16
|
};
|
|
13
17
|
/** Accumulator threaded through `$where` rendering: the relation lookups it needs, and their temp fields. */
|
|
14
18
|
type RelationLookups = {
|
|
15
19
|
readonly stages: MongoAggregationPipelineEntry<Document>[];
|
|
16
20
|
readonly temps: string[];
|
|
17
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* A text-search config as MongoDB names the language: the same word for each language both know, and
|
|
24
|
+
* `'none'` for the no-stemming parser Postgres calls `'simple'`. {@link textConfigOf} reads one back.
|
|
25
|
+
*/
|
|
26
|
+
export declare function textLanguage(config: string): string;
|
|
27
|
+
/** A MongoDB language as the text-search config it is, the inverse of {@link textLanguage}. */
|
|
28
|
+
export declare function textConfigOf(language: string): string;
|
|
18
29
|
/** Default {@link DialectFeatures} for MongoDB. */
|
|
19
30
|
export declare const mongoDialectFeatures: DialectFeatures;
|
|
20
31
|
export declare class MongoDialect extends AbstractDialect {
|
|
@@ -220,6 +231,8 @@ export declare class MongoDialect extends AbstractDialect {
|
|
|
220
231
|
normalizeIds<E extends Document>(meta: EntityMeta<E>, docs: Document[]): E[];
|
|
221
232
|
/** `doc` is the wire shape - `_id`, stored names, `ObjectId`s - and what comes back is the code's. */
|
|
222
233
|
normalizeId<E extends Document>(meta: EntityMeta<E>, doc: Document | undefined): E | undefined;
|
|
234
|
+
/** An aggregate's rows with each 64-bit integer decoded as a document's is: a `bigint` only where the column reads a `BigInt` field. */
|
|
235
|
+
normalizeAggregateRows<E extends Document, const G extends QueryGroupMap<E>, const A extends QueryAggMap<E>, R extends Document>(entity: Type<E>, q: QueryAggregate<E, G, A>, rows: R[]): R[];
|
|
223
236
|
/**
|
|
224
237
|
* A key as MongoDB stores it: a 24-hex string as an `ObjectId`, so a write matches the filter looking for
|
|
225
238
|
* it, and anything else as given. Only 24-hex, not any 12-byte string. Arrays convert element-wise.
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { ObjectId } from 'mongodb';
|
|
2
2
|
import { AbstractDialect } from '../dialect/abstractDialect.js';
|
|
3
3
|
import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, sortCountField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
|
|
4
|
-
import { groupPathField, resolveGroupJoins, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
|
|
4
|
+
import { aggregateColumnField, groupPathField, resolveGroupJoins, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
|
|
5
5
|
import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
|
|
6
6
|
import { COUNT_RESULT_KEY } from '../type/query.js';
|
|
7
7
|
import { QueryRaw } from '../type/queryRaw.js';
|
|
8
|
-
import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, rankedTextSearch, someKey, targetKeyColumns, } from '../util/index.js';
|
|
8
|
+
import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, rankedTextSearch, someKey, targetKeyColumns, textSortOf, } from '../util/index.js';
|
|
9
|
+
import { decodeBigIntsExcept } from '../util/wideNumber.js';
|
|
9
10
|
/** A scalar field's operator as the aggregation operator computing it. */
|
|
10
11
|
const MONGO_ARITHMETIC = { $inc: '$add', $mul: '$multiply' };
|
|
12
|
+
/**
|
|
13
|
+
* A text-search config as MongoDB names the language: the same word for each language both know, and
|
|
14
|
+
* `'none'` for the no-stemming parser Postgres calls `'simple'`. {@link textConfigOf} reads one back.
|
|
15
|
+
*/
|
|
16
|
+
export function textLanguage(config) {
|
|
17
|
+
return config === 'simple' ? 'none' : config;
|
|
18
|
+
}
|
|
19
|
+
/** A MongoDB language as the text-search config it is, the inverse of {@link textLanguage}. */
|
|
20
|
+
export function textConfigOf(language) {
|
|
21
|
+
return language === 'none' ? 'simple' : language;
|
|
22
|
+
}
|
|
11
23
|
/** Default {@link DialectFeatures} for MongoDB. */
|
|
12
24
|
export const mongoDialectFeatures = {
|
|
13
25
|
ifNotExists: false,
|
|
@@ -99,7 +111,8 @@ export class MongoDialect extends AbstractDialect {
|
|
|
99
111
|
else if (key === '$text') {
|
|
100
112
|
// MongoDB's text index declares which fields it covers, so `$fields` cannot narrow the search
|
|
101
113
|
// the way it does elsewhere - the same shape as `$distance` being index-defined here.
|
|
102
|
-
|
|
114
|
+
const { $value, $config } = val;
|
|
115
|
+
filter['$text'] = { $search: $value, ...($config && { $language: textLanguage($config) }) };
|
|
103
116
|
}
|
|
104
117
|
else if (meta.relations[key]) {
|
|
105
118
|
this.assertNoRaw(val);
|
|
@@ -477,7 +490,8 @@ export class MongoDialect extends AbstractDialect {
|
|
|
477
490
|
if (path) {
|
|
478
491
|
throw new TypeError(`$sort by $text is only supported on the queried entity, not on relation '${path.slice(0, -1)}'`);
|
|
479
492
|
}
|
|
480
|
-
|
|
493
|
+
const { order, project } = textSortOf(sort);
|
|
494
|
+
out[project ?? TEXT_SCORE_ALIAS] = sortDirection(order);
|
|
481
495
|
continue;
|
|
482
496
|
}
|
|
483
497
|
if (!relation) {
|
|
@@ -679,11 +693,14 @@ export class MongoDialect extends AbstractDialect {
|
|
|
679
693
|
return this.columnOf(meta, key.slice(0, dot)) + key.slice(dot);
|
|
680
694
|
}
|
|
681
695
|
aggregationPipeline(entity, q, opts) {
|
|
696
|
+
// Sorted as a field, which goes either way where a `$meta` sort only descends.
|
|
697
|
+
const text = textSortOf(q.$sort);
|
|
682
698
|
return [
|
|
683
699
|
...this.matchStages(entity, q.$where, opts, this.aggregateKeys(entity, q)),
|
|
684
700
|
...this.readStages(entity, q, {
|
|
685
701
|
sort: this.sort(entity, q),
|
|
686
702
|
pager: this.pagerStages(q),
|
|
703
|
+
score: text && { field: text.project ?? TEXT_SCORE_ALIAS, meta: 'textScore', temporary: !text.project },
|
|
687
704
|
}),
|
|
688
705
|
];
|
|
689
706
|
}
|
|
@@ -709,10 +726,17 @@ export class MongoDialect extends AbstractDialect {
|
|
|
709
726
|
const related = this.relationReadStages(entity, q);
|
|
710
727
|
const sort = hasKeys(extra.sort) ? [{ $sort: extra.sort }] : [];
|
|
711
728
|
const pager = extra.pager ?? [];
|
|
712
|
-
//
|
|
713
|
-
//
|
|
729
|
+
// The score becomes a real field before anything reads it, so the lookups, the sort and the projection
|
|
730
|
+
// that follow treat it like any other; merged into the query's own projection rather than standing in
|
|
731
|
+
// for one, since a query that asked for no columns wants the whole document as well.
|
|
732
|
+
const { score } = extra;
|
|
733
|
+
if (score && !score.temporary) {
|
|
734
|
+
this.assertProjectable(meta, score.field);
|
|
735
|
+
}
|
|
736
|
+
const scored = score ? [{ $addFields: { [score.field]: { $meta: score.meta } } }] : [];
|
|
737
|
+
const unscored = score?.temporary ? [{ $unset: [score.field] }] : [];
|
|
714
738
|
const projection = this.pipelineProjection(entity, q);
|
|
715
|
-
const projected = projection ? { ...projection,
|
|
739
|
+
const projected = projection && score ? { ...projection, [score.field]: 1 } : projection;
|
|
716
740
|
const project = projected ? [{ $project: projected }] : [];
|
|
717
741
|
// A `$lookup` the ordering asked for puts a field on the document the caller never requested,
|
|
718
742
|
// which is the one way this differs from a SQL join. Taken back out once the `$sort` that needed
|
|
@@ -734,7 +758,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
734
758
|
// ordering and the page have to run after it to address the set the caller actually receives.
|
|
735
759
|
const dedup = q.$distinct ? this.distinctStages(projected) : [];
|
|
736
760
|
if (dedup.length) {
|
|
737
|
-
return [...lookups, ...related, ...project, ...dedup, ...sort, ...pager];
|
|
761
|
+
return [...scored, ...lookups, ...related, ...project, ...dedup, ...sort, ...pager, ...unscored];
|
|
738
762
|
}
|
|
739
763
|
// A `$required` relation drops parents when it unwinds, and an ordering may read a field only a
|
|
740
764
|
// lookup produces: either one puts the lookups first, as an INNER JOIN does. Otherwise paging
|
|
@@ -742,10 +766,12 @@ export class MongoDialect extends AbstractDialect {
|
|
|
742
766
|
const lookupsFirst = this.sortsRelations(entity, q.$sort) ||
|
|
743
767
|
lookups.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
|
|
744
768
|
return [
|
|
769
|
+
...scored,
|
|
745
770
|
...(lookupsFirst ? [...lookups, ...sort, ...pager] : [...sort, ...pager, ...lookups]),
|
|
746
771
|
...related,
|
|
747
772
|
...unset,
|
|
748
773
|
...project,
|
|
774
|
+
...unscored,
|
|
749
775
|
];
|
|
750
776
|
}
|
|
751
777
|
/**
|
|
@@ -894,6 +920,9 @@ export class MongoDialect extends AbstractDialect {
|
|
|
894
920
|
res[key] = this.fromWireId(res[key]);
|
|
895
921
|
}
|
|
896
922
|
}
|
|
923
|
+
// A 64-bit integer, which the pool reads as a `bigint`: kept for a `BigInt` field, and elsewhere the
|
|
924
|
+
// number it is where exact and its exact text past 2^53, as every SQL driver decodes one.
|
|
925
|
+
decodeBigIntsExcept(res, (key) => meta.fields[key]?.type === BigInt);
|
|
897
926
|
const relKeys = getKeys(meta.relations).filter((key) => res[key]);
|
|
898
927
|
for (const relKey of relKeys) {
|
|
899
928
|
const relMeta = getMeta(relationOf(meta, relKey).entity());
|
|
@@ -903,6 +932,18 @@ export class MongoDialect extends AbstractDialect {
|
|
|
903
932
|
}
|
|
904
933
|
return res;
|
|
905
934
|
}
|
|
935
|
+
/** An aggregate's rows with each 64-bit integer decoded as a document's is: a `bigint` only where the column reads a `BigInt` field. */
|
|
936
|
+
normalizeAggregateRows(entity, q, rows) {
|
|
937
|
+
const meta = getMeta(entity);
|
|
938
|
+
const { joins } = resolveGroupJoins(meta, q);
|
|
939
|
+
const exact = new Set(parseGroupMap(q.$group, q.$select)
|
|
940
|
+
.filter((entry) => aggregateColumnField(meta, joins, entry)?.field?.type === BigInt)
|
|
941
|
+
.map((entry) => entry.alias));
|
|
942
|
+
for (const row of rows) {
|
|
943
|
+
decodeBigIntsExcept(row, (key) => exact.has(key));
|
|
944
|
+
}
|
|
945
|
+
return rows;
|
|
946
|
+
}
|
|
906
947
|
/**
|
|
907
948
|
* A key as MongoDB stores it: a 24-hex string as an `ObjectId`, so a write matches the filter looking for
|
|
908
949
|
* it, and anything else as given. Only 24-hex, not any 12-byte string. Arrays convert element-wise.
|