Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit 501ab33

Browse files
Copilotymc9
andcommitted
Merge dev branch and remove SQLite URL redaction
Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
1 parent e9537a7 commit 501ab33

45 files changed

Lines changed: 1962 additions & 1090 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,13 @@
4646
"colors": "1.4.0",
4747
"commander": "^8.3.0",
4848
"cors": "^2.8.5",
49+
"dotenv": "^17.2.3",
4950
"execa": "^9.6.0",
5051
"express": "^5.0.0",
5152
"jiti": "^2.6.1",
5253
"langium": "catalog:",
5354
"mixpanel": "^0.18.1",
55+
"mysql2": "catalog:",
5456
"ora": "^5.4.1",
5557
"package-manager-detector": "^1.3.0",
5658
"pg": "catalog:",

packages/cli/src/actions/proxy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ function redactDatabaseUrl(url: string): string {
120120
}
121121
return parsedUrl.toString();
122122
} catch {
123-
// If URL parsing fails, return the original (might be a file path for SQLite)
123+
// If URL parsing fails, return the original
124124
return url;
125125
}
126126
}
@@ -135,7 +135,7 @@ function createDialect(provider: string, databaseUrl: string, outputPath: string
135135
resolvedUrl = path.join(outputPath, filePath);
136136
}
137137
}
138-
console.log(colors.gray(`Connecting to SQLite database at: ${redactDatabaseUrl(resolvedUrl)}`));
138+
console.log(colors.gray(`Connecting to SQLite database at: ${resolvedUrl}`));
139139
return new SqliteDialect({
140140
database: new SQLite(resolvedUrl),
141141
});

packages/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dotenv/config';
12
import { ZModelLanguageMetaData } from '@zenstackhq/language';
23
import colors from 'colors';
34
import { Command, CommanderError, Option } from 'commander';

packages/cli/src/utils/exec-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,5 @@ export function execPrisma(args: string, options?: Omit<ExecSyncOptions, 'env'>
5757
return;
5858
}
5959

60-
execSync(`node ${prismaPath} ${args}`, _options);
60+
execSync(`node "${prismaPath}" ${args}`, _options);
6161
}

packages/language/src/utils.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,7 @@ export function typeAssignable(destType: ExpressionType, sourceType: ExpressionT
104104
/**
105105
* Maps a ZModel builtin type to expression type
106106
*/
107-
export function mapBuiltinTypeToExpressionType(
108-
type: BuiltinType | ExpressionType,
109-
): ExpressionType {
107+
export function mapBuiltinTypeToExpressionType(type: BuiltinType | ExpressionType): ExpressionType {
110108
switch (type) {
111109
case 'Any':
112110
case 'Boolean':
@@ -170,6 +168,7 @@ export function resolved<T extends AstNode>(ref: Reference<T>): T {
170168
export function getRecursiveBases(
171169
decl: DataModel | TypeDef,
172170
includeDelegate = true,
171+
documents?: LangiumDocuments,
173172
seen = new Set<DataModel | TypeDef>(),
174173
): (TypeDef | DataModel)[] {
175174
const result: (TypeDef | DataModel)[] = [];
@@ -179,16 +178,28 @@ export function getRecursiveBases(
179178
seen.add(decl);
180179
const bases = [...decl.mixins, ...(isDataModel(decl) && decl.baseModel ? [decl.baseModel] : [])];
181180
bases.forEach((base) => {
182-
// avoid using .ref since this function can be called before linking
183-
const baseDecl = decl.$container.declarations.find(
184-
(d): d is TypeDef | DataModel => (isTypeDef(d) || isDataModel(d)) && d.name === base.$refText,
185-
);
181+
let baseDecl: TypeDef | DataModel | undefined;
182+
183+
if (base.ref && (isTypeDef(base.ref) || isDataModel(base.ref))) {
184+
// base is already resolved
185+
baseDecl = base.ref;
186+
} else {
187+
// otherwise, search by name, in all imported documents if provided
188+
const declarations = documents
189+
? getAllDeclarationsIncludingImports(documents, decl.$container)
190+
: decl.$container.declarations;
191+
192+
baseDecl = declarations.find(
193+
(d): d is TypeDef | DataModel => (isTypeDef(d) || isDataModel(d)) && d.name === base.$refText,
194+
);
195+
}
196+
186197
if (baseDecl) {
187198
if (!includeDelegate && isDelegateModel(baseDecl)) {
188199
return;
189200
}
190201
result.push(baseDecl);
191-
result.push(...getRecursiveBases(baseDecl, includeDelegate, seen));
202+
result.push(...getRecursiveBases(baseDecl, includeDelegate, documents, seen));
192203
}
193204
});
194205
return result;

packages/language/src/zmodel-scope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export class ZModelScopeComputation extends DefaultScopeComputation {
8080
super.processNode(node, document, scopes);
8181
if (isDataModel(node) || isTypeDef(node)) {
8282
// add base fields to the scope recursively
83-
const bases = getRecursiveBases(node);
83+
const bases = getRecursiveBases(node, true, this.services.shared.workspace.LangiumDocuments);
8484
for (const base of bases) {
8585
for (const field of base.fields) {
8686
scopes.add(node, this.descriptions.createDescription(field, this.nameProvider.getName(field)));

packages/language/test/mixin.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import { invariant } from '@zenstackhq/common-helpers';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import tmp from 'tmp';
15
import { describe, expect, it } from 'vitest';
2-
import { loadSchema, loadSchemaWithError } from './utils';
6+
import { loadDocument } from '../src';
37
import { DataModel, TypeDef } from '../src/ast';
8+
import { getAllFields } from '../src/utils';
9+
import { loadSchema, loadSchemaWithError } from './utils';
410

511
describe('Mixin Tests', () => {
612
it('supports model mixing types to Model', async () => {
@@ -136,4 +142,95 @@ describe('Mixin Tests', () => {
136142
'Type field cannot be a relation',
137143
);
138144
});
145+
146+
it('supports mixin fields from imported file', async () => {
147+
const { name } = tmp.dirSync();
148+
149+
fs.writeFileSync(
150+
path.join(name, 'base.zmodel'),
151+
`
152+
type Timestamped {
153+
createdAt DateTime @default(now())
154+
updatedAt DateTime @updatedAt
155+
}
156+
`,
157+
);
158+
159+
fs.writeFileSync(
160+
path.join(name, 'main.zmodel'),
161+
`
162+
import './base'
163+
164+
datasource db {
165+
provider = 'sqlite'
166+
url = 'file:./dev.db'
167+
}
168+
169+
model Post with Timestamped {
170+
id String @id
171+
title String
172+
}
173+
`,
174+
);
175+
176+
const model = await expectLoaded(path.join(name, 'main.zmodel'));
177+
const post = model.declarations.find((d) => d.name === 'Post') as DataModel;
178+
expect(post.mixins[0].ref?.name).toBe('Timestamped');
179+
180+
// Verify fields from imported mixin are accessible
181+
const allFields = getAllFields(post);
182+
expect(allFields.map((f) => f.name)).toContain('createdAt');
183+
expect(allFields.map((f) => f.name)).toContain('updatedAt');
184+
});
185+
186+
it('can reference imported mixin fields in policy rules', async () => {
187+
const { name } = tmp.dirSync();
188+
189+
fs.writeFileSync(
190+
path.join(name, 'base.zmodel'),
191+
`
192+
type Owned {
193+
ownerId String
194+
}
195+
`,
196+
);
197+
198+
fs.writeFileSync(
199+
path.join(name, 'main.zmodel'),
200+
`
201+
import './base'
202+
203+
datasource db {
204+
provider = 'sqlite'
205+
url = 'file:./dev.db'
206+
}
207+
208+
model User {
209+
id String @id
210+
@@auth()
211+
}
212+
213+
model Post with Owned {
214+
id String @id
215+
216+
@@allow('update', auth().id == ownerId)
217+
}
218+
`,
219+
);
220+
221+
// If this loads without "Could not resolve reference" error, the fix works
222+
const model = await expectLoaded(path.join(name, 'main.zmodel'));
223+
expect(model).toBeDefined();
224+
});
225+
226+
async function expectLoaded(file: string) {
227+
const pluginDocs = [path.resolve(__dirname, '../../plugins/policy/plugin.zmodel')];
228+
const result = await loadDocument(file, pluginDocs);
229+
if (!result.success) {
230+
console.error('Errors:', result.errors);
231+
throw new Error(`Failed to load document from ${file}`);
232+
}
233+
invariant(result.success);
234+
return result.model;
235+
}
139236
});

packages/orm/src/client/crud/dialects/base-dialect.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -489,26 +489,41 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
489489
continue;
490490
}
491491

492-
const value = this.transformInput(_value, fieldType, !!fieldDef.array);
492+
invariant(fieldDef.array, 'Field must be an array type to build array filter');
493+
const value = this.transformInput(_value, fieldType, true);
494+
495+
let receiver = fieldRef;
496+
if (isEnum(this.schema, fieldType)) {
497+
// cast enum array to `text[]` for type compatibility
498+
receiver = this.eb.cast(fieldRef, sql.raw('text[]'));
499+
}
500+
501+
const buildArray = (value: unknown) => {
502+
invariant(Array.isArray(value), 'Array filter value must be an array');
503+
return this.buildArrayValue(
504+
value.map((v) => this.eb.val(v)),
505+
fieldType,
506+
);
507+
};
493508

494509
switch (key) {
495510
case 'equals': {
496-
clauses.push(this.buildLiteralFilter(fieldRef, fieldType, this.eb.val(value)));
511+
clauses.push(this.eb(receiver, '=', buildArray(value)));
497512
break;
498513
}
499514

500515
case 'has': {
501-
clauses.push(this.eb(fieldRef, '@>', this.eb.val([value])));
516+
clauses.push(this.buildArrayContains(receiver, this.eb.val(value)));
502517
break;
503518
}
504519

505520
case 'hasEvery': {
506-
clauses.push(this.eb(fieldRef, '@>', this.eb.val(value)));
521+
clauses.push(this.buildArrayHasEvery(receiver, buildArray(value)));
507522
break;
508523
}
509524

510525
case 'hasSome': {
511-
clauses.push(this.eb(fieldRef, '&&', this.eb.val(value)));
526+
clauses.push(this.buildArrayHasSome(receiver, buildArray(value)));
512527
break;
513528
}
514529

@@ -1420,9 +1435,24 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
14201435
abstract buildArrayLength(array: Expression<unknown>): AliasableExpression<number>;
14211436

14221437
/**
1423-
* Builds an array literal SQL string for the given values.
1438+
* Builds an array value expression.
14241439
*/
1425-
abstract buildArrayLiteralSQL(values: unknown[]): AliasableExpression<unknown>;
1440+
abstract buildArrayValue(values: Expression<unknown>[], elemType: string): AliasableExpression<unknown>;
1441+
1442+
/**
1443+
* Builds an expression that checks if an array contains a single value.
1444+
*/
1445+
abstract buildArrayContains(field: Expression<unknown>, value: Expression<unknown>): AliasableExpression<SqlBool>;
1446+
1447+
/**
1448+
* Builds an expression that checks if an array contains all values from another array.
1449+
*/
1450+
abstract buildArrayHasEvery(field: Expression<unknown>, values: Expression<unknown>): AliasableExpression<SqlBool>;
1451+
1452+
/**
1453+
* Builds an expression that checks if an array overlaps with another array.
1454+
*/
1455+
abstract buildArrayHasSome(field: Expression<unknown>, values: Expression<unknown>): AliasableExpression<SqlBool>;
14261456

14271457
/**
14281458
* Casts the given expression to an integer type.
@@ -1439,11 +1469,6 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
14391469
*/
14401470
abstract trimTextQuotes<T extends Expression<string>>(expression: T): T;
14411471

1442-
/**
1443-
* Gets the SQL column type for the given field definition.
1444-
*/
1445-
abstract getFieldSqlType(fieldDef: FieldDef): string;
1446-
14471472
/*
14481473
* Gets the string casing behavior for the dialect.
14491474
*/

packages/orm/src/client/crud/dialects/mysql.ts

Lines changed: 26 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@ import Decimal from 'decimal.js';
33
import type { AliasableExpression, TableExpression } from 'kysely';
44
import {
55
expressionBuilder,
6+
ExpressionWrapper,
67
sql,
8+
ValueListNode,
79
type Expression,
8-
type ExpressionWrapper,
910
type SelectQueryBuilder,
1011
type SqlBool,
1112
} from 'kysely';
1213
import { match } from 'ts-pattern';
1314
import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types';
1415
import type { BuiltinType, FieldDef, SchemaDef } from '../../../schema';
1516
import type { SortOrder } from '../../crud-types';
16-
import { createInternalError, createInvalidInputError, createNotSupportedError } from '../../errors';
17+
import { createInvalidInputError, createNotSupportedError } from '../../errors';
1718
import type { ClientOptions } from '../../options';
1819
import { isTypeDef } from '../../query-utils';
1920
import { LateralJoinDialectBase } from './lateral-join-dialect-base';
@@ -223,8 +224,29 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale
223224
return this.eb.fn('JSON_LENGTH', [array]);
224225
}
225226

226-
override buildArrayLiteralSQL(_values: unknown[]): AliasableExpression<number> {
227-
throw new Error('MySQL does not support array literals');
227+
override buildArrayValue(values: Expression<unknown>[], _elemType: string): AliasableExpression<unknown> {
228+
return new ExpressionWrapper(ValueListNode.create(values.map((v) => v.toOperationNode())));
229+
}
230+
231+
override buildArrayContains(
232+
_field: Expression<unknown>,
233+
_value: Expression<unknown>,
234+
): AliasableExpression<SqlBool> {
235+
throw createNotSupportedError('MySQL does not support native array operations');
236+
}
237+
238+
override buildArrayHasEvery(
239+
_field: Expression<unknown>,
240+
_values: Expression<unknown>,
241+
): AliasableExpression<SqlBool> {
242+
throw createNotSupportedError('MySQL does not support native array operations');
243+
}
244+
245+
override buildArrayHasSome(
246+
_field: Expression<unknown>,
247+
_values: Expression<unknown>,
248+
): AliasableExpression<SqlBool> {
249+
throw createNotSupportedError('MySQL does not support native array operations');
228250
}
229251

230252
protected override buildJsonEqualityFilter(
@@ -288,40 +310,6 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale
288310
);
289311
}
290312

291-
override getFieldSqlType(fieldDef: FieldDef) {
292-
// TODO: respect `@db.x` attributes
293-
if (fieldDef.relation) {
294-
throw createInternalError('Cannot get SQL type of a relation field');
295-
}
296-
297-
let result: string;
298-
299-
if (this.schema.enums?.[fieldDef.type]) {
300-
// enums are treated as text/varchar
301-
result = 'varchar(255)';
302-
} else {
303-
result = match(fieldDef.type)
304-
.with('String', () => 'varchar(255)')
305-
.with('Boolean', () => 'tinyint(1)') // MySQL uses tinyint(1) for boolean
306-
.with('Int', () => 'int')
307-
.with('BigInt', () => 'bigint')
308-
.with('Float', () => 'double')
309-
.with('Decimal', () => 'decimal')
310-
.with('DateTime', () => 'datetime')
311-
.with('Bytes', () => 'blob')
312-
.with('Json', () => 'json')
313-
// fallback to text
314-
.otherwise(() => 'text');
315-
}
316-
317-
if (fieldDef.array) {
318-
// MySQL stores arrays as JSON
319-
result = 'json';
320-
}
321-
322-
return result;
323-
}
324-
325313
override getStringCasingBehavior() {
326314
// MySQL LIKE is case-insensitive by default (depends on collation), no ILIKE support
327315
return { supportsILike: false, likeCaseSensitive: false };

0 commit comments

Comments
 (0)