Skip to content

Add schema support #973

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
1 change: 1 addition & 0 deletions packages/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
*/

export { validate } from './validator';
export { buildRuleset } from './rulesets';
116 changes: 116 additions & 0 deletions packages/schema/src/rulesets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright 2025 The Yorkie Authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { CharStreams, CommonTokenStream } from 'antlr4ts';
import { ParseTreeWalker } from 'antlr4ts/tree';
import {
PrimitiveTypeContext,
PropertyNameContext,
TypeAliasDeclarationContext,
YorkieSchemaParser,
} from '../antlr/YorkieSchemaParser';
import { YorkieSchemaLexer } from '../antlr/YorkieSchemaLexer';
import { YorkieSchemaListener } from '../antlr/YorkieSchemaListener';

/**
* `Rule` represents a rule for a field in the schema.
*/
export type Rule = StringRule | ObjectRule | ArrayRule;
export type RuleType = 'string' | 'object' | 'array';

export type RuleBase = {
path: string;
type: RuleType;
};

export type StringRule = {
type: 'string';
} & RuleBase;

export type ObjectRule = {
type: 'object';
properties: { [key: string]: RuleBase };
} & RuleBase;

export type ArrayRule = {
type: 'array';
} & RuleBase;

/**
* `RulesetBuilder` is a visitor that builds a ruleset from the given schema.
*/
export class RulesetBuilder implements YorkieSchemaListener {
private currentPath: Array<string> = ['$'];
private ruleMap: Map<string, Rule> = new Map();

/**
* `enterTypeAliasDeclaration` is called when entering a type alias declaration.
*/
enterTypeAliasDeclaration(ctx: TypeAliasDeclarationContext) {
const typeName = ctx.Identifier().text;
if (typeName === 'Document') {
this.currentPath = ['$'];
}
}

/**
* `enterPropertyName` is called when entering a property name.
*/
enterPropertyName(ctx: PropertyNameContext) {
const propName = ctx.Identifier()!.text;
this.currentPath.push(propName);
}

/**
* `enterPrimitiveType` is called when entering a primitive type.
*/
enterPrimitiveType(ctx: PrimitiveTypeContext) {
const type = ctx.text;
const path = this.buildPath();
const rule = {
path,
type,
} as Rule;

this.ruleMap.set(path, rule);
this.currentPath.pop();
}

private buildPath(): string {
return this.currentPath.join('.');
}

/**
* `build` returns the built ruleset.
*/
build(): Map<string, Rule> {
return this.ruleMap;
}
}

/**
* `buildRuleset` builds a ruleset from the given schema string.
*/
export function buildRuleset(schema: string): Map<string, Rule> {
const stream = CharStreams.fromString(schema);
const lexer = new YorkieSchemaLexer(stream);
const tokens = new CommonTokenStream(lexer);
const parser = new YorkieSchemaParser(tokens);
const tree = parser.document();
const builder = new RulesetBuilder();
ParseTreeWalker.DEFAULT.walk(builder as any, tree);
return builder.build();
}
75 changes: 75 additions & 0 deletions packages/schema/test/ruleset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2025 The Yorkie Authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, it, expect } from 'vitest';
import { buildRuleset } from '../src/rulesets';

describe('RulesetBuilder', () => {
it('should create rules for simple document', () => {
const schema = `
type Document = {
name: string;
age: number;
};
`;

const ruleset = buildRuleset(schema);
expect(ruleset.get('$.name')!.type).to.eq('string');
expect(ruleset.get('$.age')!.type).to.eq('number');
});

it('should handle nested objects', () => {
const schema = `
type Document = {
user: User;
};

type User = {
name: string;
address: Address;
};

type Address = {
street: string;
city: string;
};
`;

const ruleset = buildRuleset(schema);
expect(ruleset.get('$.user.name')!.type).to.eq('string');
expect(ruleset.get('$.user.address.street')!.type).to.eq('string');
expect(ruleset.get('$.user.address.city')!.type).to.eq('string');
});

// TODO(hackerwins): Implement array type handling.
it.todo('should handle array types', () => {
const schema = `
type Document = {
todos: Array<Todo>;
};

type Todo = {
id: string;
text: string;
};
`;

const ruleset = buildRuleset(schema);
expect(ruleset.get('$.todos')!.type).to.eq('array');
expect(ruleset.get('$.todos[*].id')!.type).to.be('string');
expect(ruleset.get('$.todos[*].text')!.type).to.be('string');
});
});
1 change: 1 addition & 0 deletions packages/sdk/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ <h4 class="title">

await client.attach(doc, {
initialPresence: { color: getRandomColor() },
schema: 'test@1',
});

doc.update((root) => {
Expand Down
52 changes: 46 additions & 6 deletions packages/sdk/src/api/yorkie/v1/resources.proto
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ message ChangePack {
TimeTicket min_synced_ticket = 5;
bool is_removed = 6;
VersionVector version_vector = 7;
repeated Rule rules = 8;
}

message Change {
Expand Down Expand Up @@ -286,8 +287,9 @@ message TreePos {

message User {
string id = 1;
string username = 2;
google.protobuf.Timestamp created_at = 3;
string auth_provider = 2;
string username = 3;
google.protobuf.Timestamp created_at = 4;
}

message Project {
Expand All @@ -297,26 +299,50 @@ message Project {
string secret_key = 4;
string auth_webhook_url = 5;
repeated string auth_webhook_methods = 6;
string client_deactivate_threshold = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
string event_webhook_url = 7;
repeated string event_webhook_events = 8;
string client_deactivate_threshold = 9;
int32 max_subscribers_per_document = 10;
int32 max_attachments_per_document = 11;
repeated string allowed_origins = 14;
google.protobuf.Timestamp created_at = 12;
google.protobuf.Timestamp updated_at = 13;
}

message MetricPoint {
int64 timestamp = 1;
int32 value = 2;
}

message UpdatableProjectFields {
message AuthWebhookMethods {
repeated string methods = 1;
}

message EventWebhookEvents {
repeated string events = 1;
}

message AllowedOrigins {
repeated string origins = 1;
}

google.protobuf.StringValue name = 1;
google.protobuf.StringValue auth_webhook_url = 2;
AuthWebhookMethods auth_webhook_methods = 3;
google.protobuf.StringValue client_deactivate_threshold = 4;
google.protobuf.StringValue event_webhook_url = 4;
EventWebhookEvents event_webhook_events = 5;
google.protobuf.StringValue client_deactivate_threshold = 6;
google.protobuf.Int32Value max_subscribers_per_document = 7;
google.protobuf.Int32Value max_attachments_per_document = 8;
AllowedOrigins allowed_origins = 9;
}

message DocumentSummary {
string id = 1;
string key = 2;
string snapshot = 3;
int32 attached_clients = 7;
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp accessed_at = 5;
google.protobuf.Timestamp updated_at = 6;
Expand Down Expand Up @@ -388,3 +414,17 @@ message DocEvent {
string publisher = 2;
DocEventBody body = 3;
}

message Schema {
string id = 1;
string name = 2;
int32 version = 3;
string body = 4;
repeated Rule rules = 5;
google.protobuf.Timestamp created_at = 6;
}

message Rule {
string path = 1;
string type = 2;
}
Loading