@electric-sql/client 1.3.1 → 1.4.0

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@electric-sql/client",
3
3
  "description": "Postgres everywhere - your data, in sync, wherever you need it.",
4
- "version": "1.3.1",
4
+ "version": "1.4.0",
5
5
  "author": "ElectricSQL team and contributors.",
6
6
  "bugs": {
7
7
  "url": "https://github.com/electric-sql/electric/issues"
package/src/client.ts CHANGED
@@ -59,7 +59,10 @@ import {
59
59
  SUBSET_PARAM_LIMIT,
60
60
  SUBSET_PARAM_OFFSET,
61
61
  SUBSET_PARAM_ORDER_BY,
62
+ SUBSET_PARAM_WHERE_EXPR,
63
+ SUBSET_PARAM_ORDER_BY_EXPR,
62
64
  } from './constants'
65
+ import { compileExpression, compileOrderBy } from './expression-compiler'
63
66
  import {
64
67
  EventSourceMessage,
65
68
  fetchEventSource,
@@ -890,13 +893,29 @@ export class ShapeStream<T extends Row<unknown> = Row>
890
893
  }
891
894
 
892
895
  if (subsetParams) {
893
- if (subsetParams.where && typeof subsetParams.where === `string`) {
896
+ // Prefer structured expressions when available (allows proper columnMapper application)
897
+ // Fall back to legacy string format for backwards compatibility
898
+ if (subsetParams.whereExpr) {
899
+ // Compile structured expression with columnMapper applied
900
+ const compiledWhere = compileExpression(
901
+ subsetParams.whereExpr,
902
+ this.options.columnMapper?.encode
903
+ )
904
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, compiledWhere)
905
+ // Also send the structured expression for servers that support it
906
+ fetchUrl.searchParams.set(
907
+ SUBSET_PARAM_WHERE_EXPR,
908
+ JSON.stringify(subsetParams.whereExpr)
909
+ )
910
+ } else if (subsetParams.where && typeof subsetParams.where === `string`) {
911
+ // Legacy string format (no columnMapper applied to already-compiled SQL)
894
912
  const encodedWhere = encodeWhereClause(
895
913
  subsetParams.where,
896
914
  this.options.columnMapper?.encode
897
915
  )
898
916
  setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere)
899
917
  }
918
+
900
919
  if (subsetParams.params)
901
920
  // Serialize params as JSON to keep the parameter name constant for proxy configs
902
921
  fetchUrl.searchParams.set(
@@ -907,7 +926,25 @@ export class ShapeStream<T extends Row<unknown> = Row>
907
926
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
908
927
  if (subsetParams.offset)
909
928
  setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
910
- if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
929
+
930
+ // Prefer structured ORDER BY expressions when available
931
+ if (subsetParams.orderByExpr) {
932
+ // Compile structured ORDER BY with columnMapper applied
933
+ const compiledOrderBy = compileOrderBy(
934
+ subsetParams.orderByExpr,
935
+ this.options.columnMapper?.encode
936
+ )
937
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, compiledOrderBy)
938
+ // Also send the structured expression for servers that support it
939
+ fetchUrl.searchParams.set(
940
+ SUBSET_PARAM_ORDER_BY_EXPR,
941
+ JSON.stringify(subsetParams.orderByExpr)
942
+ )
943
+ } else if (
944
+ subsetParams.orderBy &&
945
+ typeof subsetParams.orderBy === `string`
946
+ ) {
947
+ // Legacy string format
911
948
  const encodedOrderBy = encodeWhereClause(
912
949
  subsetParams.orderBy,
913
950
  this.options.columnMapper?.encode
package/src/constants.ts CHANGED
@@ -26,6 +26,8 @@ export const SUBSET_PARAM_LIMIT = `subset__limit`
26
26
  export const SUBSET_PARAM_OFFSET = `subset__offset`
27
27
  export const SUBSET_PARAM_ORDER_BY = `subset__order_by`
28
28
  export const SUBSET_PARAM_WHERE_PARAMS = `subset__params`
29
+ export const SUBSET_PARAM_WHERE_EXPR = `subset__where_expr`
30
+ export const SUBSET_PARAM_ORDER_BY_EXPR = `subset__order_by_expr`
29
31
 
30
32
  // Query parameters that should be passed through when proxying Electric requests
31
33
  export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
@@ -41,4 +43,6 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
41
43
  SUBSET_PARAM_OFFSET,
42
44
  SUBSET_PARAM_ORDER_BY,
43
45
  SUBSET_PARAM_WHERE_PARAMS,
46
+ SUBSET_PARAM_WHERE_EXPR,
47
+ SUBSET_PARAM_ORDER_BY_EXPR,
44
48
  ]
@@ -0,0 +1,132 @@
1
+ import { SerializedExpression, SerializedOrderByClause } from './types'
2
+ import { quoteIdentifier } from './column-mapper'
3
+
4
+ /**
5
+ * Compiles a serialized expression into a SQL string.
6
+ * Applies columnMapper transformations to column references.
7
+ *
8
+ * @param expr - The serialized expression to compile
9
+ * @param columnMapper - Optional function to transform column names (e.g., camelCase to snake_case)
10
+ * @returns The compiled SQL string
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const expr = { type: 'ref', column: 'userId' }
15
+ * compileExpression(expr, camelToSnake) // '"user_id"'
16
+ * ```
17
+ */
18
+ export function compileExpression(
19
+ expr: SerializedExpression,
20
+ columnMapper?: (col: string) => string
21
+ ): string {
22
+ switch (expr.type) {
23
+ case `ref`: {
24
+ // Apply columnMapper, then quote
25
+ const mappedColumn = columnMapper
26
+ ? columnMapper(expr.column)
27
+ : expr.column
28
+ return quoteIdentifier(mappedColumn)
29
+ }
30
+ case `val`:
31
+ return `$${expr.paramIndex}`
32
+ case `func`:
33
+ return compileFunction(expr, columnMapper)
34
+ default: {
35
+ // TypeScript exhaustiveness check
36
+ const _exhaustive: never = expr
37
+ throw new Error(`Unknown expression type: ${JSON.stringify(_exhaustive)}`)
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Compiles a function expression into SQL.
44
+ */
45
+ function compileFunction(
46
+ expr: { type: `func`; name: string; args: SerializedExpression[] },
47
+ columnMapper?: (col: string) => string
48
+ ): string {
49
+ const args = expr.args.map((arg) => compileExpression(arg, columnMapper))
50
+
51
+ switch (expr.name) {
52
+ // Binary comparison operators
53
+ case `eq`:
54
+ return `${args[0]} = ${args[1]}`
55
+ case `gt`:
56
+ return `${args[0]} > ${args[1]}`
57
+ case `gte`:
58
+ return `${args[0]} >= ${args[1]}`
59
+ case `lt`:
60
+ return `${args[0]} < ${args[1]}`
61
+ case `lte`:
62
+ return `${args[0]} <= ${args[1]}`
63
+
64
+ // Logical operators
65
+ case `and`:
66
+ return args.map((a) => `(${a})`).join(` AND `)
67
+ case `or`:
68
+ return args.map((a) => `(${a})`).join(` OR `)
69
+ case `not`:
70
+ return `NOT (${args[0]})`
71
+
72
+ // Special operators
73
+ case `in`:
74
+ return `${args[0]} = ANY(${args[1]})`
75
+ case `like`:
76
+ return `${args[0]} LIKE ${args[1]}`
77
+ case `ilike`:
78
+ return `${args[0]} ILIKE ${args[1]}`
79
+ case `isNull`:
80
+ case `isUndefined`:
81
+ return `${args[0]} IS NULL`
82
+
83
+ // String functions
84
+ case `upper`:
85
+ return `UPPER(${args[0]})`
86
+ case `lower`:
87
+ return `LOWER(${args[0]})`
88
+ case `length`:
89
+ return `LENGTH(${args[0]})`
90
+ case `concat`:
91
+ return `CONCAT(${args.join(`, `)})`
92
+
93
+ // Other functions
94
+ case `coalesce`:
95
+ return `COALESCE(${args.join(`, `)})`
96
+
97
+ default:
98
+ throw new Error(`Unknown function: ${expr.name}`)
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Compiles serialized ORDER BY clauses into a SQL string.
104
+ * Applies columnMapper transformations to column references.
105
+ *
106
+ * @param clauses - The serialized ORDER BY clauses to compile
107
+ * @param columnMapper - Optional function to transform column names
108
+ * @returns The compiled SQL ORDER BY string
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * const clauses = [{ column: 'createdAt', direction: 'desc', nulls: 'first' }]
113
+ * compileOrderBy(clauses, camelToSnake) // '"created_at" DESC NULLS FIRST'
114
+ * ```
115
+ */
116
+ export function compileOrderBy(
117
+ clauses: SerializedOrderByClause[],
118
+ columnMapper?: (col: string) => string
119
+ ): string {
120
+ return clauses
121
+ .map((clause) => {
122
+ const mappedColumn = columnMapper
123
+ ? columnMapper(clause.column)
124
+ : clause.column
125
+ let sql = quoteIdentifier(mappedColumn)
126
+ if (clause.direction === `desc`) sql += ` DESC`
127
+ if (clause.nulls === `first`) sql += ` NULLS FIRST`
128
+ if (clause.nulls === `last`) sql += ` NULLS LAST`
129
+ return sql
130
+ })
131
+ .join(`, `)
132
+ }
package/src/index.ts CHANGED
@@ -16,3 +16,4 @@ export {
16
16
  snakeToCamel,
17
17
  camelToSnake,
18
18
  } from './column-mapper'
19
+ export { compileExpression, compileOrderBy } from './expression-compiler'
package/src/types.ts CHANGED
@@ -68,12 +68,40 @@ export type MoveTag = string
68
68
  */
69
69
  export type MoveOutPattern = { pos: number; value: string }
70
70
 
71
+ /**
72
+ * Serialized expression types for structured subset queries.
73
+ * These allow Electric to properly apply columnMapper transformations
74
+ * before generating the final SQL.
75
+ */
76
+ export type SerializedExpression =
77
+ | { type: `ref`; column: string } // Column reference
78
+ | { type: `val`; paramIndex: number } // Parameter placeholder ($1, $2, etc.)
79
+ | { type: `func`; name: string; args: SerializedExpression[] } // Operator/function
80
+
81
+ /**
82
+ * Serialized ORDER BY clause for structured subset queries.
83
+ */
84
+ export type SerializedOrderByClause = {
85
+ column: string
86
+ direction?: `asc` | `desc` // omitted means 'asc'
87
+ nulls?: `first` | `last`
88
+ }
89
+
71
90
  export type SubsetParams = {
91
+ /** Legacy string format WHERE clause */
72
92
  where?: string
93
+ /** Positional parameter values for WHERE clause */
73
94
  params?: Record<string, string>
95
+ /** Maximum number of rows to return */
74
96
  limit?: number
97
+ /** Number of rows to skip */
75
98
  offset?: number
99
+ /** Legacy string format ORDER BY clause */
76
100
  orderBy?: string
101
+ /** Structured WHERE expression (preferred when available) */
102
+ whereExpr?: SerializedExpression
103
+ /** Structured ORDER BY clauses (preferred when available) */
104
+ orderByExpr?: SerializedOrderByClause[]
77
105
  }
78
106
 
79
107
  export type ControlMessage = {