@atscript/db-mysql 0.1.166 → 0.1.168

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/index.cjs CHANGED
@@ -212,6 +212,8 @@ const mysqlDialect = {
212
212
  },
213
213
  jsonFromText: (quotedCol) => `IF(JSON_VALID(${quotedCol}), ${quotedCol}, JSON_QUOTE(${quotedCol}))`,
214
214
  castDouble: (expr) => `CAST(${expr} AS DOUBLE)`,
215
+ conditionalAggregate: "case",
216
+ countDistinctColumns: (columns, when) => `COUNT(DISTINCT ${columns.map((c) => when ? `CASE WHEN ${when} THEN ${c} END` : c).join(", ")})`,
215
217
  createViewPrefix: "CREATE OR REPLACE VIEW"
216
218
  };
217
219
  /** `JSON_TYPE()` names of JSON numbers (MariaDB reports only INTEGER / DOUBLE). */
@@ -811,6 +813,18 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
811
813
  supportsAggregateExpressions() {
812
814
  return true;
813
815
  }
816
+ /** Conditional aggregates (`$where` on an aggregate `$select` entry). */
817
+ supportsConditionalAggregates() {
818
+ return true;
819
+ }
820
+ /** `countDistinct` over several fields. */
821
+ supportsDistinctFieldLists() {
822
+ return true;
823
+ }
824
+ /** Two-level aggregates (`$from`): the inner level is a derived table. */
825
+ supportsNestedAggregates() {
826
+ return true;
827
+ }
814
828
  /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
815
829
  supportsNullsPlacement() {
816
830
  return true;
@@ -1421,7 +1435,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1421
1435
  const search = (0, _atscript_db_agg.resolveAggregateSearch)(query.controls);
1422
1436
  const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
1423
1437
  const tableName = search ? this.resolveTableName() : this._readSource(query, "aggregate");
1424
- for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1438
+ for (const select of [query.controls.$select, query.controls.$from?.$select]) for (const bucket of select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1425
1439
  if (query.controls.$count) {
1426
1440
  const { sql, params } = buildAggregateCount(tableName, where, query.controls);
1427
1441
  this._log(sql, params);
@@ -1925,6 +1939,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1925
1939
  }
1926
1940
  async renameTable(oldName) {
1927
1941
  const newName = this.resolveTableName();
1942
+ if (!await this.getObjectKind(oldName) && await this.getObjectKind(this._table.tableName)) return;
1928
1943
  const ddl = `RENAME TABLE ${quoteTableName(oldName)} TO ${quoteTableName(newName)}`;
1929
1944
  this._log(ddl);
1930
1945
  await this._exec().exec(ddl);
@@ -1940,6 +1955,9 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1940
1955
  async _syncIndexes(dryRun) {
1941
1956
  const tableName = this._table.tableName;
1942
1957
  const schema = this._schema;
1958
+ if (!dryRun) {
1959
+ for (const index of this._table.indexes.values()) if (index.type === "fulltext" && index.language !== void 0) this.logger.warn(`[mysql] fulltext index "${index.name}": @db.index.fulltext.language "${index.language}" is ignored — MySQL FULLTEXT has no per-index language`);
1960
+ }
1943
1961
  const bytesPerChar = mysqlBytesPerChar(this._charset);
1944
1962
  const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
1945
1963
  const mappedType = (column) => {
@@ -2123,13 +2141,16 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
2123
2141
  const { text: textFields, integer: integerFields } = (0, _atscript_db.splitFulltextFields)(fulltextIndex);
2124
2142
  const where = buildWhere(query.filter);
2125
2143
  const n = integerFields.length > 0 ? (0, _atscript_db.searchTermInteger)(text) : void 0;
2126
- const matchClause = `MATCH(${textFields.map((f) => qi(f.name)).join(", ")}) AGAINST(? IN NATURAL LANGUAGE MODE)`;
2144
+ const matchCols = textFields.map((f) => qi(f.name)).join(", ");
2145
+ const all = fulltextIndex.match === "all";
2146
+ const matchClause = `MATCH(${matchCols}) AGAINST(? IN ${all ? "BOOLEAN" : "NATURAL LANGUAGE"} MODE)`;
2147
+ const term = all ? mysqlRequireEveryTerm(text) : text;
2127
2148
  const rank = (0, _atscript_db_sql_tools.searchRankOrder)(mysqlDialect, {
2128
2149
  exactEqualities: n === void 0 ? [] : integerFields.map((f) => `${qi(f.name)} = ?`),
2129
2150
  n,
2130
2151
  textRank: textFields.length > 0 ? {
2131
2152
  sql: `${matchClause} DESC`,
2132
- params: [text]
2153
+ params: [term]
2133
2154
  } : void 0,
2134
2155
  pkColumns: this._table.primaryKeys.map((key) => this._table.physicalPath(key))
2135
2156
  });
@@ -2138,10 +2159,10 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
2138
2159
  params: [...where.params, ...params],
2139
2160
  rank
2140
2161
  });
2141
- if (n === void 0) return textFields.length === 0 ? withWhere(_atscript_db_sql_tools.EMPTY_OR.sql, []) : withWhere(matchClause, [text]);
2162
+ if (n === void 0) return textFields.length === 0 ? withWhere(_atscript_db_sql_tools.EMPTY_OR.sql, []) : withWhere(matchClause, [term]);
2142
2163
  const keys = this._table.primaryKeys.map((key) => qi(this._table.physicalPath(key)));
2143
2164
  if (keys.length === 0) {
2144
- const or = (0, _atscript_db_sql_tools.orFragment)([...textFields.length > 0 ? [matchClause] : [], ...integerFields.map((f) => `${qi(f.name)} = ?`)], [...textFields.length > 0 ? [text] : [], ...integerFields.map(() => n)]);
2165
+ const or = (0, _atscript_db_sql_tools.orFragment)([...textFields.length > 0 ? [matchClause] : [], ...integerFields.map((f) => `${qi(f.name)} = ?`)], [...textFields.length > 0 ? [term] : [], ...integerFields.map(() => n)]);
2145
2166
  return withWhere(or.sql, or.params);
2146
2167
  }
2147
2168
  const table = quoteTableName(this.resolveTableName());
@@ -2150,7 +2171,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
2150
2171
  const params = [];
2151
2172
  if (textFields.length > 0) {
2152
2173
  arms.push(`SELECT ${keyList} FROM ${table} WHERE ${matchClause}`);
2153
- params.push(text);
2174
+ params.push(term);
2154
2175
  }
2155
2176
  for (const f of integerFields) {
2156
2177
  arms.push(`SELECT ${keyList} FROM ${table} WHERE ${qi(f.name)} = ?`);
@@ -2441,6 +2462,37 @@ function similarityToMysqlFn(similarity) {
2441
2462
  function vectorToString(vector) {
2442
2463
  return `[${vector.join(",")}]`;
2443
2464
  }
2465
+ /** InnoDB's default full-text stop words (`INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD`). */
2466
+ const INNODB_DEFAULT_STOPWORDS = new Set("a about an are as at be by com de en for from how i in is it la of on or that the this to was what when where who will with und www".split(" "));
2467
+ /**
2468
+ * A search text as a boolean-mode `AGAINST` term with every word required
2469
+ * (`@db.index.fulltext.match "all"`, since 0.1.168): `+word` per word, a
2470
+ * `"quoted phrase"` stays one required phrase, a trailing `*` keeps its prefix
2471
+ * meaning. The user's own boolean operators (`+ - < > ( ) ~ * " @`) are text,
2472
+ * not syntax. A word InnoDB does not index by default — shorter than 3
2473
+ * characters (`innodb_ft_min_token_size`) or on its default stop-word list —
2474
+ * stays optional: requiring it would match no row.
2475
+ */
2476
+ function mysqlRequireEveryTerm(text) {
2477
+ const parts = [];
2478
+ for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) {
2479
+ const phrase = match[1];
2480
+ if (phrase !== void 0) {
2481
+ const clean = phrase.trim();
2482
+ if (clean) parts.push(`+"${clean}"`);
2483
+ continue;
2484
+ }
2485
+ const word = match[2];
2486
+ const core = word.replace(/\*+$/, "");
2487
+ const pieces = core.split(/[+\-<>()~*"@]+/).filter(Boolean);
2488
+ pieces.forEach((piece, i) => {
2489
+ const star = i === pieces.length - 1 && core.length < word.length ? "*" : "";
2490
+ const required = [...piece].length >= 3 && !INNODB_DEFAULT_STOPWORDS.has(piece.toLowerCase());
2491
+ parts.push(`${required ? "+" : ""}${piece}${star}`);
2492
+ });
2493
+ }
2494
+ return parts.join(" ");
2495
+ }
2444
2496
  //#endregion
2445
2497
  //#region src/mysql2-driver.ts
2446
2498
  const NO_PARAMS = [];
package/dist/index.d.cts CHANGED
@@ -201,6 +201,12 @@ declare class MysqlAdapter extends BaseDbAdapter {
201
201
  aggregateFns(): ReadonlySet<AggregateFn>;
202
202
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
203
203
  supportsAggregateExpressions(): boolean;
204
+ /** Conditional aggregates (`$where` on an aggregate `$select` entry). */
205
+ supportsConditionalAggregates(): boolean;
206
+ /** `countDistinct` over several fields. */
207
+ supportsDistinctFieldLists(): boolean;
208
+ /** Two-level aggregates (`$from`): the inner level is a derived table. */
209
+ supportsNestedAggregates(): boolean;
204
210
  /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
205
211
  supportsNullsPlacement(): boolean;
206
212
  /**
package/dist/index.d.mts CHANGED
@@ -201,6 +201,12 @@ declare class MysqlAdapter extends BaseDbAdapter {
201
201
  aggregateFns(): ReadonlySet<AggregateFn>;
202
202
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
203
203
  supportsAggregateExpressions(): boolean;
204
+ /** Conditional aggregates (`$where` on an aggregate `$select` entry). */
205
+ supportsConditionalAggregates(): boolean;
206
+ /** `countDistinct` over several fields. */
207
+ supportsDistinctFieldLists(): boolean;
208
+ /** Two-level aggregates (`$from`): the inner level is a derived table. */
209
+ supportsNestedAggregates(): boolean;
204
210
  /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
205
211
  supportsNullsPlacement(): boolean;
206
212
  /**
package/dist/index.mjs CHANGED
@@ -211,6 +211,8 @@ const mysqlDialect = {
211
211
  },
212
212
  jsonFromText: (quotedCol) => `IF(JSON_VALID(${quotedCol}), ${quotedCol}, JSON_QUOTE(${quotedCol}))`,
213
213
  castDouble: (expr) => `CAST(${expr} AS DOUBLE)`,
214
+ conditionalAggregate: "case",
215
+ countDistinctColumns: (columns, when) => `COUNT(DISTINCT ${columns.map((c) => when ? `CASE WHEN ${when} THEN ${c} END` : c).join(", ")})`,
214
216
  createViewPrefix: "CREATE OR REPLACE VIEW"
215
217
  };
216
218
  /** `JSON_TYPE()` names of JSON numbers (MariaDB reports only INTEGER / DOUBLE). */
@@ -810,6 +812,18 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
810
812
  supportsAggregateExpressions() {
811
813
  return true;
812
814
  }
815
+ /** Conditional aggregates (`$where` on an aggregate `$select` entry). */
816
+ supportsConditionalAggregates() {
817
+ return true;
818
+ }
819
+ /** `countDistinct` over several fields. */
820
+ supportsDistinctFieldLists() {
821
+ return true;
822
+ }
823
+ /** Two-level aggregates (`$from`): the inner level is a derived table. */
824
+ supportsNestedAggregates() {
825
+ return true;
826
+ }
813
827
  /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
814
828
  supportsNullsPlacement() {
815
829
  return true;
@@ -1420,7 +1434,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1420
1434
  const search = resolveAggregateSearch(query.controls);
1421
1435
  const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
1422
1436
  const tableName = search ? this.resolveTableName() : this._readSource(query, "aggregate");
1423
- for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1437
+ for (const select of [query.controls.$select, query.controls.$from?.$select]) for (const bucket of select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1424
1438
  if (query.controls.$count) {
1425
1439
  const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
1426
1440
  this._log(sql, params);
@@ -1924,6 +1938,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1924
1938
  }
1925
1939
  async renameTable(oldName) {
1926
1940
  const newName = this.resolveTableName();
1941
+ if (!await this.getObjectKind(oldName) && await this.getObjectKind(this._table.tableName)) return;
1927
1942
  const ddl = `RENAME TABLE ${quoteTableName(oldName)} TO ${quoteTableName(newName)}`;
1928
1943
  this._log(ddl);
1929
1944
  await this._exec().exec(ddl);
@@ -1939,6 +1954,9 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1939
1954
  async _syncIndexes(dryRun) {
1940
1955
  const tableName = this._table.tableName;
1941
1956
  const schema = this._schema;
1957
+ if (!dryRun) {
1958
+ for (const index of this._table.indexes.values()) if (index.type === "fulltext" && index.language !== void 0) this.logger.warn(`[mysql] fulltext index "${index.name}": @db.index.fulltext.language "${index.language}" is ignored — MySQL FULLTEXT has no per-index language`);
1959
+ }
1942
1960
  const bytesPerChar = mysqlBytesPerChar(this._charset);
1943
1961
  const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
1944
1962
  const mappedType = (column) => {
@@ -2122,13 +2140,16 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2122
2140
  const { text: textFields, integer: integerFields } = splitFulltextFields(fulltextIndex);
2123
2141
  const where = buildWhere(query.filter);
2124
2142
  const n = integerFields.length > 0 ? searchTermInteger(text) : void 0;
2125
- const matchClause = `MATCH(${textFields.map((f) => qi(f.name)).join(", ")}) AGAINST(? IN NATURAL LANGUAGE MODE)`;
2143
+ const matchCols = textFields.map((f) => qi(f.name)).join(", ");
2144
+ const all = fulltextIndex.match === "all";
2145
+ const matchClause = `MATCH(${matchCols}) AGAINST(? IN ${all ? "BOOLEAN" : "NATURAL LANGUAGE"} MODE)`;
2146
+ const term = all ? mysqlRequireEveryTerm(text) : text;
2126
2147
  const rank = searchRankOrder(mysqlDialect, {
2127
2148
  exactEqualities: n === void 0 ? [] : integerFields.map((f) => `${qi(f.name)} = ?`),
2128
2149
  n,
2129
2150
  textRank: textFields.length > 0 ? {
2130
2151
  sql: `${matchClause} DESC`,
2131
- params: [text]
2152
+ params: [term]
2132
2153
  } : void 0,
2133
2154
  pkColumns: this._table.primaryKeys.map((key) => this._table.physicalPath(key))
2134
2155
  });
@@ -2137,10 +2158,10 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2137
2158
  params: [...where.params, ...params],
2138
2159
  rank
2139
2160
  });
2140
- if (n === void 0) return textFields.length === 0 ? withWhere(EMPTY_OR.sql, []) : withWhere(matchClause, [text]);
2161
+ if (n === void 0) return textFields.length === 0 ? withWhere(EMPTY_OR.sql, []) : withWhere(matchClause, [term]);
2141
2162
  const keys = this._table.primaryKeys.map((key) => qi(this._table.physicalPath(key)));
2142
2163
  if (keys.length === 0) {
2143
- const or = orFragment([...textFields.length > 0 ? [matchClause] : [], ...integerFields.map((f) => `${qi(f.name)} = ?`)], [...textFields.length > 0 ? [text] : [], ...integerFields.map(() => n)]);
2164
+ const or = orFragment([...textFields.length > 0 ? [matchClause] : [], ...integerFields.map((f) => `${qi(f.name)} = ?`)], [...textFields.length > 0 ? [term] : [], ...integerFields.map(() => n)]);
2144
2165
  return withWhere(or.sql, or.params);
2145
2166
  }
2146
2167
  const table = quoteTableName(this.resolveTableName());
@@ -2149,7 +2170,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2149
2170
  const params = [];
2150
2171
  if (textFields.length > 0) {
2151
2172
  arms.push(`SELECT ${keyList} FROM ${table} WHERE ${matchClause}`);
2152
- params.push(text);
2173
+ params.push(term);
2153
2174
  }
2154
2175
  for (const f of integerFields) {
2155
2176
  arms.push(`SELECT ${keyList} FROM ${table} WHERE ${qi(f.name)} = ?`);
@@ -2440,6 +2461,37 @@ function similarityToMysqlFn(similarity) {
2440
2461
  function vectorToString(vector) {
2441
2462
  return `[${vector.join(",")}]`;
2442
2463
  }
2464
+ /** InnoDB's default full-text stop words (`INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD`). */
2465
+ const INNODB_DEFAULT_STOPWORDS = new Set("a about an are as at be by com de en for from how i in is it la of on or that the this to was what when where who will with und www".split(" "));
2466
+ /**
2467
+ * A search text as a boolean-mode `AGAINST` term with every word required
2468
+ * (`@db.index.fulltext.match "all"`, since 0.1.168): `+word` per word, a
2469
+ * `"quoted phrase"` stays one required phrase, a trailing `*` keeps its prefix
2470
+ * meaning. The user's own boolean operators (`+ - < > ( ) ~ * " @`) are text,
2471
+ * not syntax. A word InnoDB does not index by default — shorter than 3
2472
+ * characters (`innodb_ft_min_token_size`) or on its default stop-word list —
2473
+ * stays optional: requiring it would match no row.
2474
+ */
2475
+ function mysqlRequireEveryTerm(text) {
2476
+ const parts = [];
2477
+ for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) {
2478
+ const phrase = match[1];
2479
+ if (phrase !== void 0) {
2480
+ const clean = phrase.trim();
2481
+ if (clean) parts.push(`+"${clean}"`);
2482
+ continue;
2483
+ }
2484
+ const word = match[2];
2485
+ const core = word.replace(/\*+$/, "");
2486
+ const pieces = core.split(/[+\-<>()~*"@]+/).filter(Boolean);
2487
+ pieces.forEach((piece, i) => {
2488
+ const star = i === pieces.length - 1 && core.length < word.length ? "*" : "";
2489
+ const required = [...piece].length >= 3 && !INNODB_DEFAULT_STOPWORDS.has(piece.toLowerCase());
2490
+ parts.push(`${required ? "+" : ""}${piece}${star}`);
2491
+ });
2492
+ }
2493
+ return parts.join(" ");
2494
+ }
2443
2495
  //#endregion
2444
2496
  //#region src/mysql2-driver.ts
2445
2497
  const NO_PARAMS = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mysql",
3
- "version": "0.1.166",
3
+ "version": "0.1.168",
4
4
  "description": "MySQL adapter for @atscript/db with mysql2 driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -40,20 +40,22 @@
40
40
  "publishConfig": {
41
41
  "access": "public"
42
42
  },
43
+ "dependencies": {
44
+ "@atscript/db-sql-tools": "^0.1.168"
45
+ },
43
46
  "devDependencies": {
44
- "@atscript/core": "^0.2.1",
45
- "@atscript/typescript": "^0.2.1",
46
- "@uniqu/core": "^0.1.15",
47
+ "@atscript/core": "^0.2.4",
48
+ "@atscript/typescript": "^0.2.4",
49
+ "@uniqu/core": "^0.2.0",
47
50
  "mysql2": "^3.11.0",
48
- "unplugin-atscript": "^0.2.1"
51
+ "unplugin-atscript": "^0.2.4"
49
52
  },
50
53
  "peerDependencies": {
51
- "@atscript/core": "^0.2.1",
52
- "@atscript/typescript": "^0.2.1",
53
- "@uniqu/core": "^0.1.15",
54
+ "@atscript/core": "^0.2.4",
55
+ "@atscript/typescript": "^0.2.4",
56
+ "@uniqu/core": "^0.2.0",
54
57
  "mysql2": ">=3.0.0",
55
- "@atscript/db": "^0.1.166",
56
- "@atscript/db-sql-tools": "^0.1.166"
58
+ "@atscript/db": "^0.1.168"
57
59
  },
58
60
  "peerDependenciesMeta": {
59
61
  "mysql2": {