@atscript/db-mysql 0.1.167 → 0.1.169

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). */
@@ -673,6 +675,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
673
675
  _replaceColumnsMemo;
674
676
  _fulltextMemo;
675
677
  _searchIndexesMemo;
678
+ _incrementPkMemo;
676
679
  /**
677
680
  * Schema name for INFORMATION_SCHEMA queries — `@db.schema` of the bound
678
681
  * table, or `null` (→ `DATABASE()`, the pool's database) when the table
@@ -811,6 +814,18 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
811
814
  supportsAggregateExpressions() {
812
815
  return true;
813
816
  }
817
+ /** Conditional aggregates (`$where` on an aggregate `$select` entry). */
818
+ supportsConditionalAggregates() {
819
+ return true;
820
+ }
821
+ /** `countDistinct` over several fields. */
822
+ supportsDistinctFieldLists() {
823
+ return true;
824
+ }
825
+ /** Two-level aggregates (`$from`): the inner level is a derived table. */
826
+ supportsNestedAggregates() {
827
+ return true;
828
+ }
814
829
  /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
815
830
  supportsNullsPlacement() {
816
831
  return true;
@@ -980,7 +995,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
980
995
  const { sql, params } = buildInsert(this.resolveTableName(), data);
981
996
  this._log(sql, params);
982
997
  const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
983
- return { insertedId: this._resolveInsertedId(data, result.insertId) };
998
+ return { insertedId: this._insertedIdOf(data, result.insertId, result.insertId) };
984
999
  }
985
1000
  async insertMany(data) {
986
1001
  if (data.length === 0) return {
@@ -1058,9 +1073,25 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1058
1073
  }
1059
1074
  /** Physical column of the single-column AUTO_INCREMENT primary key, if the table has one. */
1060
1075
  _autoIncrementPk() {
1061
- const pks = this._table.primaryKeys;
1062
- if (pks.length !== 1 || !this._incrementFields.has(pks[0])) return void 0;
1063
- return this._table.getMetadata().physicalPath(pks[0]);
1076
+ const { column, composite } = this._incrementPk();
1077
+ return composite ? void 0 : column;
1078
+ }
1079
+ /**
1080
+ * Physical column of the primary key's `@db.default.increment` part (if any)
1081
+ * and whether the key is composite — computed once per table.
1082
+ */
1083
+ _incrementPk() {
1084
+ const src = this._table.primaryKeys;
1085
+ let memo = this._incrementPkMemo;
1086
+ if (memo?.src !== src) {
1087
+ const part = src.find((pk) => this._incrementFields.has(pk));
1088
+ memo = this._incrementPkMemo = {
1089
+ src,
1090
+ column: part === void 0 ? void 0 : this._table.physicalPath(part),
1091
+ composite: src.length > 1
1092
+ };
1093
+ }
1094
+ return memo;
1064
1095
  }
1065
1096
  /**
1066
1097
  * Splits `rows` into runs that each map to ONE statement with a derivable
@@ -1150,11 +1181,30 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1150
1181
  async _groupInsertedIds(group, insertId, step) {
1151
1182
  const first = Number(insertId);
1152
1183
  const stride = group.generated && group.rows.length > 1 && first > 0 ? await step() : 1;
1184
+ const partId = group.rows.length === 1 ? insertId : void 0;
1153
1185
  return group.rows.map((row, i) => {
1154
1186
  const generatedId = first > 0 ? first + i * stride : 0;
1155
- return group.generated ? generatedId : this._resolveInsertedId(row, generatedId);
1187
+ return group.generated ? generatedId : this._insertedIdOf(row, generatedId, partId);
1156
1188
  });
1157
1189
  }
1190
+ /**
1191
+ * The inserted id of `row` (see `_resolveInsertedId`). For a COMPOSITE key
1192
+ * with an AUTO_INCREMENT part, `partId` is what the statement generated for
1193
+ * that part — pass it only when exact (a ONE-row INSERT); a row carrying the
1194
+ * part keeps its own value. An explicit `0` in the part counts as absent:
1195
+ * MySQL generates a value for it (unless `NO_AUTO_VALUE_ON_ZERO`), so only
1196
+ * `partId` knows it — without that the id is `undefined`, never a wrong `0`.
1197
+ */
1198
+ _insertedIdOf(row, generatedId, partId) {
1199
+ const { column, composite } = this._incrementPk();
1200
+ if (!composite || column === void 0) return this._resolveInsertedId(row, generatedId);
1201
+ const source = isZero(row[column]) ? {
1202
+ ...row,
1203
+ [column]: void 0
1204
+ } : row;
1205
+ const part = Number(partId) > 0 ? { [column]: partId } : void 0;
1206
+ return this._resolveInsertedId(source, generatedId, part);
1207
+ }
1158
1208
  supportsInsertIgnore() {
1159
1209
  return true;
1160
1210
  }
@@ -1421,7 +1471,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1421
1471
  const search = (0, _atscript_db_agg.resolveAggregateSearch)(query.controls);
1422
1472
  const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
1423
1473
  const tableName = search ? this.resolveTableName() : this._readSource(query, "aggregate");
1424
- for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1474
+ for (const select of [query.controls.$select, query.controls.$from?.$select]) for (const bucket of select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1425
1475
  if (query.controls.$count) {
1426
1476
  const { sql, params } = buildAggregateCount(tableName, where, query.controls);
1427
1477
  this._log(sql, params);
@@ -1925,6 +1975,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1925
1975
  }
1926
1976
  async renameTable(oldName) {
1927
1977
  const newName = this.resolveTableName();
1978
+ if (!await this.getObjectKind(oldName) && await this.getObjectKind(this._table.tableName)) return;
1928
1979
  const ddl = `RENAME TABLE ${quoteTableName(oldName)} TO ${quoteTableName(newName)}`;
1929
1980
  this._log(ddl);
1930
1981
  await this._exec().exec(ddl);
@@ -1940,6 +1991,9 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1940
1991
  async _syncIndexes(dryRun) {
1941
1992
  const tableName = this._table.tableName;
1942
1993
  const schema = this._schema;
1994
+ if (!dryRun) {
1995
+ 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`);
1996
+ }
1943
1997
  const bytesPerChar = mysqlBytesPerChar(this._charset);
1944
1998
  const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
1945
1999
  const mappedType = (column) => {
@@ -2123,13 +2177,16 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
2123
2177
  const { text: textFields, integer: integerFields } = (0, _atscript_db.splitFulltextFields)(fulltextIndex);
2124
2178
  const where = buildWhere(query.filter);
2125
2179
  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)`;
2180
+ const matchCols = textFields.map((f) => qi(f.name)).join(", ");
2181
+ const all = fulltextIndex.match === "all";
2182
+ const matchClause = `MATCH(${matchCols}) AGAINST(? IN ${all ? "BOOLEAN" : "NATURAL LANGUAGE"} MODE)`;
2183
+ const term = all ? mysqlRequireEveryTerm(text) : text;
2127
2184
  const rank = (0, _atscript_db_sql_tools.searchRankOrder)(mysqlDialect, {
2128
2185
  exactEqualities: n === void 0 ? [] : integerFields.map((f) => `${qi(f.name)} = ?`),
2129
2186
  n,
2130
2187
  textRank: textFields.length > 0 ? {
2131
2188
  sql: `${matchClause} DESC`,
2132
- params: [text]
2189
+ params: [term]
2133
2190
  } : void 0,
2134
2191
  pkColumns: this._table.primaryKeys.map((key) => this._table.physicalPath(key))
2135
2192
  });
@@ -2138,10 +2195,10 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
2138
2195
  params: [...where.params, ...params],
2139
2196
  rank
2140
2197
  });
2141
- if (n === void 0) return textFields.length === 0 ? withWhere(_atscript_db_sql_tools.EMPTY_OR.sql, []) : withWhere(matchClause, [text]);
2198
+ if (n === void 0) return textFields.length === 0 ? withWhere(_atscript_db_sql_tools.EMPTY_OR.sql, []) : withWhere(matchClause, [term]);
2142
2199
  const keys = this._table.primaryKeys.map((key) => qi(this._table.physicalPath(key)));
2143
2200
  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)]);
2201
+ 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
2202
  return withWhere(or.sql, or.params);
2146
2203
  }
2147
2204
  const table = quoteTableName(this.resolveTableName());
@@ -2150,7 +2207,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
2150
2207
  const params = [];
2151
2208
  if (textFields.length > 0) {
2152
2209
  arms.push(`SELECT ${keyList} FROM ${table} WHERE ${matchClause}`);
2153
- params.push(text);
2210
+ params.push(term);
2154
2211
  }
2155
2212
  for (const f of integerFields) {
2156
2213
  arms.push(`SELECT ${keyList} FROM ${table} WHERE ${qi(f.name)} = ?`);
@@ -2441,6 +2498,37 @@ function similarityToMysqlFn(similarity) {
2441
2498
  function vectorToString(vector) {
2442
2499
  return `[${vector.join(",")}]`;
2443
2500
  }
2501
+ /** InnoDB's default full-text stop words (`INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD`). */
2502
+ 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(" "));
2503
+ /**
2504
+ * A search text as a boolean-mode `AGAINST` term with every word required
2505
+ * (`@db.index.fulltext.match "all"`, since 0.1.168): `+word` per word, a
2506
+ * `"quoted phrase"` stays one required phrase, a trailing `*` keeps its prefix
2507
+ * meaning. The user's own boolean operators (`+ - < > ( ) ~ * " @`) are text,
2508
+ * not syntax. A word InnoDB does not index by default — shorter than 3
2509
+ * characters (`innodb_ft_min_token_size`) or on its default stop-word list —
2510
+ * stays optional: requiring it would match no row.
2511
+ */
2512
+ function mysqlRequireEveryTerm(text) {
2513
+ const parts = [];
2514
+ for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) {
2515
+ const phrase = match[1];
2516
+ if (phrase !== void 0) {
2517
+ const clean = phrase.trim();
2518
+ if (clean) parts.push(`+"${clean}"`);
2519
+ continue;
2520
+ }
2521
+ const word = match[2];
2522
+ const core = word.replace(/\*+$/, "");
2523
+ const pieces = core.split(/[+\-<>()~*"@]+/).filter(Boolean);
2524
+ pieces.forEach((piece, i) => {
2525
+ const star = i === pieces.length - 1 && core.length < word.length ? "*" : "";
2526
+ const required = [...piece].length >= 3 && !INNODB_DEFAULT_STOPWORDS.has(piece.toLowerCase());
2527
+ parts.push(`${required ? "+" : ""}${piece}${star}`);
2528
+ });
2529
+ }
2530
+ return parts.join(" ");
2531
+ }
2444
2532
  //#endregion
2445
2533
  //#region src/mysql2-driver.ts
2446
2534
  const NO_PARAMS = [];
package/dist/index.d.cts CHANGED
@@ -141,6 +141,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
141
141
  private _replaceColumnsMemo?;
142
142
  private _fulltextMemo?;
143
143
  private _searchIndexesMemo?;
144
+ private _incrementPkMemo?;
144
145
  /**
145
146
  * Schema name for INFORMATION_SCHEMA queries — `@db.schema` of the bound
146
147
  * table, or `null` (→ `DATABASE()`, the pool's database) when the table
@@ -201,6 +202,12 @@ declare class MysqlAdapter extends BaseDbAdapter {
201
202
  aggregateFns(): ReadonlySet<AggregateFn>;
202
203
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
203
204
  supportsAggregateExpressions(): boolean;
205
+ /** Conditional aggregates (`$where` on an aggregate `$select` entry). */
206
+ supportsConditionalAggregates(): boolean;
207
+ /** `countDistinct` over several fields. */
208
+ supportsDistinctFieldLists(): boolean;
209
+ /** Two-level aggregates (`$from`): the inner level is a derived table. */
210
+ supportsNestedAggregates(): boolean;
204
211
  /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
205
212
  supportsNullsPlacement(): boolean;
206
213
  /**
@@ -254,6 +261,11 @@ declare class MysqlAdapter extends BaseDbAdapter {
254
261
  private _uniformIdGroup;
255
262
  /** Physical column of the single-column AUTO_INCREMENT primary key, if the table has one. */
256
263
  private _autoIncrementPk;
264
+ /**
265
+ * Physical column of the primary key's `@db.default.increment` part (if any)
266
+ * and whether the key is composite — computed once per table.
267
+ */
268
+ private _incrementPk;
257
269
  /**
258
270
  * Splits `rows` into runs that each map to ONE statement with a derivable
259
271
  * id sequence. MySQL reports only the first GENERATED id of a statement, and
@@ -292,6 +304,15 @@ declare class MysqlAdapter extends BaseDbAdapter {
292
304
  * count — mode 2, the 8.0 default), explicit rows keep their own value.
293
305
  */
294
306
  private _groupInsertedIds;
307
+ /**
308
+ * The inserted id of `row` (see `_resolveInsertedId`). For a COMPOSITE key
309
+ * with an AUTO_INCREMENT part, `partId` is what the statement generated for
310
+ * that part — pass it only when exact (a ONE-row INSERT); a row carrying the
311
+ * part keeps its own value. An explicit `0` in the part counts as absent:
312
+ * MySQL generates a value for it (unless `NO_AUTO_VALUE_ON_ZERO`), so only
313
+ * `partId` knows it — without that the id is `undefined`, never a wrong `0`.
314
+ */
315
+ private _insertedIdOf;
295
316
  supportsInsertIgnore(): boolean;
296
317
  /**
297
318
  * Standalone (no caller transaction): per chunk ONE optimistic multi-row
package/dist/index.d.mts CHANGED
@@ -141,6 +141,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
141
141
  private _replaceColumnsMemo?;
142
142
  private _fulltextMemo?;
143
143
  private _searchIndexesMemo?;
144
+ private _incrementPkMemo?;
144
145
  /**
145
146
  * Schema name for INFORMATION_SCHEMA queries — `@db.schema` of the bound
146
147
  * table, or `null` (→ `DATABASE()`, the pool's database) when the table
@@ -201,6 +202,12 @@ declare class MysqlAdapter extends BaseDbAdapter {
201
202
  aggregateFns(): ReadonlySet<AggregateFn>;
202
203
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
203
204
  supportsAggregateExpressions(): boolean;
205
+ /** Conditional aggregates (`$where` on an aggregate `$select` entry). */
206
+ supportsConditionalAggregates(): boolean;
207
+ /** `countDistinct` over several fields. */
208
+ supportsDistinctFieldLists(): boolean;
209
+ /** Two-level aggregates (`$from`): the inner level is a derived table. */
210
+ supportsNestedAggregates(): boolean;
204
211
  /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
205
212
  supportsNullsPlacement(): boolean;
206
213
  /**
@@ -254,6 +261,11 @@ declare class MysqlAdapter extends BaseDbAdapter {
254
261
  private _uniformIdGroup;
255
262
  /** Physical column of the single-column AUTO_INCREMENT primary key, if the table has one. */
256
263
  private _autoIncrementPk;
264
+ /**
265
+ * Physical column of the primary key's `@db.default.increment` part (if any)
266
+ * and whether the key is composite — computed once per table.
267
+ */
268
+ private _incrementPk;
257
269
  /**
258
270
  * Splits `rows` into runs that each map to ONE statement with a derivable
259
271
  * id sequence. MySQL reports only the first GENERATED id of a statement, and
@@ -292,6 +304,15 @@ declare class MysqlAdapter extends BaseDbAdapter {
292
304
  * count — mode 2, the 8.0 default), explicit rows keep their own value.
293
305
  */
294
306
  private _groupInsertedIds;
307
+ /**
308
+ * The inserted id of `row` (see `_resolveInsertedId`). For a COMPOSITE key
309
+ * with an AUTO_INCREMENT part, `partId` is what the statement generated for
310
+ * that part — pass it only when exact (a ONE-row INSERT); a row carrying the
311
+ * part keeps its own value. An explicit `0` in the part counts as absent:
312
+ * MySQL generates a value for it (unless `NO_AUTO_VALUE_ON_ZERO`), so only
313
+ * `partId` knows it — without that the id is `undefined`, never a wrong `0`.
314
+ */
315
+ private _insertedIdOf;
295
316
  supportsInsertIgnore(): boolean;
296
317
  /**
297
318
  * Standalone (no caller transaction): per chunk ONE optimistic multi-row
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). */
@@ -672,6 +674,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
672
674
  _replaceColumnsMemo;
673
675
  _fulltextMemo;
674
676
  _searchIndexesMemo;
677
+ _incrementPkMemo;
675
678
  /**
676
679
  * Schema name for INFORMATION_SCHEMA queries — `@db.schema` of the bound
677
680
  * table, or `null` (→ `DATABASE()`, the pool's database) when the table
@@ -810,6 +813,18 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
810
813
  supportsAggregateExpressions() {
811
814
  return true;
812
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
+ }
813
828
  /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
814
829
  supportsNullsPlacement() {
815
830
  return true;
@@ -979,7 +994,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
979
994
  const { sql, params } = buildInsert$1(this.resolveTableName(), data);
980
995
  this._log(sql, params);
981
996
  const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
982
- return { insertedId: this._resolveInsertedId(data, result.insertId) };
997
+ return { insertedId: this._insertedIdOf(data, result.insertId, result.insertId) };
983
998
  }
984
999
  async insertMany(data) {
985
1000
  if (data.length === 0) return {
@@ -1057,9 +1072,25 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1057
1072
  }
1058
1073
  /** Physical column of the single-column AUTO_INCREMENT primary key, if the table has one. */
1059
1074
  _autoIncrementPk() {
1060
- const pks = this._table.primaryKeys;
1061
- if (pks.length !== 1 || !this._incrementFields.has(pks[0])) return void 0;
1062
- return this._table.getMetadata().physicalPath(pks[0]);
1075
+ const { column, composite } = this._incrementPk();
1076
+ return composite ? void 0 : column;
1077
+ }
1078
+ /**
1079
+ * Physical column of the primary key's `@db.default.increment` part (if any)
1080
+ * and whether the key is composite — computed once per table.
1081
+ */
1082
+ _incrementPk() {
1083
+ const src = this._table.primaryKeys;
1084
+ let memo = this._incrementPkMemo;
1085
+ if (memo?.src !== src) {
1086
+ const part = src.find((pk) => this._incrementFields.has(pk));
1087
+ memo = this._incrementPkMemo = {
1088
+ src,
1089
+ column: part === void 0 ? void 0 : this._table.physicalPath(part),
1090
+ composite: src.length > 1
1091
+ };
1092
+ }
1093
+ return memo;
1063
1094
  }
1064
1095
  /**
1065
1096
  * Splits `rows` into runs that each map to ONE statement with a derivable
@@ -1149,11 +1180,30 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1149
1180
  async _groupInsertedIds(group, insertId, step) {
1150
1181
  const first = Number(insertId);
1151
1182
  const stride = group.generated && group.rows.length > 1 && first > 0 ? await step() : 1;
1183
+ const partId = group.rows.length === 1 ? insertId : void 0;
1152
1184
  return group.rows.map((row, i) => {
1153
1185
  const generatedId = first > 0 ? first + i * stride : 0;
1154
- return group.generated ? generatedId : this._resolveInsertedId(row, generatedId);
1186
+ return group.generated ? generatedId : this._insertedIdOf(row, generatedId, partId);
1155
1187
  });
1156
1188
  }
1189
+ /**
1190
+ * The inserted id of `row` (see `_resolveInsertedId`). For a COMPOSITE key
1191
+ * with an AUTO_INCREMENT part, `partId` is what the statement generated for
1192
+ * that part — pass it only when exact (a ONE-row INSERT); a row carrying the
1193
+ * part keeps its own value. An explicit `0` in the part counts as absent:
1194
+ * MySQL generates a value for it (unless `NO_AUTO_VALUE_ON_ZERO`), so only
1195
+ * `partId` knows it — without that the id is `undefined`, never a wrong `0`.
1196
+ */
1197
+ _insertedIdOf(row, generatedId, partId) {
1198
+ const { column, composite } = this._incrementPk();
1199
+ if (!composite || column === void 0) return this._resolveInsertedId(row, generatedId);
1200
+ const source = isZero(row[column]) ? {
1201
+ ...row,
1202
+ [column]: void 0
1203
+ } : row;
1204
+ const part = Number(partId) > 0 ? { [column]: partId } : void 0;
1205
+ return this._resolveInsertedId(source, generatedId, part);
1206
+ }
1157
1207
  supportsInsertIgnore() {
1158
1208
  return true;
1159
1209
  }
@@ -1420,7 +1470,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1420
1470
  const search = resolveAggregateSearch(query.controls);
1421
1471
  const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
1422
1472
  const tableName = search ? this.resolveTableName() : this._readSource(query, "aggregate");
1423
- for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1473
+ for (const select of [query.controls.$select, query.controls.$from?.$select]) for (const bucket of select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1424
1474
  if (query.controls.$count) {
1425
1475
  const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
1426
1476
  this._log(sql, params);
@@ -1924,6 +1974,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1924
1974
  }
1925
1975
  async renameTable(oldName) {
1926
1976
  const newName = this.resolveTableName();
1977
+ if (!await this.getObjectKind(oldName) && await this.getObjectKind(this._table.tableName)) return;
1927
1978
  const ddl = `RENAME TABLE ${quoteTableName(oldName)} TO ${quoteTableName(newName)}`;
1928
1979
  this._log(ddl);
1929
1980
  await this._exec().exec(ddl);
@@ -1939,6 +1990,9 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1939
1990
  async _syncIndexes(dryRun) {
1940
1991
  const tableName = this._table.tableName;
1941
1992
  const schema = this._schema;
1993
+ if (!dryRun) {
1994
+ 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`);
1995
+ }
1942
1996
  const bytesPerChar = mysqlBytesPerChar(this._charset);
1943
1997
  const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
1944
1998
  const mappedType = (column) => {
@@ -2122,13 +2176,16 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2122
2176
  const { text: textFields, integer: integerFields } = splitFulltextFields(fulltextIndex);
2123
2177
  const where = buildWhere(query.filter);
2124
2178
  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)`;
2179
+ const matchCols = textFields.map((f) => qi(f.name)).join(", ");
2180
+ const all = fulltextIndex.match === "all";
2181
+ const matchClause = `MATCH(${matchCols}) AGAINST(? IN ${all ? "BOOLEAN" : "NATURAL LANGUAGE"} MODE)`;
2182
+ const term = all ? mysqlRequireEveryTerm(text) : text;
2126
2183
  const rank = searchRankOrder(mysqlDialect, {
2127
2184
  exactEqualities: n === void 0 ? [] : integerFields.map((f) => `${qi(f.name)} = ?`),
2128
2185
  n,
2129
2186
  textRank: textFields.length > 0 ? {
2130
2187
  sql: `${matchClause} DESC`,
2131
- params: [text]
2188
+ params: [term]
2132
2189
  } : void 0,
2133
2190
  pkColumns: this._table.primaryKeys.map((key) => this._table.physicalPath(key))
2134
2191
  });
@@ -2137,10 +2194,10 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2137
2194
  params: [...where.params, ...params],
2138
2195
  rank
2139
2196
  });
2140
- if (n === void 0) return textFields.length === 0 ? withWhere(EMPTY_OR.sql, []) : withWhere(matchClause, [text]);
2197
+ if (n === void 0) return textFields.length === 0 ? withWhere(EMPTY_OR.sql, []) : withWhere(matchClause, [term]);
2141
2198
  const keys = this._table.primaryKeys.map((key) => qi(this._table.physicalPath(key)));
2142
2199
  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)]);
2200
+ const or = orFragment([...textFields.length > 0 ? [matchClause] : [], ...integerFields.map((f) => `${qi(f.name)} = ?`)], [...textFields.length > 0 ? [term] : [], ...integerFields.map(() => n)]);
2144
2201
  return withWhere(or.sql, or.params);
2145
2202
  }
2146
2203
  const table = quoteTableName(this.resolveTableName());
@@ -2149,7 +2206,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2149
2206
  const params = [];
2150
2207
  if (textFields.length > 0) {
2151
2208
  arms.push(`SELECT ${keyList} FROM ${table} WHERE ${matchClause}`);
2152
- params.push(text);
2209
+ params.push(term);
2153
2210
  }
2154
2211
  for (const f of integerFields) {
2155
2212
  arms.push(`SELECT ${keyList} FROM ${table} WHERE ${qi(f.name)} = ?`);
@@ -2440,6 +2497,37 @@ function similarityToMysqlFn(similarity) {
2440
2497
  function vectorToString(vector) {
2441
2498
  return `[${vector.join(",")}]`;
2442
2499
  }
2500
+ /** InnoDB's default full-text stop words (`INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD`). */
2501
+ 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(" "));
2502
+ /**
2503
+ * A search text as a boolean-mode `AGAINST` term with every word required
2504
+ * (`@db.index.fulltext.match "all"`, since 0.1.168): `+word` per word, a
2505
+ * `"quoted phrase"` stays one required phrase, a trailing `*` keeps its prefix
2506
+ * meaning. The user's own boolean operators (`+ - < > ( ) ~ * " @`) are text,
2507
+ * not syntax. A word InnoDB does not index by default — shorter than 3
2508
+ * characters (`innodb_ft_min_token_size`) or on its default stop-word list —
2509
+ * stays optional: requiring it would match no row.
2510
+ */
2511
+ function mysqlRequireEveryTerm(text) {
2512
+ const parts = [];
2513
+ for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) {
2514
+ const phrase = match[1];
2515
+ if (phrase !== void 0) {
2516
+ const clean = phrase.trim();
2517
+ if (clean) parts.push(`+"${clean}"`);
2518
+ continue;
2519
+ }
2520
+ const word = match[2];
2521
+ const core = word.replace(/\*+$/, "");
2522
+ const pieces = core.split(/[+\-<>()~*"@]+/).filter(Boolean);
2523
+ pieces.forEach((piece, i) => {
2524
+ const star = i === pieces.length - 1 && core.length < word.length ? "*" : "";
2525
+ const required = [...piece].length >= 3 && !INNODB_DEFAULT_STOPWORDS.has(piece.toLowerCase());
2526
+ parts.push(`${required ? "+" : ""}${piece}${star}`);
2527
+ });
2528
+ }
2529
+ return parts.join(" ");
2530
+ }
2443
2531
  //#endregion
2444
2532
  //#region src/mysql2-driver.ts
2445
2533
  const NO_PARAMS = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mysql",
3
- "version": "0.1.167",
3
+ "version": "0.1.169",
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.169"
45
+ },
43
46
  "devDependencies": {
44
- "@atscript/core": "^0.2.3",
45
- "@atscript/typescript": "^0.2.3",
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.3"
51
+ "unplugin-atscript": "^0.2.4"
49
52
  },
50
53
  "peerDependencies": {
51
- "@atscript/core": "^0.2.3",
52
- "@atscript/typescript": "^0.2.3",
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.167",
56
- "@atscript/db-sql-tools": "^0.1.167"
58
+ "@atscript/db": "^0.1.169"
57
59
  },
58
60
  "peerDependenciesMeta": {
59
61
  "mysql2": {