velocious 1.0.652 → 1.0.653

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/README.md CHANGED
@@ -1683,9 +1683,20 @@ const tasks = await Task.all().toArray()
1683
1683
 
1684
1684
  ### Filtering
1685
1685
 
1686
+ Use `{in: [...]}` for explicit null-aware membership. Mixed lists match either
1687
+ IN members or SQL NULL, grouped locally so sibling and chained filters still
1688
+ apply. Null-only lists use IS NULL; empty lists match nothing. Direct arrays keep
1689
+ their existing driver-specific behavior, including string-null quoting and
1690
+ column collation. See [Query filtering](docs/query-filtering.md) for
1691
+ validation, normalization, negation, relationships and raw-query boundaries.
1692
+
1686
1693
  ```js
1687
1694
  const tasks = await Task.where({status: "open"}).toArray()
1688
1695
 
1696
+ const nullOrManualTasks = await Task
1697
+ .where({projectId: project.id(), description: {in: [null, "manual"]}})
1698
+ .toArray()
1699
+
1689
1700
  const tasksForActiveProjects = await Task.where({
1690
1701
  project: {projectDetail: {isActive: true}}
1691
1702
  }).toArray()
@@ -1468,7 +1468,9 @@ function splitWhereHash({hash, modelClass}) {
1468
1468
  const isNested = isPlainObject(value)
1469
1469
  const relationship = getRelationshipByName(modelClass, key)
1470
1470
 
1471
- if (isNested) {
1471
+ if (isNested && !relationship && resolveColumnName(modelClass, key)) {
1472
+ resolvedHash[key] = value
1473
+ } else if (isNested) {
1472
1474
  if (relationship) {
1473
1475
  const rawTargetModelClass = relationship.getTargetModelClass()
1474
1476
  if (!rawTargetModelClass) {
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import WhereBase from "./where-base.js"
4
+ import WhereIn from "./where-in.js"
4
5
 
5
6
  /**
6
7
  * VelociousDatabaseQueryWhereHash class.
@@ -50,7 +51,17 @@ export default class VelociousDatabaseQueryWhereHash extends WhereBase {
50
51
  if (index > 0) sql += " AND "
51
52
  sql += "1=0"
52
53
  } else if (!Array.isArray(whereValue) && whereValue !== null && typeof whereValue == "object") {
53
- sql += this._whereSQLFromHash(whereValue, whereKey, index)
54
+ if (tableName && "in" in whereValue) {
55
+ if (index > 0) sql += " AND "
56
+
57
+ sql += WhereIn.toSql({
58
+ columnSql: `${options.quoteTableName(tableName)}.${options.quoteColumnName(whereKey)}`,
59
+ options,
60
+ values: WhereIn.values(whereValue)
61
+ })
62
+ } else {
63
+ sql += this._whereSQLFromHash(whereValue, whereKey, index)
64
+ }
54
65
  } else {
55
66
  if (index > 0) sql += " AND "
56
67
 
@@ -0,0 +1,48 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {string | number | boolean | null} InValue */
4
+
5
+ export default class WhereIn {
6
+ /**
7
+ * Validates an explicit membership descriptor at a column boundary.
8
+ * @param {unknown} condition - Untrusted column condition, narrowed before use.
9
+ * @returns {InValue[]} - Validated members in a new array.
10
+ */
11
+ static values(condition) {
12
+ if (condition === null || typeof condition !== "object" ||
13
+ !Object.hasOwn(condition, "in") || !("in" in condition) ||
14
+ Reflect.ownKeys(condition).length !== 1 || !Array.isArray(condition.in)) {
15
+ throw new Error("Invalid IN condition: expected an object with only an own 'in' array")
16
+ }
17
+
18
+ /** @type {InValue[]} */
19
+ const values = []
20
+
21
+ for (const value of condition.in) {
22
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" &&
23
+ !(typeof value === "number" && Number.isFinite(value))) {
24
+ throw new Error("Invalid IN condition: members must be strings, finite numbers, booleans or null")
25
+ }
26
+
27
+ values.push(value)
28
+ }
29
+
30
+ return values
31
+ }
32
+
33
+ /**
34
+ * Renders membership without letting its null branch escape sibling filters.
35
+ * @param {{columnSql: string, inColumnSql?: string, values: InValue[], options: import("../query-parser/options.js").default}} args - Quoted column operands, normalized members and driver quoting.
36
+ * @returns {string} - Complete membership predicate.
37
+ */
38
+ static toSql({columnSql, inColumnSql = columnSql, values, options}) {
39
+ const nonNullValues = values.filter((value) => value !== null)
40
+ const includesNull = values.includes(null)
41
+
42
+ if (nonNullValues.length === 0) return includesNull ? `${columnSql} IS NULL` : "1=0"
43
+
44
+ const membershipSql = `${inColumnSql} IN (${nonNullValues.map((value) => options.quote(value)).join(", ")})`
45
+
46
+ return includesNull ? `(${membershipSql} OR ${columnSql} IS NULL)` : membershipSql
47
+ }
48
+ }
@@ -3,6 +3,7 @@
3
3
  import * as inflection from "inflection"
4
4
  import {isPlainObject} from "is-plain-object"
5
5
  import WhereBase from "./where-base.js"
6
+ import WhereIn from "./where-in.js"
6
7
 
7
8
  /**
8
9
  * No match.
@@ -378,6 +379,34 @@ export default class VelociousDatabaseQueryWhereModelClassHash extends WhereBase
378
379
  return normalized
379
380
  }
380
381
 
382
+ /**
383
+ * Normalizes explicit membership through the model's column metadata.
384
+ * @param {{modelClass: typeof import("../record/index.js").default, columnName: string, tableName?: string, condition: unknown}} args - Resolved column and untrusted membership descriptor.
385
+ * @returns {string} - Complete column membership predicate.
386
+ */
387
+ _whereSQLFromInCondition({modelClass, columnName, tableName, condition}) {
388
+ const options = this.getOptions()
389
+ const values = WhereIn.values(condition)
390
+ const normalizedValues = this._normalizeSqliteBooleanValue({columnName, modelClass, value: values})
391
+ /** @type {import("./where-in.js").InValue[] | typeof NO_MATCH} */
392
+ const typedValues = this._normalizeValueForColumnType({columnName, modelClass, value: normalizedValues})
393
+
394
+ if (typedValues === NO_MATCH) return "1=0"
395
+
396
+ const columnSql = tableName
397
+ ? `${options.quoteTableName(tableName)}.${options.quoteColumnName(columnName)}`
398
+ : options.quoteColumnName(columnName)
399
+ const columnType = modelClass.getColumnTypeByName(columnName)
400
+ const castText = this.getQuery().driver.getType() === "mssql" && columnType?.toLowerCase() === "text"
401
+
402
+ return WhereIn.toSql({
403
+ columnSql,
404
+ inColumnSql: castText ? `CAST(${columnSql} AS NVARCHAR(MAX))` : columnSql,
405
+ options,
406
+ values: typedValues
407
+ })
408
+ }
409
+
381
410
  /**
382
411
  * Runs where sqlfrom hash.
383
412
  * @param {WhereHash} hash - Hash.
@@ -400,7 +429,11 @@ export default class VelociousDatabaseQueryWhereModelClassHash extends WhereBase
400
429
  : null
401
430
  const resolvedColumnName = this._resolveColumnName(modelClass, whereKey)
402
431
 
403
- if (relationship && tuples) {
432
+ if (resolvedColumnName && !relationship && isPlainObject(whereValue)) {
433
+ if (index > 0) sql += " AND "
434
+
435
+ sql += this._whereSQLFromInCondition({columnName: resolvedColumnName, condition: whereValue, modelClass, tableName})
436
+ } else if (relationship && tuples) {
404
437
  if (index > 0) sql += " AND "
405
438
 
406
439
  const rawTargetModelClass = relationship.getTargetModelClass()