@bakery-framework/orm 2.0.0-alpha.5 → 2.0.0-alpha.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/orm",
3
- "version": "2.0.0-alpha.5",
3
+ "version": "2.0.0-alpha.6",
4
4
  "description": "Bakery database layer: adapters, query builder, schema sync and backup.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -54,6 +54,6 @@
54
54
  "bun": ">=1.3.14"
55
55
  },
56
56
  "dependencies": {
57
- "@bakery-framework/core": "^2.0.0-alpha.5"
57
+ "@bakery-framework/core": "^2.0.0-alpha.6"
58
58
  }
59
59
  }
@@ -849,20 +849,69 @@ export abstract class SQLAdapter {
849
849
  return new DatabaseStatement(this, sqlText)
850
850
  }
851
851
 
852
+ /**
853
+ * The `LIKE` escape character — `!`, and deliberately **not** a backslash.
854
+ *
855
+ * A backslash is the obvious choice and is unusable here. MySQL processes
856
+ * backslash escapes inside string literals where SQLite and Postgres do not,
857
+ * so the clause would need to differ per dialect; and worse, Postgres never
858
+ * receives what is written. `normalizePostgresSQL` treats `\'` as an escaped
859
+ * quote — MySQL's rule, applied to every dialect — so `ESCAPE '\'` leaves the
860
+ * string literal *open*, swallows the rest of the statement, and the driver
861
+ * reports `syntax error at or near "OFFSET"` from a clause several tokens
862
+ * later.
863
+ *
864
+ * That normalizer behaviour is a real defect and is not fixed here: a test
865
+ * pins it (`a backslash escape keeps the character it escapes`), and changing
866
+ * it would change every literal the framework emits. Choosing a character
867
+ * with no meaning to any of the three parsers sidesteps it entirely, and one
868
+ * spelling for all dialects beats a capability getter per dialect.
869
+ */
870
+ protected readonly likeEscape = '!'
871
+
872
+ get likeEscapeClause(): string {
873
+ return ` ESCAPE '${this.likeEscape}'`
874
+ }
875
+
876
+ /**
877
+ * Make a value match literally under `LIKE`.
878
+ *
879
+ * Without this a search for `50%` matches every row and one for `a_b` matches
880
+ * `axb` — `%` and `_` are the wildcards, and the filter passed user input
881
+ * through untouched. The escape character itself goes first, or escaping it
882
+ * afterwards would double the ones this method just added.
883
+ */
884
+ protected escapeLike(value: string): string {
885
+ const e = this.likeEscape
886
+ return value
887
+ .split(e)
888
+ .join(e + e)
889
+ .replace(/[%_]/g, m => `${e}${m}`)
890
+ }
891
+
892
+ /**
893
+ * `WHERE` and `ORDER BY` for a browsable table listing.
894
+ *
895
+ * A filter is either a **bare scalar**, which means `contains` and is what
896
+ * every caller sent before operators existed, or **`{op, value}`**. Keeping
897
+ * the scalar form meaningful is not politeness: `getData` is public and the
898
+ * dashboard still calls it that way.
899
+ *
900
+ * Columns are intersected with the real column set by the caller, so an
901
+ * unknown column disappears rather than reaching SQL. Values always bind.
902
+ */
852
903
  protected buildFilterSort(
853
904
  options: SQLAdapter.FilterSortOptions,
854
905
  validCols: Set<string>,
855
906
  ) {
856
907
  const whereParams: unknown[] = []
857
- const whereClauses = Object.entries(options.filters || {})
858
- .filter(
859
- ([col, val]) =>
860
- validCols.has(col) && val !== undefined && val !== null && val !== '',
861
- )
862
- .map(([col, val]) => {
863
- whereParams.push(`%${val}%`)
864
- return `${this.quote(col)} LIKE ?`
865
- })
908
+ const whereClauses: string[] = []
909
+
910
+ for (const [col, raw] of Object.entries(options.filters || {})) {
911
+ if (!validCols.has(col)) continue
912
+ const clause = this.filterClause(col, raw, whereParams)
913
+ if (clause) whereClauses.push(clause)
914
+ }
866
915
 
867
916
  const whereSql = whereClauses.length
868
917
  ? ` WHERE ${whereClauses.join(' AND ')}`
@@ -880,6 +929,67 @@ export abstract class SQLAdapter {
880
929
  return { whereSql, orderSql, whereParams }
881
930
  }
882
931
 
932
+ /**
933
+ * One filter, as SQL. Returns `null` when the filter says nothing.
934
+ *
935
+ * `params` is appended to rather than returned, because an operator may bind
936
+ * one value, two, or none at all — `IS NULL` has nothing to bind, and
937
+ * pretending otherwise is how a placeholder count drifts from its arguments.
938
+ */
939
+ private filterClause(
940
+ col: string,
941
+ raw: unknown,
942
+ params: unknown[],
943
+ ): string | null {
944
+ const quoted = this.quote(col)
945
+
946
+ // The pre-operator form. An empty string means "no filter" here, which is
947
+ // what a cleared text box sends and what every caller has relied on.
948
+ if (raw === null || raw === undefined || typeof raw !== 'object') {
949
+ if (raw === undefined || raw === null || raw === '') return null
950
+ params.push(`%${this.escapeLike(String(raw))}%`)
951
+ return `${quoted} LIKE ?${this.likeEscapeClause}`
952
+ }
953
+
954
+ const { op, value } = raw as { op?: string; value?: unknown }
955
+
956
+ // No value to bind, and none expected — these two are the whole reason a
957
+ // filter cannot be modelled as a plain column/value pair.
958
+ if (op === 'null') return `${quoted} IS NULL`
959
+ if (op === 'notnull') return `${quoted} IS NOT NULL`
960
+
961
+ if (value === undefined || value === null) return null
962
+
963
+ const comparison: Record<string, string> = {
964
+ eq: '=',
965
+ ne: '<>',
966
+ gt: '>',
967
+ gte: '>=',
968
+ lt: '<',
969
+ lte: '<=',
970
+ }
971
+ if (op && comparison[op]) {
972
+ params.push(value)
973
+ return `${quoted} ${comparison[op]} ?`
974
+ }
975
+
976
+ const pattern: Record<string, (v: string) => string> = {
977
+ contains: v => `%${v}%`,
978
+ starts: v => `${v}%`,
979
+ ends: v => `%${v}`,
980
+ }
981
+ const shape = op ? pattern[op] : undefined
982
+ if (shape) {
983
+ params.push(shape(this.escapeLike(String(value))))
984
+ return `${quoted} LIKE ?${this.likeEscapeClause}`
985
+ }
986
+
987
+ // An operator this dialect does not know is dropped rather than guessed
988
+ // at. Guessing would mean answering a question nobody asked, and the
989
+ // caller validates the vocabulary before it gets here.
990
+ return null
991
+ }
992
+
883
993
  protected formatDefault(
884
994
  def: unknown,
885
995
  boolTrue: string,
@@ -267,13 +267,22 @@ export class PGAdapter extends SQLAdapter {
267
267
  " AND table_schema NOT IN ('pg_catalog', 'information_schema')" +
268
268
  ' ORDER BY ordinal_position',
269
269
  ).all(t.name),
270
+ // `::regclass` casts a **string**, so the table name binds as a
271
+ // parameter. It used to interpolate `qName` — a double-quoted
272
+ // *identifier* — which Postgres reads as a column reference, so this
273
+ // threw `column "<table>" does not exist` for every table and took the
274
+ // whole of `getSchema()` down with it on this dialect.
275
+ //
276
+ // The quiet case was worse than the loud one: a table with a column of
277
+ // the same name resolved, casting that column's *value* to a regclass
278
+ // and reporting some other table's primary key as this one's.
270
279
  this.query(
271
280
  'SELECT a.attname AS name' +
272
281
  ' FROM pg_index i' +
273
282
  ' JOIN pg_attribute a ON a.attrelid = i.indrelid' +
274
283
  ' AND a.attnum = ANY(i.indkey)' +
275
- ` WHERE i.indisprimary AND i.indrelid = ${qName}::regclass`,
276
- ).all(),
284
+ ' WHERE i.indisprimary AND i.indrelid = ?::regclass',
285
+ ).all(t.name),
277
286
  this.query(
278
287
  'SELECT indexname AS name, indexdef AS def' +
279
288
  ' FROM pg_indexes' +
@@ -304,7 +304,18 @@ export class SQLiteAdapter extends SQLAdapter {
304
304
  name: c.name,
305
305
  type: c.type,
306
306
  notnull: c.notnull === 1,
307
- pk: c.pk === 1,
307
+ // `pk` is the column's **1-based position within the primary key**,
308
+ // not a boolean — `PRAGMA table_info` reports 0 for "not part of the
309
+ // key", 1 for the first key column, 2 for the second. So `=== 1`
310
+ // reported a composite `PRIMARY KEY (a, b)` as a single-column key on
311
+ // `a`, silently, and only on SQLite: MySQL reads `column_key = 'PRI'`
312
+ // and Postgres reads `pg_index.indisprimary`, both of which are set on
313
+ // every member.
314
+ //
315
+ // `parseConstraints` a few hundred lines down already had this right
316
+ // (`col.pk > 0`), which is why `getConstraints()` disagreed with
317
+ // `getSchema()` about the same table.
318
+ pk: c.pk > 0,
308
319
  })),
309
320
  indexes: idxs.map(i => ({ name: i.name, unique: i.unique === 1 })),
310
321
  })