Skip to content

Commit b797c23

Browse files
committed
[release] Add Go, Dart, and Rust schema generators
Three new generators alongside the existing Zod/JSON/Pydantic ones, each mapping the shared attribute model to idiomatic output: - Go: structs with json tags ($id/$createdAt/$updatedAt), *T+omitempty for optional, []T arrays, []string for one/manyToMany relationships. - Dart: classes with nullable (?) optionals, const constructor, and a fromJson factory (DateTime.parse, List<String> mapping). - Rust: serde structs (#[derive(Serialize, Deserialize)]) with rename for $-prefixed + camelCase keys, Option<T>+skip_serializing_if, Vec<T>. Wired into SchemaGenerator.parseFormats (new tokens go|golang, dart, rust|rs; "all" now emits every format) and generateSchemas, so regenerate_schema and the generate_* path can target them. Datetime maps to an ISO string in Go/Rust to avoid forcing time/chrono deps.
1 parent a2465e6 commit b797c23

6 files changed

Lines changed: 517 additions & 4 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import type { AppwriteConfig, Attribute } from "appwrite-utils";
4+
import { MessageFormatter } from "./messageFormatter.js";
5+
6+
export class DartModelGenerator {
7+
constructor(private config: AppwriteConfig, private appwriteFolderPath: string) {}
8+
9+
public generate(options: { baseOutputDirectory: string; verbose?: boolean }): void {
10+
const { baseOutputDirectory, verbose = false } = options;
11+
if (!fs.existsSync(baseOutputDirectory)) {
12+
fs.mkdirSync(baseOutputDirectory, { recursive: true });
13+
}
14+
15+
const collections = this.config.collections || [];
16+
for (const coll of collections) {
17+
const fileName = `${this.toSnake(coll.name)}.dart`;
18+
const filePath = path.join(baseOutputDirectory, fileName);
19+
const code = this.generateModel(coll.name, coll.attributes || []);
20+
fs.writeFileSync(filePath, code, { encoding: "utf-8" });
21+
if (verbose) {
22+
MessageFormatter.success(`Dart model written to ${filePath}`, { prefix: "Schema" });
23+
}
24+
}
25+
}
26+
27+
private generateModel(name: string, attributes: Attribute[]): string {
28+
const className = this.toPascal(name);
29+
30+
interface FieldInfo {
31+
dartType: string;
32+
fieldName: string;
33+
appwriteKey: string;
34+
required: boolean;
35+
isDateTime: boolean;
36+
isStringList: boolean;
37+
comment?: string;
38+
}
39+
40+
const baseFields: FieldInfo[] = [
41+
{ dartType: "String", fieldName: "id", appwriteKey: "$id", required: true, isDateTime: false, isStringList: false },
42+
{ dartType: "String", fieldName: "createdAt", appwriteKey: "$createdAt", required: true, isDateTime: false, isStringList: false },
43+
{ dartType: "String", fieldName: "updatedAt", appwriteKey: "$updatedAt", required: true, isDateTime: false, isStringList: false },
44+
];
45+
46+
const attrFields: FieldInfo[] = [];
47+
for (const attr of attributes) {
48+
if (!attr || !(attr as any).key) continue;
49+
const required = !!(attr as any).required;
50+
const dartType = this.mapAttributeToDartType(attr);
51+
const key = String((attr as any).key);
52+
const t = String((attr as any).type || "").toLowerCase();
53+
const els = Array.isArray((attr as any).elements) ? (attr as any).elements : [];
54+
const comment = t === "enum" && els.length > 0 ? `// allowed: ${els.join(", ")}` : undefined;
55+
attrFields.push({
56+
dartType,
57+
fieldName: this.toCamel(key),
58+
appwriteKey: key,
59+
required,
60+
isDateTime: t === "datetime",
61+
isStringList: this.isStringListType(dartType),
62+
comment,
63+
});
64+
}
65+
66+
const allFields = [...baseFields, ...attrFields];
67+
68+
// Field declarations
69+
const fieldLines: string[] = [];
70+
for (const f of allFields) {
71+
if (f.comment) fieldLines.push(` ${f.comment}`);
72+
fieldLines.push(` final ${f.dartType} ${f.fieldName};`);
73+
}
74+
75+
// Constructor params: required (non-null) use `required this.x`, nullable use `this.x`
76+
const ctorParams = allFields
77+
.map((f) => ` ${f.required ? "required " : ""}this.${f.fieldName},`)
78+
.join("\n");
79+
80+
// fromJson mappings
81+
const fromJsonLines = allFields
82+
.map((f) => ` ${f.fieldName}: ${this.fromJsonExpression(f)},`)
83+
.join("\n");
84+
85+
return (
86+
`class ${className} {\n` +
87+
`${fieldLines.join("\n")}\n\n` +
88+
` const ${className}({\n` +
89+
`${ctorParams}\n` +
90+
` });\n\n` +
91+
` factory ${className}.fromJson(Map<String, dynamic> json) {\n` +
92+
` return ${className}(\n` +
93+
`${fromJsonLines}\n` +
94+
` );\n` +
95+
` }\n` +
96+
`}\n`
97+
);
98+
}
99+
100+
private fromJsonExpression(f: {
101+
dartType: string;
102+
appwriteKey: string;
103+
required: boolean;
104+
isDateTime: boolean;
105+
isStringList: boolean;
106+
}): string {
107+
const accessor = `json['${f.appwriteKey}']`;
108+
if (f.isDateTime) {
109+
if (f.required) {
110+
return `DateTime.parse(${accessor})`;
111+
}
112+
return `${accessor} != null ? DateTime.parse(${accessor}) : null`;
113+
}
114+
if (f.isStringList) {
115+
const mapped = `(${accessor} as List?)?.map((e) => e as String).toList()`;
116+
if (f.required) {
117+
return `${mapped} ?? <String>[]`;
118+
}
119+
return mapped;
120+
}
121+
return `${accessor} as ${f.dartType}`;
122+
}
123+
124+
private isStringListType(dartType: string): boolean {
125+
return dartType === "List<String>" || dartType === "List<String>?";
126+
}
127+
128+
private mapAttributeToDartType(attr: Attribute): string {
129+
const t = String((attr as any).type || "").toLowerCase();
130+
const isArray = !!(attr as any).array;
131+
let base: string;
132+
switch (t) {
133+
case "string":
134+
case "email":
135+
case "ip":
136+
case "url":
137+
base = "String";
138+
break;
139+
case "integer":
140+
base = "int";
141+
break;
142+
case "double":
143+
case "float":
144+
base = "double";
145+
break;
146+
case "boolean":
147+
base = "bool";
148+
break;
149+
case "datetime":
150+
base = "DateTime";
151+
break;
152+
case "enum":
153+
base = "String";
154+
break;
155+
case "relationship": {
156+
const relType = (attr as any).relationType || "";
157+
base = relType === "oneToMany" || relType === "manyToMany" ? "List<String>" : "String";
158+
break;
159+
}
160+
default:
161+
base = "String";
162+
break;
163+
}
164+
if (isArray && t !== "relationship") {
165+
base = `List<${base}>`;
166+
}
167+
const required = !!(attr as any).required;
168+
if (!required) {
169+
base = `${base}?`;
170+
}
171+
return base;
172+
}
173+
174+
private toSnake(s: string): string {
175+
return s
176+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
177+
.replace(/[^a-zA-Z0-9]+/g, "_")
178+
.replace(/_+/g, "_")
179+
.replace(/^_|_$/g, "")
180+
.toLowerCase();
181+
}
182+
183+
private toPascal(s: string): string {
184+
return s
185+
.replace(/[^a-zA-Z0-9]+/g, " ")
186+
.split(" ")
187+
.filter(Boolean)
188+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
189+
.join("");
190+
}
191+
192+
private toCamel(s: string): string {
193+
const pascal = this.toPascal(s);
194+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
195+
}
196+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import type { AppwriteConfig, Attribute } from "appwrite-utils";
4+
import { MessageFormatter } from "./messageFormatter.js";
5+
6+
export class GoModelGenerator {
7+
constructor(private config: AppwriteConfig, private appwriteFolderPath: string) {}
8+
9+
public generate(options: { baseOutputDirectory: string; verbose?: boolean }): void {
10+
const { baseOutputDirectory, verbose = false } = options;
11+
const goDir = baseOutputDirectory;
12+
if (!fs.existsSync(goDir)) fs.mkdirSync(goDir, { recursive: true });
13+
14+
const collections = this.config.collections || [];
15+
for (const coll of collections) {
16+
const fileName = `${this.toSnake(coll.name)}.go`;
17+
const filePath = path.join(goDir, fileName);
18+
const code = this.generateStruct(coll.name, coll.attributes || []);
19+
fs.writeFileSync(filePath, code, { encoding: "utf-8" });
20+
if (verbose) MessageFormatter.success(`Go struct written to ${filePath}`, { prefix: "Schema" });
21+
}
22+
}
23+
24+
private generateStruct(name: string, attributes: Attribute[]): string {
25+
const pascal = this.toPascal(name);
26+
27+
const lines: string[] = [];
28+
// File doc comment noting datetime handling convention.
29+
lines.push(`// Code generated by appwrite-utils. DO NOT EDIT.`);
30+
lines.push(`// Appwrite datetime fields are represented as ISO 8601 strings.`);
31+
lines.push(`// (time.Time as ISO string) to avoid forcing a time import.`);
32+
lines.push(``);
33+
lines.push(`package models`);
34+
lines.push(``);
35+
lines.push(`type ${pascal} struct {`);
36+
37+
// Base Appwrite system fields.
38+
lines.push("\tID string `json:\"$id\"`");
39+
lines.push("\tCreatedAt string `json:\"$createdAt\"`");
40+
lines.push("\tUpdatedAt string `json:\"$updatedAt\"`");
41+
42+
for (const attr of attributes) {
43+
if (!attr || !(attr as any).key) continue;
44+
const key = String((attr as any).key);
45+
const required = !!(attr as any).required;
46+
47+
const enumComment = this.enumComment(attr);
48+
if (enumComment) lines.push(`\t${enumComment}`);
49+
50+
const goType = this.mapAttributeToGoType(attr);
51+
const fieldName = this.toPascalField(key);
52+
const fieldType = required ? goType : `*${goType}`;
53+
const jsonTag = required ? `json:"${key}"` : `json:"${key},omitempty"`;
54+
lines.push(`\t${fieldName} ${fieldType} \`${jsonTag}\``);
55+
}
56+
57+
lines.push(`}`);
58+
lines.push(``);
59+
return lines.join("\n");
60+
}
61+
62+
private enumComment(attr: Attribute): string | null {
63+
const t = String((attr as any).type || "").toLowerCase();
64+
if (t !== "enum") return null;
65+
const els = Array.isArray((attr as any).elements) ? (attr as any).elements : [];
66+
if (els.length === 0) return null;
67+
return `// allowed: ${els.join(", ")}`;
68+
}
69+
70+
private mapAttributeToGoType(attr: Attribute): string {
71+
const t = String((attr as any).type || "").toLowerCase();
72+
const isArray = !!(attr as any).array;
73+
let base: string;
74+
switch (t) {
75+
case "string":
76+
case "email":
77+
case "ip":
78+
case "url":
79+
base = "string";
80+
break;
81+
case "integer":
82+
base = "int64";
83+
break;
84+
case "double":
85+
case "float":
86+
base = "float64";
87+
break;
88+
case "boolean":
89+
base = "bool";
90+
break;
91+
case "datetime":
92+
// time.Time as ISO string; kept as string to avoid forcing a time import.
93+
base = "string";
94+
break;
95+
case "enum":
96+
// Go has no literal unions; allowed values are emitted as a line comment.
97+
base = "string";
98+
break;
99+
case "relationship": {
100+
const relType = (attr as any).relationType || "";
101+
base = relType === "oneToMany" || relType === "manyToMany" ? "[]string" : "string";
102+
break;
103+
}
104+
default:
105+
base = "string";
106+
break;
107+
}
108+
if (isArray && t !== "relationship") {
109+
base = `[]${base}`;
110+
}
111+
return base;
112+
}
113+
114+
private toSnake(s: string): string {
115+
return s
116+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
117+
.replace(/[^a-zA-Z0-9]+/g, "_")
118+
.replace(/_+/g, "_")
119+
.replace(/^_|_$/g, "")
120+
.toLowerCase();
121+
}
122+
123+
private toPascal(s: string): string {
124+
return s
125+
.replace(/[^a-zA-Z0-9]+/g, " ")
126+
.split(" ")
127+
.filter(Boolean)
128+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
129+
.join("");
130+
}
131+
132+
private toPascalField(s: string): string {
133+
return s
134+
.replace(/[^a-zA-Z0-9]+/g, " ")
135+
.split(" ")
136+
.filter(Boolean)
137+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
138+
.join("");
139+
}
140+
}

packages/appwrite-utils-helpers/src/schemas/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ export * from "./attributeMapper.js";
22
export * from "./schemaGenerator.js";
33
export * from "./jsonSchemaGenerator.js";
44
export * from "./pydanticModelGenerator.js";
5+
export * from "./goModelGenerator.js";
6+
export * from "./dartModelGenerator.js";
7+
export * from "./rustModelGenerator.js";
58
export * from "./constantsGenerator.js";
69
export * from "./relationshipExtractor.js";
710
// messageFormatter.js is exported from shared/index.js - removed to avoid duplicate exports

0 commit comments

Comments
 (0)