@dbsp/core 1.0.3 → 1.0.4

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.d.ts CHANGED
@@ -2573,15 +2573,20 @@ declare function notExists(relation: string, options?: {
2573
2573
  * Accepts a SubqueryBuilder (must have `.build()`) or any builder
2574
2574
  * exposing `buildIntent(): QueryIntent` (e.g. QueryBuilder).
2575
2575
  *
2576
+ * **Limitation:** correlated subqueries (using `outerRef()` inside the inner WHERE)
2577
+ * are NOT supported and will throw at compile time. For correlated EXISTS over an
2578
+ * FK-declared relation, use `exists('relation', { where: ... outerRef(...) })` instead.
2579
+ *
2576
2580
  * @param subquery - A SubqueryBuilder or any object with buildIntent()
2577
2581
  *
2578
2582
  * @example
2579
- * // EXISTS (SELECT 1 FROM audit_log WHERE audit_log.entity_id = users.id)
2580
- * rawExists(subquery('audit_log').select('id').where(eq('entityId', outerRef('id'))))
2583
+ * // EXISTS (SELECT 1 FROM audit_log WHERE audit_log.entity_type = 'login')
2584
+ * // Uncorrelated: no reference to the outer row — inner filter is a plain value.
2585
+ * rawExists(subquery('audit_log').select('id').where(eq('entityType', 'login')))
2581
2586
  *
2582
2587
  * @example
2583
- * // EXISTS with a full query builder
2584
- * rawExists(orm.select('sessions').where(and(eq('userId', outerRef('id')), gt('expiresAt', new Date()))))
2588
+ * // EXISTS with a full query builder — polymorphic table, no FK to source
2589
+ * rawExists(orm.select('sessions').where(and(eq('status', 'active'), gt('expiresAt', new Date()))))
2585
2590
  */
2586
2591
  declare function rawExists(sq: SubqueryBuilder | {
2587
2592
  buildIntent(): QueryIntent;
@@ -2593,11 +2598,16 @@ declare function rawExists(sq: SubqueryBuilder | {
2593
2598
  * Accepts a SubqueryBuilder (must have `.build()`) or any builder
2594
2599
  * exposing `buildIntent(): QueryIntent` (e.g. QueryBuilder).
2595
2600
  *
2601
+ * **Limitation:** correlated subqueries (using `outerRef()` inside the inner WHERE)
2602
+ * are NOT supported and will throw at compile time. For correlated NOT EXISTS over an
2603
+ * FK-declared relation, use `notExists('relation', { where: ... outerRef(...) })` instead.
2604
+ *
2596
2605
  * @param subquery - A SubqueryBuilder or any object with buildIntent()
2597
2606
  *
2598
2607
  * @example
2599
- * // NOT EXISTS (SELECT 1 FROM bans WHERE bans.user_id = users.id)
2600
- * rawNotExists(subquery('bans').select('id').where(eq('userId', outerRef('id'))))
2608
+ * // NOT EXISTS (SELECT 1 FROM bans WHERE bans.reason = 'spam')
2609
+ * // Uncorrelated: inner filter is a plain value, no reference to the outer row.
2610
+ * rawNotExists(subquery('bans').select('id').where(eq('reason', 'spam')))
2601
2611
  */
2602
2612
  declare function rawNotExists(sq: SubqueryBuilder | {
2603
2613
  buildIntent(): QueryIntent;
package/dist/index.js CHANGED
@@ -2146,6 +2146,7 @@ function plan(intent, model, options = {}) {
2146
2146
  throw new Error(`Unknown table: ${intent.from}`);
2147
2147
  }
2148
2148
  const optimizedWhere = intent.where ? optimizeInToExists(intent.where, intent.from, model) : void 0;
2149
+ const plannedIntent = optimizedWhere !== void 0 && optimizedWhere !== intent.where ? { ...intent, where: optimizedWhere } : intent;
2149
2150
  if (optimizedWhere) {
2150
2151
  processWhere(optimizedWhere, intent.from, model, state, opts, "where");
2151
2152
  }
@@ -2188,7 +2189,13 @@ function plan(intent, model, options = {}) {
2188
2189
  decisions: Object.freeze(state.decisions.slice()),
2189
2190
  warnings: Object.freeze(state.warnings.slice()),
2190
2191
  ctes: Object.freeze(state.ctes.slice()),
2192
+ // intent is ALWAYS the original submitted intent (contract: observable via dump()).
2193
+ // executableIntent carries the optimized WHERE (e.g. IN→EXISTS) when the
2194
+ // optimizer rewrote it, so the adapter compiles the correct SQL from that field.
2195
+ // When no optimization applies, executableIntent is left undefined and the
2196
+ // adapter falls back to intent.
2191
2197
  intent,
2198
+ ...plannedIntent !== intent && { executableIntent: plannedIntent },
2192
2199
  metadata
2193
2200
  };
2194
2201
  return Object.freeze(report);
@@ -2326,11 +2333,15 @@ function isSubquerySelectedColumnNonNullable(existsIntent, sourceTable, model) {
2326
2333
  if (!column) return false;
2327
2334
  return !column.nullable;
2328
2335
  }
2329
- function optimizeInToExists(where, sourceTable, model) {
2336
+ function optimizeInToExists(where, sourceTable, model, negated = false) {
2330
2337
  switch (where.kind) {
2331
2338
  case "in": {
2332
2339
  const inWhere = where;
2333
2340
  if (!inWhere.subquery) return where;
2341
+ const sq = inWhere.subquery;
2342
+ if (sq.limit != null || sq.orderBy?.length || sq.offset != null || sq.groupBy?.length || sq.having != null || sq.distinct || sq.distinctOn?.length || sq.joins?.length || sq.include?.length || sq.batchValuesSource != null || sq.existsWrap || sq.lock != null || sq.select != null && sq.select.type !== "fields") {
2343
+ return where;
2344
+ }
2334
2345
  const subSelect = inWhere.subquery.select;
2335
2346
  if (!subSelect || subSelect.type !== "fields") return where;
2336
2347
  const fields = "fields" in subSelect ? subSelect.fields : void 0;
@@ -2350,18 +2361,24 @@ function optimizeInToExists(where, sourceTable, model) {
2350
2361
  }
2351
2362
  }
2352
2363
  if (!matchedRelation) return where;
2353
- const existsWhere = {
2354
- kind: "exists",
2364
+ const effectiveNegated = negated !== Boolean(inWhere.not);
2365
+ if (effectiveNegated) {
2366
+ const existsIntent = { relation: matchedRelation };
2367
+ if (!isSubquerySelectedColumnNonNullable(existsIntent, sourceTable, model)) {
2368
+ return where;
2369
+ }
2370
+ }
2371
+ const targetKind = inWhere.not ? "notExists" : "exists";
2372
+ return {
2373
+ kind: targetKind,
2355
2374
  relation: matchedRelation,
2356
- // Forward the subquery's inner WHERE conditions
2357
2375
  ...inWhere.subquery.where && { where: inWhere.subquery.where }
2358
2376
  };
2359
- return existsWhere;
2360
2377
  }
2361
2378
  case "and": {
2362
2379
  const andWhere = where;
2363
2380
  const optimized = andWhere.conditions.map(
2364
- (c) => optimizeInToExists(c, sourceTable, model)
2381
+ (c) => optimizeInToExists(c, sourceTable, model, negated)
2365
2382
  );
2366
2383
  if (optimized.every((c, i) => c === andWhere.conditions[i])) return where;
2367
2384
  return { kind: "and", conditions: optimized };
@@ -2369,7 +2386,7 @@ function optimizeInToExists(where, sourceTable, model) {
2369
2386
  case "or": {
2370
2387
  const orWhere = where;
2371
2388
  const optimized = orWhere.conditions.map(
2372
- (c) => optimizeInToExists(c, sourceTable, model)
2389
+ (c) => optimizeInToExists(c, sourceTable, model, negated)
2373
2390
  );
2374
2391
  if (optimized.every((c, i) => c === orWhere.conditions[i])) return where;
2375
2392
  return { kind: "or", conditions: optimized };
@@ -2379,22 +2396,10 @@ function optimizeInToExists(where, sourceTable, model) {
2379
2396
  const optimized = optimizeInToExists(
2380
2397
  notWhere.condition,
2381
2398
  sourceTable,
2382
- model
2399
+ model,
2400
+ !negated
2383
2401
  );
2384
2402
  if (optimized === notWhere.condition) return where;
2385
- if (optimized.kind === "exists") {
2386
- if (!isSubquerySelectedColumnNonNullable(
2387
- optimized,
2388
- sourceTable,
2389
- model
2390
- )) {
2391
- return where;
2392
- }
2393
- return {
2394
- ...optimized,
2395
- kind: "notExists"
2396
- };
2397
- }
2398
2403
  return { kind: "not", condition: optimized };
2399
2404
  }
2400
2405
  default:
@@ -2476,6 +2481,26 @@ function processWhere(where, sourceTable, model, state, opts, intentPath) {
2476
2481
  break;
2477
2482
  case "expression":
2478
2483
  break;
2484
+ // Custom expression — no relation analysis, pass through
2485
+ // Adapter-only kinds: planner records no decisions; the adapter compiles
2486
+ // them directly from the intent. Explicit cases here prevent silent
2487
+ // fallthrough and keep the switch exhaustive.
2488
+ case "rawExists":
2489
+ case "rawNotExists":
2490
+ break;
2491
+ case "subquery":
2492
+ break;
2493
+ case "range":
2494
+ break;
2495
+ case "jsonContains":
2496
+ case "jsonExists":
2497
+ break;
2498
+ default: {
2499
+ const _exhaustive = where;
2500
+ throw new Error(
2501
+ `processWhere: unhandled WhereIntent kind '${_exhaustive.kind}'`
2502
+ );
2503
+ }
2479
2504
  }
2480
2505
  }
2481
2506
  function processRelationFilter(relationPath, sourceTable, model, state, opts, intentPath, nestedWhere, mode) {
@@ -3012,6 +3037,60 @@ function assertCapability(adapter, capability, operation) {
3012
3037
  }
3013
3038
 
3014
3039
  // src/dx/batch-values.ts
3040
+ var MULTIWORD_BASE_TYPES = [
3041
+ "timestamp with time zone",
3042
+ "timestamp without time zone",
3043
+ "time with time zone",
3044
+ "time without time zone",
3045
+ "double precision",
3046
+ "character varying",
3047
+ "bit varying"
3048
+ ];
3049
+ var IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3050
+ function validateTypeName(typeName) {
3051
+ const raw2 = typeName.trim();
3052
+ if (raw2.length === 0) {
3053
+ throw new Error(
3054
+ `batchValues: invalid type name '${typeName}'. Type names must not be empty.`
3055
+ );
3056
+ }
3057
+ let rest = raw2;
3058
+ if (rest.endsWith("[]")) {
3059
+ rest = rest.slice(0, -2);
3060
+ if (rest.endsWith("[]")) {
3061
+ throw new Error(
3062
+ `batchValues: invalid type name '${typeName}'. At most one array suffix "[]" is allowed as a raw type-name input. Use "int4[]" not "int4[][]".`
3063
+ );
3064
+ }
3065
+ }
3066
+ const modifierMatch = rest.match(/\(([^)]*)\)$/);
3067
+ if (modifierMatch) {
3068
+ const inner = modifierMatch[1] ?? "";
3069
+ if (!/^\d+(?:,\d+)?$/.test(inner)) {
3070
+ throw new Error(
3071
+ `batchValues: invalid type name '${typeName}'. Type modifier must be "(N)" or "(N,M)" with digits only; got "(${inner})".`
3072
+ );
3073
+ }
3074
+ rest = rest.slice(0, rest.length - modifierMatch[0].length).trimEnd();
3075
+ }
3076
+ const baseLower = rest.toLowerCase();
3077
+ if (MULTIWORD_BASE_TYPES.includes(baseLower)) {
3078
+ return;
3079
+ }
3080
+ const parts = rest.split(".");
3081
+ if (parts.length > 2) {
3082
+ throw new Error(
3083
+ `batchValues: invalid type name '${typeName}'. Schema-qualified types allow at most one dot (schema.type).`
3084
+ );
3085
+ }
3086
+ for (const part of parts) {
3087
+ if (!IDENT_RE.test(part)) {
3088
+ throw new Error(
3089
+ `batchValues: invalid type name '${typeName}'. Base type "${part}" is not a valid SQL identifier ([A-Za-z_][A-Za-z0-9_]*) and is not in the multi-word type allowlist.`
3090
+ );
3091
+ }
3092
+ }
3093
+ }
3015
3094
  function isBatchValuesRef(value) {
3016
3095
  return typeof value === "object" && value !== null && "__kind" in value && value.__kind === "batchValues";
3017
3096
  }
@@ -3024,20 +3103,28 @@ function batchValues(data, columns, types, opts) {
3024
3103
  if (columns.length === 0) {
3025
3104
  throw new Error("batchValues: at least one column is required");
3026
3105
  }
3027
- const invalidType = types.find((t) => !/^[a-zA-Z0-9_]+$/.test(t));
3028
- if (invalidType !== void 0) {
3029
- throw new Error(
3030
- `batchValues: invalid type name '${invalidType}'. Type names must contain only letters, digits, and underscores.`
3031
- );
3106
+ const normalizedTypes = types.map((t) => t.trim());
3107
+ for (const t of normalizedTypes) {
3108
+ validateTypeName(t);
3032
3109
  }
3033
- return {
3110
+ const alias = opts?.alias ?? "batch";
3111
+ validateIdentifier(alias, "alias");
3112
+ for (const col2 of columns) {
3113
+ validateIdentifier(col2, "column");
3114
+ }
3115
+ const frozenData = Object.freeze(
3116
+ data.map((row) => Object.freeze([...row]))
3117
+ );
3118
+ const frozenColumns = Object.freeze([...columns]);
3119
+ const frozenTypes = Object.freeze([...normalizedTypes]);
3120
+ return Object.freeze({
3034
3121
  __kind: "batchValues",
3035
- data,
3036
- columns,
3037
- types,
3038
- alias: opts?.alias ?? "batch",
3122
+ data: frozenData,
3123
+ columns: frozenColumns,
3124
+ types: frozenTypes,
3125
+ alias,
3039
3126
  ordinality: opts?.ordinality ?? false
3040
- };
3127
+ });
3041
3128
  }
3042
3129
 
3043
3130
  // src/dx/expressions.ts
@@ -4018,36 +4105,31 @@ function coalesce(fields, as) {
4018
4105
  };
4019
4106
  }
4020
4107
  function raw(sqlFragment, as) {
4021
- if (!as || as.trim() === "") {
4022
- throw new Error("raw() requires a non-empty alias");
4023
- }
4108
+ validateIdentifier(as, "column");
4024
4109
  return {
4025
4110
  __expr: true,
4026
4111
  intent: { kind: "raw", sql: sqlFragment, as }
4027
4112
  };
4028
4113
  }
4029
4114
  function col(column, alias) {
4030
- if (!column || column.trim() === "") {
4031
- throw new Error("col() requires a non-empty column name");
4032
- }
4033
- if (!alias || alias.trim() === "") {
4034
- throw new Error("col() requires a non-empty alias");
4035
- }
4115
+ validateIdentifier(column, "column");
4116
+ validateIdentifier(alias, "column");
4036
4117
  return {
4037
4118
  __expr: true,
4038
4119
  intent: { kind: "columnAlias", column, alias }
4039
4120
  };
4040
4121
  }
4041
4122
  function relationColumn(relation, column, as) {
4042
- if (!relation || relation.trim() === "") {
4123
+ if (!relation) {
4043
4124
  throw new Error("relationColumn() requires a non-empty relation path");
4044
4125
  }
4045
- if (!column || column.trim() === "") {
4046
- throw new Error("relationColumn() requires a non-empty column name");
4126
+ for (const segment of relation.split(".")) {
4127
+ validateIdentifier(segment, "relation");
4047
4128
  }
4048
- if (!as || as.trim() === "") {
4049
- throw new Error("relationColumn() requires a non-empty alias");
4129
+ if (column !== "*") {
4130
+ validateIdentifier(column, "column");
4050
4131
  }
4132
+ validateIdentifier(as, "column");
4051
4133
  return {
4052
4134
  __expr: true,
4053
4135
  intent: { kind: "relationColumn", relation, column, as }
@@ -4406,7 +4488,7 @@ async function runAfterMutationHooks(hooks, ctx, result, onHookError) {
4406
4488
  error,
4407
4489
  hook.name || "anonymous",
4408
4490
  frozen,
4409
- "beforeMutation"
4491
+ "afterMutation"
4410
4492
  );
4411
4493
  if (action === "continue") continue;
4412
4494
  }
@@ -4619,7 +4701,8 @@ function subquery(table) {
4619
4701
  function outerRef(column) {
4620
4702
  return {
4621
4703
  kind: "ref",
4622
- column
4704
+ column,
4705
+ outer: true
4623
4706
  };
4624
4707
  }
4625
4708
  function isSubqueryExpression(value) {
@@ -7056,10 +7139,9 @@ function stream(builder, options) {
7056
7139
  };
7057
7140
  let hookIntent = rawIntent;
7058
7141
  try {
7059
- const afterHookCtx = await runBeforeQueryHooks(
7060
- hookStore.beforeQuery,
7061
- ctx,
7062
- onHookError
7142
+ const afterHookCtx = await withReentrancyGuard(
7143
+ hookStore,
7144
+ (s) => runBeforeQueryHooks(s.beforeQuery, ctx, onHookError)
7063
7145
  );
7064
7146
  hookIntent = afterHookCtx.intent;
7065
7147
  } catch (error) {
@@ -7515,6 +7597,13 @@ var QueryBuilderImpl = class _QueryBuilderImpl {
7515
7597
  "join(batchValuesRef): an `on` condition is required for BatchValues joins"
7516
7598
  );
7517
7599
  }
7600
+ validateIdentifier(bv.alias, "alias");
7601
+ for (const col2 of bv.columns) {
7602
+ validateIdentifier(col2, "column");
7603
+ }
7604
+ if (opts.as !== void 0) {
7605
+ validateIdentifier(opts.as, "alias");
7606
+ }
7518
7607
  const joinIntent = {
7519
7608
  batchValues: {
7520
7609
  data: bv.data,
@@ -7530,6 +7619,9 @@ var QueryBuilderImpl = class _QueryBuilderImpl {
7530
7619
  builder.joinIntents.push(joinIntent);
7531
7620
  } else {
7532
7621
  validateIdentifier(relationOrTableOrBatch, "table");
7622
+ if (opts?.as !== void 0) {
7623
+ validateIdentifier(opts.as, "alias");
7624
+ }
7533
7625
  const joinIntent = opts?.on ? {
7534
7626
  table: relationOrTableOrBatch,
7535
7627
  on: opts.on,