@spooky-sync/query-builder 0.0.0-canary.1

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.
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { QueryBuilder, SchemaStructure } from './index';
3
+
4
+ describe('QueryBuilder Relationship Inference', () => {
5
+ const schema = {
6
+ tables: [
7
+ {
8
+ name: 'game_database',
9
+ columns: {
10
+ id: { type: 'string', recordId: true, optional: false },
11
+ games: { type: 'string', optional: true },
12
+ },
13
+ primaryKey: ['id'],
14
+ },
15
+ {
16
+ name: 'game',
17
+ columns: {
18
+ id: { type: 'string', recordId: true, optional: false },
19
+ database: { type: 'string', recordId: true, optional: false },
20
+ },
21
+ primaryKey: ['id'],
22
+ },
23
+ ],
24
+ relationships: [
25
+ {
26
+ from: 'game_database',
27
+ field: 'games',
28
+ to: 'game',
29
+ cardinality: 'many',
30
+ },
31
+ {
32
+ from: 'game',
33
+ field: 'database',
34
+ to: 'game_database',
35
+ cardinality: 'one',
36
+ },
37
+ ],
38
+ backends: {},
39
+ } as const;
40
+
41
+ it('should correctly infer foreign key field for game_database -> games', () => {
42
+ const qb = new QueryBuilder(schema, 'game_database');
43
+
44
+ // We want to simulate: SELECT *, (SELECT * FROM game WHERE database=$parent.id) AS games FROM game_database
45
+ // This happens when we include 'games' in the related fields
46
+
47
+ // The QueryBuilder.related method adds to options.related
48
+ qb.related('games');
49
+
50
+ // To verify the generated subquery, we need to inspect the build output
51
+ // buildQueryFromOptions is internal, but we can check the result of build()
52
+ // or we can inspect the private options if we cast to any
53
+
54
+ const options = (qb as any).options;
55
+ expect(options.related).toHaveLength(1);
56
+ expect(options.related[0].alias).toBe('games');
57
+ expect(options.related[0].foreignKeyField).toBe('database');
58
+ });
59
+ });
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Supported value types in the schema
3
+ */
4
+ export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json';
5
+
6
+ /**
7
+ * Column metadata defining the type and optionality of a field
8
+ */
9
+ export interface ColumnSchema {
10
+ readonly type: ValueType;
11
+ readonly optional: boolean;
12
+ readonly dateTime?: boolean;
13
+ readonly recordId?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Table metadata containing columns and primary key information
18
+ */
19
+ export interface TableSchemaMetadata {
20
+ readonly name: string;
21
+ readonly columns: {
22
+ readonly [columnName: string]: ColumnSchema;
23
+ };
24
+ readonly primaryKey: readonly string[];
25
+ }
26
+
27
+ /**
28
+ * Cardinality of a relationship: one-to-one or one-to-many
29
+ */
30
+ export type Cardinality = 'one' | 'many';
31
+
32
+ /**
33
+ * Relationship metadata defining how tables relate to each other
34
+ */
35
+ export interface RelationshipMetadata {
36
+ readonly model: string;
37
+ readonly table: string;
38
+ readonly cardinality: Cardinality;
39
+ }
40
+
41
+ /**
42
+ * Complete schema metadata structure
43
+ * Maps table names to their schemas and relationships
44
+ */
45
+ export interface SchemaMetadataStructure {
46
+ readonly tables: {
47
+ readonly [tableName: string]: TableSchemaMetadata;
48
+ };
49
+ readonly relationships: {
50
+ readonly [tableName: string]: {
51
+ readonly [fieldName: string]: RelationshipMetadata;
52
+ };
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Type mapping from ValueType to TypeScript types
58
+ */
59
+ export type TypeNameToTypeMap = {
60
+ string: string;
61
+ number: number;
62
+ boolean: boolean;
63
+ null: null;
64
+ json: unknown;
65
+ };
66
+
67
+ /**
68
+ * Convert a column type to its TypeScript type
69
+ */
70
+ export type ColumnToTSType<T extends ColumnSchema> = T extends {
71
+ optional: true;
72
+ }
73
+ ? TypeNameToTypeMap[T['type']] | null
74
+ : TypeNameToTypeMap[T['type']];
75
+
76
+ /**
77
+ * Helper to extract relationship field names for a table
78
+ */
79
+ export type RelationshipFieldNames<
80
+ Metadata extends SchemaMetadataStructure,
81
+ TableName extends keyof Metadata['relationships'] & string,
82
+ > = keyof Metadata['relationships'][TableName] & string;
83
+
84
+ /**
85
+ * Helper to get cardinality for a specific relationship
86
+ */
87
+ export type GetRelationshipCardinality<
88
+ Metadata extends SchemaMetadataStructure,
89
+ TableName extends keyof Metadata['relationships'] & string,
90
+ FieldName extends keyof Metadata['relationships'][TableName] & string,
91
+ > = Metadata['relationships'][TableName][FieldName]['cardinality'];
92
+
93
+ /**
94
+ * Helper to get related table name for a relationship
95
+ */
96
+ export type GetRelatedTable<
97
+ Metadata extends SchemaMetadataStructure,
98
+ TableName extends keyof Metadata['relationships'] & string,
99
+ FieldName extends keyof Metadata['relationships'][TableName] & string,
100
+ > = Metadata['relationships'][TableName][FieldName]['table'];
101
+
102
+ // ============================================================================
103
+ // ACCESS SCHEMA HELPERS
104
+ // ============================================================================
105
+
106
+ export interface AccessDefinition {
107
+ readonly signIn: {
108
+ readonly params: Record<string, ColumnSchema>;
109
+ };
110
+ readonly signup: {
111
+ readonly params: Record<string, ColumnSchema>;
112
+ };
113
+ }
114
+
115
+ // ============================================================================
116
+ // ARRAY-BASED SCHEMA TYPE HELPERS
117
+ // ============================================================================
118
+
119
+ /**
120
+ * Base schema structure for array-based schemas
121
+ */
122
+ export interface SchemaStructure {
123
+ readonly tables: readonly {
124
+ readonly name: string;
125
+ readonly columns: Record<string, ColumnSchema>;
126
+ readonly primaryKey: readonly string[];
127
+ }[];
128
+ readonly relationships: readonly {
129
+ readonly from: string;
130
+ readonly field: string;
131
+ readonly to: string;
132
+ readonly cardinality: Cardinality;
133
+ }[];
134
+ readonly backends: Record<string, HTTPOutboxBackendDefinition>;
135
+ readonly access?: Record<string, AccessDefinition>;
136
+ }
137
+
138
+ export interface HTTPOutboxBackendDefinition {
139
+ readonly outboxTable: string;
140
+ readonly routes: Record<string, HTTPBackendRouteDefinition>;
141
+ }
142
+
143
+ export interface HTTPBackendRouteDefinition {
144
+ readonly args: Record<string, HTTPBackendRouteArgsDefinition>;
145
+ }
146
+
147
+ export interface HTTPBackendRouteArgsDefinition {
148
+ readonly type: ValueType;
149
+ readonly optional: boolean;
150
+ }
151
+
152
+ // ============================================================================
153
+ // BACKEND / RUN TYPE HELPERS
154
+ // ============================================================================
155
+
156
+ /** Extract all backend names from the schema */
157
+ export type BackendNames<S extends SchemaStructure> = keyof S['backends'] & string;
158
+
159
+ /** Extract all route paths for a specific backend */
160
+ export type BackendRoutes<
161
+ S extends SchemaStructure,
162
+ B extends BackendNames<S>,
163
+ > = keyof S['backends'][B]['routes'] & string;
164
+
165
+ /** Build the typed payload for a specific route */
166
+ export type RoutePayload<
167
+ S extends SchemaStructure,
168
+ B extends BackendNames<S>,
169
+ R extends BackendRoutes<S, B>,
170
+ > = [keyof RouteArgsDef<S, B, R>] extends [never]
171
+ ? Record<string, never>
172
+ : Prettify<
173
+ { [K in RequiredRouteArgKeys<S, B, R>]: TypeNameToTypeMap[RouteArgsDef<S, B, R>[K]['type']] }
174
+ &
175
+ { [K in OptionalRouteArgKeys<S, B, R>]?: TypeNameToTypeMap[RouteArgsDef<S, B, R>[K]['type']] }
176
+ >;
177
+
178
+ type RouteArgsDef<
179
+ S extends SchemaStructure,
180
+ B extends BackendNames<S>,
181
+ R extends BackendRoutes<S, B>,
182
+ > = S['backends'][B]['routes'][R]['args'];
183
+
184
+ type RequiredRouteArgKeys<
185
+ S extends SchemaStructure,
186
+ B extends BackendNames<S>,
187
+ R extends BackendRoutes<S, B>,
188
+ > = {
189
+ [K in keyof RouteArgsDef<S, B, R>]: RouteArgsDef<S, B, R>[K]['optional'] extends true ? never : K;
190
+ }[keyof RouteArgsDef<S, B, R>] & string;
191
+
192
+ type OptionalRouteArgKeys<
193
+ S extends SchemaStructure,
194
+ B extends BackendNames<S>,
195
+ R extends BackendRoutes<S, B>,
196
+ > = {
197
+ [K in keyof RouteArgsDef<S, B, R>]: RouteArgsDef<S, B, R>[K]['optional'] extends true ? K : never;
198
+ }[keyof RouteArgsDef<S, B, R>] & string;
199
+
200
+ type Prettify<T> = { [K in keyof T]: T[K] } & {};
201
+
202
+ /**
203
+ * Extract a specific table by name from the schema tables array
204
+ */
205
+ export type GetTable<S extends SchemaStructure, Name extends TableNames<S>> = Extract<
206
+ S['tables'][number],
207
+ { name: Name }
208
+ >;
209
+
210
+ /**
211
+ * Extract all table names from the schema
212
+ */
213
+ export type TableNames<S extends SchemaStructure> = S['tables'][number]['name'];
214
+
215
+ /**
216
+ * Extract all field names from a type
217
+ */
218
+ export type TableFieldNames<T extends { columns: Record<string, ColumnSchema> }> =
219
+ keyof T['columns'] & string;
220
+
221
+ /**
222
+ * Convert table schema columns to a TypeScript model type
223
+ */
224
+ export type TableModel<T extends { columns: Record<string, ColumnSchema> }> = {
225
+ [K in keyof T['columns']]: ColumnToTSType<T['columns'][K]>;
226
+ };
227
+
228
+ /**
229
+ * Extract all relationships for a specific table from relationships array
230
+ */
231
+ export type TableRelationships<S extends SchemaStructure, TableName extends string> = Extract<
232
+ S['relationships'][number],
233
+ { from: TableName }
234
+ >;
235
+
236
+ /**
237
+ * Get relationship field names for a table
238
+ */
239
+ export type RelationshipFields<
240
+ S extends SchemaStructure,
241
+ TableName extends string,
242
+ > = TableRelationships<S, TableName>['field'];
243
+
244
+ /**
245
+ * Get specific relationship by table and field
246
+ */
247
+ export type GetRelationship<
248
+ S extends SchemaStructure,
249
+ TableName extends string,
250
+ Field extends string,
251
+ > = Extract<Extract<S['relationships'][number], { from: TableName }>, { field: Field }>;
252
+
253
+ /**
254
+ * Convert array-based schema to indexed format (for internal compatibility)
255
+ */
256
+ export type SchemaToIndexed<S extends SchemaStructure> = {
257
+ tables: {
258
+ [K in S['tables'][number]['name']]: Extract<S['tables'][number], { name: K }>;
259
+ };
260
+ relationships: {
261
+ [K in S['tables'][number]['name']]: {
262
+ [R in Extract<S['relationships'][number], { from: K }>['field']]: Extract<
263
+ Extract<S['relationships'][number], { from: K }>,
264
+ { field: R }
265
+ >;
266
+ };
267
+ };
268
+ };
package/src/types.ts ADDED
@@ -0,0 +1,216 @@
1
+ // Import new schema types
2
+ export type {
3
+ ValueType,
4
+ ColumnSchema,
5
+ TableSchemaMetadata,
6
+ Cardinality,
7
+ RelationshipMetadata,
8
+ SchemaMetadataStructure,
9
+ AccessDefinition,
10
+ } from './table-schema';
11
+
12
+ // Model types (backward compatibility)
13
+ export type GenericModel = Record<string, any>;
14
+ export type GenericSchema = Record<string, GenericModel>;
15
+
16
+ /**
17
+ * Helper to constrain related field names based on relationships metadata
18
+ */
19
+ export type RelatedField<T extends string, R> = GetRelationshipFields<T, R> & string;
20
+
21
+ // Query interfaces
22
+ export interface QueryInfo {
23
+ query: string;
24
+ hash: number;
25
+ vars?: Record<string, unknown>;
26
+ }
27
+
28
+ export interface RelatedQuery {
29
+ /** The name of the related table to query */
30
+ relatedTable: string;
31
+ /** The alias for this subquery result (defaults to relatedTable name) */
32
+ alias?: string;
33
+ /** Optional query modifier for the subquery */
34
+ modifier?: SchemaAwareQueryModifier<SchemaStructure, string>;
35
+ /** The cardinality of the relationship */
36
+ cardinality: 'one' | 'many';
37
+ }
38
+
39
+ export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean> {
40
+ select?: ((keyof TModel & string) | '*')[];
41
+ where?: Partial<TModel>;
42
+ limit?: number;
43
+ offset?: number;
44
+ orderBy?: Partial<Record<keyof TModel, 'asc' | 'desc'>>;
45
+ /** Related tables to include via subqueries */
46
+ related?: RelatedQuery[];
47
+ isOne?: IsOne;
48
+ }
49
+
50
+ export interface LiveQueryOptions<TModel extends GenericModel> extends Omit<
51
+ QueryOptions<TModel, boolean>,
52
+ 'orderBy'
53
+ > {}
54
+
55
+ // Import schema types for schema-aware modifiers
56
+ import type {
57
+ SchemaStructure,
58
+ TableNames,
59
+ GetTable,
60
+ TableModel,
61
+ TableRelationships,
62
+ GetRelationship,
63
+ } from './table-schema';
64
+
65
+ // Query modifier type for related queries - now schema-aware!
66
+ export type QueryModifier<TModel extends GenericModel> = (
67
+ builder: QueryModifierBuilder<TModel>
68
+ ) => QueryModifierBuilder<TModel>;
69
+
70
+ // Schema-aware query modifier that knows about relationships
71
+ export type SchemaAwareQueryModifier<
72
+ S extends SchemaStructure,
73
+ TableName extends TableNames<S>,
74
+ RelatedFields extends Record<string, any> = {},
75
+ > = (
76
+ builder: SchemaAwareQueryModifierBuilder<S, TableName, {}>
77
+ ) => SchemaAwareQueryModifierBuilder<S, TableName, RelatedFields>;
78
+
79
+ // Simplified query builder interface for modifying subqueries
80
+ export interface QueryModifierBuilder<TModel extends GenericModel> {
81
+ where(conditions: Partial<TModel>): this;
82
+ select(...fields: ((keyof TModel & string) | '*')[]): this;
83
+ limit(count: number): this;
84
+ offset(count: number): this;
85
+ orderBy(field: keyof TModel & string, direction?: 'asc' | 'desc'): this;
86
+ related<Field extends string>(relatedField: Field, modifier?: QueryModifier<any>): this;
87
+ _getOptions(): QueryOptions<TModel, boolean>;
88
+ }
89
+
90
+ // Schema-aware query builder interface that understands relationships
91
+ export interface SchemaAwareQueryModifierBuilder<
92
+ S extends SchemaStructure,
93
+ TableName extends TableNames<S>,
94
+ RelatedFields extends Record<string, any> = {},
95
+ > {
96
+ where(conditions: Partial<TableModel<GetTable<S, TableName>>>): this;
97
+ select(...fields: ((keyof TableModel<GetTable<S, TableName>> & string) | '*')[]): this;
98
+ limit(count: number): this;
99
+ offset(count: number): this;
100
+ orderBy(
101
+ field: keyof TableModel<GetTable<S, TableName>> & string,
102
+ direction?: 'asc' | 'desc'
103
+ ): this;
104
+ related<
105
+ Field extends TableRelationships<S, TableName>['field'],
106
+ Rel extends GetRelationship<S, TableName, Field>,
107
+ RelatedFields2 extends Record<string, any> = {},
108
+ >(
109
+ relatedField: Field,
110
+ modifier?: SchemaAwareQueryModifier<S, Rel['to'], RelatedFields2>
111
+ ): SchemaAwareQueryModifierBuilder<
112
+ S,
113
+ TableName,
114
+ RelatedFields & {
115
+ [K in Field]: {
116
+ to: Rel['to'];
117
+ cardinality: Rel['cardinality'];
118
+ relatedFields: RelatedFields2;
119
+ };
120
+ }
121
+ >;
122
+ _getOptions(): QueryOptions<TableModel<GetTable<S, TableName>>, boolean>;
123
+ }
124
+
125
+ /**
126
+ * Extract fields from a model that are relationship fields (string or string[])
127
+ * Excludes common non-relationship fields like id, created_at, updated_at, etc.
128
+ */
129
+ export type RelationshipFields<TModel extends GenericModel> = {
130
+ [K in keyof TModel]: K extends 'id' | 'created_at' | 'updated_at' | 'deleted_at'
131
+ ? never
132
+ : TModel[K] extends string | string[] | null | undefined
133
+ ? K
134
+ : never;
135
+ }[keyof TModel];
136
+
137
+ /**
138
+ * Helper type to infer the related model type from a field name using Relationships metadata
139
+ * Simplified to directly access the nested structure
140
+ */
141
+ export type InferRelatedModelFromMetadata<
142
+ Schema extends GenericSchema,
143
+ TableName extends string,
144
+ FieldName extends string,
145
+ Relationships,
146
+ > =
147
+ Relationships extends Record<string, Record<string, RelationshipDefinition>>
148
+ ? TableName extends keyof Relationships
149
+ ? FieldName extends keyof Relationships[TableName]
150
+ ? Relationships[TableName][FieldName]['model']
151
+ : any
152
+ : any
153
+ : any;
154
+
155
+ /**
156
+ * Get cardinality for a relationship field from metadata
157
+ * Simplified to directly access the nested structure
158
+ */
159
+ export type GetCardinality<TableName extends string, FieldName extends string, Relationships> =
160
+ Relationships extends Record<string, Record<string, RelationshipDefinition>>
161
+ ? TableName extends keyof Relationships
162
+ ? FieldName extends keyof Relationships[TableName]
163
+ ? Relationships[TableName][FieldName]['cardinality']
164
+ : 'many'
165
+ : 'many'
166
+ : 'many';
167
+
168
+ /**
169
+ * Type that transforms a Model by replacing a field with its related records
170
+ * Uses Relationships metadata to determine cardinality and related table
171
+ */
172
+ export type WithRelated<
173
+ Schema extends GenericSchema,
174
+ TModel extends Record<string, any>,
175
+ TableName extends string,
176
+ FieldName extends string,
177
+ Relationships,
178
+ > = FieldName extends keyof TModel
179
+ ? Omit<TModel, FieldName> & {
180
+ [K in FieldName]: GetCardinality<TableName, FieldName, Relationships> extends 'one'
181
+ ? InferRelatedModelFromMetadata<Schema, TableName, K, Relationships> | null
182
+ : InferRelatedModelFromMetadata<Schema, TableName, K, Relationships>[] | null;
183
+ }
184
+ : TModel;
185
+
186
+ /**
187
+ * Type to extract relationship fields from Relationships metadata
188
+ * Now simplified to just get the keys of the nested object
189
+ */
190
+ export type GetRelationshipFields<TableName extends string, Relationships> =
191
+ Relationships extends Record<string, Record<string, any>>
192
+ ? TableName extends keyof Relationships
193
+ ? keyof Relationships[TableName] & string
194
+ : never
195
+ : never;
196
+
197
+ /**
198
+ * Relationship metadata structure - now a nested object for better type safety
199
+ * Example:
200
+ * {
201
+ * thread: {
202
+ * author: { model: Schema["user"], table: "user", cardinality: "one" },
203
+ * comments: { model: Schema["comment"], table: "comment", cardinality: "many" }
204
+ * }
205
+ * }
206
+ */
207
+ export interface RelationshipDefinition<Model = any> {
208
+ /** The related model type */
209
+ model: Model;
210
+ /** The related table name */
211
+ table: string;
212
+ /** Whether this is a 1:1 or 1:many relationship */
213
+ cardinality: 'one' | 'many';
214
+ }
215
+
216
+ export type RelationshipsMetadata = Record<string, Record<string, RelationshipDefinition>>;
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020", "DOM"],
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "esModuleInterop": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true,
17
+ "outDir": "./dist",
18
+ "rootDir": "./src"
19
+ },
20
+ "include": ["src/**/*"],
21
+ "exclude": ["node_modules", "dist"]
22
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsdown';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm', 'cjs'],
6
+ dts: true,
7
+ external: ['surrealdb'],
8
+ clean: true,
9
+ hash: false,
10
+ sourcemap: true,
11
+ });