1mod skeletons;
7mod sql_templates;
8
9use std::{
10 fmt, fs,
11 path::{Path, PathBuf},
12 str::FromStr,
13};
14
15use anyhow::{Context, Result};
16use tracing::info;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Language {
22 Python,
24 TypeScript,
26 Rust,
28 Java,
30 Kotlin,
32 Go,
34 CSharp,
36 Swift,
38 Scala,
40 Php,
42}
43
44impl fmt::Display for Language {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 match self {
47 Self::Python => write!(f, "python"),
48 Self::TypeScript => write!(f, "typescript"),
49 Self::Rust => write!(f, "rust"),
50 Self::Java => write!(f, "java"),
51 Self::Kotlin => write!(f, "kotlin"),
52 Self::Go => write!(f, "go"),
53 Self::CSharp => write!(f, "csharp"),
54 Self::Swift => write!(f, "swift"),
55 Self::Scala => write!(f, "scala"),
56 Self::Php => write!(f, "php"),
57 }
58 }
59}
60
61impl Language {
62 pub fn from_extension(ext: &str) -> Option<Self> {
64 match ext {
65 "py" => Some(Self::Python),
66 "ts" | "tsx" => Some(Self::TypeScript),
67 "rs" => Some(Self::Rust),
68 "java" => Some(Self::Java),
69 "kt" | "kts" => Some(Self::Kotlin),
70 "go" => Some(Self::Go),
71 "cs" => Some(Self::CSharp),
72 "swift" => Some(Self::Swift),
73 "scala" | "sc" => Some(Self::Scala),
74 "php" => Some(Self::Php),
75 _ => None,
76 }
77 }
78}
79
80impl FromStr for Language {
81 type Err = String;
82
83 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
84 match s.to_lowercase().as_str() {
85 "python" | "py" => Ok(Self::Python),
86 "typescript" | "ts" => Ok(Self::TypeScript),
87 "rust" | "rs" => Ok(Self::Rust),
88 "java" | "jav" => Ok(Self::Java),
89 "kotlin" | "kt" => Ok(Self::Kotlin),
90 "go" | "golang" => Ok(Self::Go),
91 "csharp" | "c#" | "cs" => Ok(Self::CSharp),
92 "swift" => Ok(Self::Swift),
93 "scala" | "sc" => Ok(Self::Scala),
94 "php" => Ok(Self::Php),
95 other => Err(format!(
96 "Unknown language: {other}. Choose: python, typescript, rust, java, kotlin, go, csharp, swift, scala, php"
97 )),
98 }
99 }
100}
101
102pub(super) const ENTITIES: [&str; 4] = ["author", "post", "comment", "tag"];
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum Database {
115 Postgres,
117}
118
119impl fmt::Display for Database {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 Self::Postgres => write!(f, "postgres"),
123 }
124 }
125}
126
127impl FromStr for Database {
128 type Err = String;
129
130 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
131 match s.to_lowercase().as_str() {
132 "postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
133 removed @ ("mysql" | "sqlite" | "sqlserver" | "mssql") => Err(format!(
134 "FraiseQL is PostgreSQL-only: {removed} support was removed in v2.15.0 \
135 (see docs/database-compatibility.md). Use --database postgres."
136 )),
137 other => Err(format!("Unknown database: {other}. Choose: postgres")),
138 }
139 }
140}
141
142impl Database {
144 const fn toml_target(self) -> &'static str {
145 match self {
146 Self::Postgres => "postgresql",
147 }
148 }
149
150 fn default_url(self, project_name: &str) -> String {
151 match self {
152 Self::Postgres => format!("postgresql://localhost/{project_name}"),
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum ProjectSize {
161 Xs,
163 S,
165 M,
167}
168
169impl FromStr for ProjectSize {
170 type Err = String;
171
172 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
173 match s.to_lowercase().as_str() {
174 "xs" => Ok(Self::Xs),
175 "s" => Ok(Self::S),
176 "m" => Ok(Self::M),
177 other => Err(format!("Unknown size: {other}. Choose: xs, s, m")),
178 }
179 }
180}
181
182pub struct InitConfig {
184 pub project_name: String,
186 pub language: Language,
188 pub database: Database,
190 pub size: ProjectSize,
192 pub no_git: bool,
194}
195
196pub fn run(config: &InitConfig) -> Result<()> {
203 let project_dir = PathBuf::from(&config.project_name);
204
205 if project_dir.exists() {
206 anyhow::bail!(
207 "Directory '{}' already exists. Choose a different name or remove it first.",
208 config.project_name
209 );
210 }
211
212 info!("Creating project: {}", config.project_name);
213 println!("Creating FraiseQL project: {}", config.project_name);
214
215 fs::create_dir_all(&project_dir)
217 .context(format!("Failed to create directory: {}", config.project_name))?;
218
219 create_gitignore(&project_dir)?;
221
222 create_toml_config(&project_dir, config)?;
224
225 create_schema_json(&project_dir)?;
227
228 let sql_files = create_db_structure(&project_dir, config)?;
231
232 skeletons::create_authoring_skeleton(&project_dir, config)?;
234
235 if !config.no_git {
237 skeletons::init_git(&project_dir)?;
238 }
239
240 println!();
241 println!("Project created at ./{}", config.project_name);
242 println!();
243 println!("Set DATABASE_URL to your PostgreSQL connection URL first, e.g.:");
244 println!(
245 " export DATABASE_URL=postgres://user:pass@localhost:5432/{}",
246 config.project_name
247 );
248 println!();
249 println!("Next steps:");
252 println!(" cd {}", config.project_name);
253 println!(" fraiseql setup");
254 let file_args = sql_files.iter().map(|f| format!("-f {f}")).collect::<Vec<_>>().join(" ");
255 println!(" psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 {file_args}");
256 println!(" fraiseql compile schema.json");
257 println!(" fraiseql query '{{ posts {{ title }} }}'");
258 if !config.no_git {
259 println!(" git add -A && git commit -m \"Initial FraiseQL project\"");
260 }
261 println!();
262
263 Ok(())
264}
265
266fn create_gitignore(project_dir: &Path) -> Result<()> {
267 let content = "\
268# FraiseQL compiled output
269schema.compiled.json
270
271# Rust
272target/
273
274# Python
275__pycache__/
276*.pyc
277.venv/
278
279# TypeScript / Node
280node_modules/
281dist/
282
283# IDE
284.idea/
285.vscode/
286*.swp
287*.swo
288
289# OS
290.DS_Store
291Thumbs.db
292
293# Environment
294.env
295.env.local
296";
297 fs::write(project_dir.join(".gitignore"), content).context("Failed to create .gitignore")?;
298 info!("Created .gitignore");
299 Ok(())
300}
301
302fn create_toml_config(project_dir: &Path, config: &InitConfig) -> Result<()> {
303 let db_url = config.database.default_url(&config.project_name);
304 let db_target = config.database.toml_target();
305
306 let content = format!(
307 r#"[project]
308name = "{name}"
309version = "0.1.0"
310description = "A FraiseQL project"
311database_target = "{db_target}"
312
313[fraiseql]
314schema_file = "schema.json"
315output_file = "schema.compiled.json"
316
317[fraiseql.security.rate_limiting]
318enabled = true
319
320[fraiseql.security.audit_logging]
321enabled = true
322
323# Database connection URL — set via DATABASE_URL environment variable at runtime
324# {db_url}
325"#,
326 name = config.project_name,
327 );
328
329 fs::write(project_dir.join("fraiseql.toml"), content)
330 .context("Failed to create fraiseql.toml")?;
331 info!("Created fraiseql.toml");
332 Ok(())
333}
334
335fn create_schema_json(project_dir: &Path) -> Result<()> {
336 let schema = serde_json::json!({
339 "version": "2.0.0",
340 "types": [
341 {
342 "name": "Author",
343 "description": "Blog author",
344 "fields": [
345 { "name": "pk", "type": "Int", "nullable": false, "description": "Internal primary key" },
346 { "name": "id", "type": "ID", "nullable": false, "description": "Public UUID" },
347 { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
348 { "name": "name", "type": "String", "nullable": false },
349 { "name": "email", "type": "String", "nullable": false },
350 { "name": "bio", "type": "String", "nullable": true },
351 { "name": "createdAt", "type": "DateTime", "nullable": false },
352 { "name": "updatedAt", "type": "DateTime", "nullable": false }
353 ]
354 },
355 {
356 "name": "Post",
357 "description": "Blog post",
358 "fields": [
359 { "name": "pk", "type": "Int", "nullable": false },
360 { "name": "id", "type": "ID", "nullable": false },
361 { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
362 { "name": "title", "type": "String", "nullable": false },
363 { "name": "body", "type": "String", "nullable": false },
364 { "name": "published", "type": "Boolean", "nullable": false },
365 { "name": "authorId", "type": "ID", "nullable": false },
366 { "name": "createdAt", "type": "DateTime", "nullable": false },
367 { "name": "updatedAt", "type": "DateTime", "nullable": false }
368 ]
369 },
370 {
371 "name": "Comment",
372 "description": "Comment on a blog post",
373 "fields": [
374 { "name": "pk", "type": "Int", "nullable": false },
375 { "name": "id", "type": "ID", "nullable": false },
376 { "name": "body", "type": "String", "nullable": false },
377 { "name": "authorName", "type": "String", "nullable": false },
378 { "name": "postId", "type": "ID", "nullable": false },
379 { "name": "createdAt", "type": "DateTime", "nullable": false }
380 ]
381 },
382 {
383 "name": "Tag",
384 "description": "Categorization tag for posts",
385 "fields": [
386 { "name": "pk", "type": "Int", "nullable": false },
387 { "name": "id", "type": "ID", "nullable": false },
388 { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
389 { "name": "name", "type": "String", "nullable": false }
390 ]
391 }
392 ],
393 "queries": [
394 {
395 "name": "posts",
396 "return_type": "Post",
397 "returns_list": true,
398 "sql_source": "v_post",
399 "description": "List all published posts"
400 },
401 {
402 "name": "post",
403 "return_type": "Post",
404 "returns_list": false,
405 "nullable": true,
406 "sql_source": "v_post",
407 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
408 },
409 {
410 "name": "authors",
411 "return_type": "Author",
412 "returns_list": true,
413 "sql_source": "v_author"
414 },
415 {
416 "name": "author",
417 "return_type": "Author",
418 "returns_list": false,
419 "nullable": true,
420 "sql_source": "v_author",
421 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
422 },
423 {
424 "name": "tags",
425 "return_type": "Tag",
426 "returns_list": true,
427 "sql_source": "v_tag"
428 }
429 ],
430 "mutations": [
431 {
432 "name": "createAuthor",
433 "return_type": "Author",
434 "sql_source": "fn_author_create",
435 "operation": "CREATE",
436 "description": "Create a blog author",
437 "arguments": [
438 { "name": "identifier", "type": "String", "nullable": false },
439 { "name": "name", "type": "String", "nullable": false },
440 { "name": "email", "type": "String", "nullable": false },
441 { "name": "bio", "type": "String", "nullable": true }
442 ]
443 },
444 {
445 "name": "deleteAuthor",
446 "return_type": "Author",
447 "sql_source": "fn_author_delete",
448 "operation": "DELETE",
449 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
450 },
451 {
452 "name": "createPost",
453 "return_type": "Post",
454 "sql_source": "fn_post_create",
455 "operation": "CREATE",
456 "description": "Create a blog post (unpublished)",
457 "arguments": [
458 { "name": "identifier", "type": "String", "nullable": false },
459 { "name": "title", "type": "String", "nullable": false },
460 { "name": "body", "type": "String", "nullable": false },
461 { "name": "authorId", "type": "ID", "nullable": false }
462 ]
463 },
464 {
465 "name": "publishPost",
466 "return_type": "Post",
467 "sql_source": "fn_post_publish",
468 "operation": "UPDATE",
469 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
470 },
471 {
472 "name": "deletePost",
473 "return_type": "Post",
474 "sql_source": "fn_post_delete",
475 "operation": "DELETE",
476 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
477 },
478 {
479 "name": "createComment",
480 "return_type": "Comment",
481 "sql_source": "fn_comment_create",
482 "operation": "CREATE",
483 "arguments": [
484 { "name": "body", "type": "String", "nullable": false },
485 { "name": "authorName", "type": "String", "nullable": false },
486 { "name": "postId", "type": "ID", "nullable": false }
487 ]
488 },
489 {
490 "name": "deleteComment",
491 "return_type": "Comment",
492 "sql_source": "fn_comment_delete",
493 "operation": "DELETE",
494 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
495 },
496 {
497 "name": "createTag",
498 "return_type": "Tag",
499 "sql_source": "fn_tag_create",
500 "operation": "CREATE",
501 "arguments": [
502 { "name": "identifier", "type": "String", "nullable": false },
503 { "name": "name", "type": "String", "nullable": false }
504 ]
505 },
506 {
507 "name": "deleteTag",
508 "return_type": "Tag",
509 "sql_source": "fn_tag_delete",
510 "operation": "DELETE",
511 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
512 }
513 ],
514 "enums": [],
515 "input_types": [],
516 "interfaces": [],
517 "unions": [],
518 "subscriptions": []
519 });
520
521 let content = serde_json::to_string_pretty(&schema).context("Failed to serialize schema")?;
522 fs::write(project_dir.join("schema.json"), content).context("Failed to create schema.json")?;
523 info!("Created schema.json");
524 Ok(())
525}
526
527fn create_db_structure(project_dir: &Path, config: &InitConfig) -> Result<Vec<String>> {
531 match config.size {
532 ProjectSize::Xs => create_db_xs(project_dir),
533 ProjectSize::S => create_db_s(project_dir),
534 ProjectSize::M => create_db_m(project_dir),
535 }
536}
537
538fn create_db_xs(project_dir: &Path) -> Result<Vec<String>> {
539 let db_dir = project_dir.join("db").join("0_schema");
540 fs::create_dir_all(&db_dir).context("Failed to create db/0_schema")?;
541
542 let content = sql_templates::generate_single_schema_sql();
543 fs::write(db_dir.join("schema.sql"), content).context("Failed to create schema.sql")?;
544 info!("Created db/0_schema/schema.sql (xs layout)");
545 Ok(vec!["db/0_schema/schema.sql".to_string()])
546}
547
548fn create_db_s(project_dir: &Path) -> Result<Vec<String>> {
549 let schema_dir = project_dir.join("db").join("0_schema");
550 let write_dir = schema_dir.join("01_write");
551 let read_dir = schema_dir.join("02_read");
552 let functions_dir = schema_dir.join("03_functions");
553
554 fs::create_dir_all(&write_dir).context("Failed to create 01_write")?;
555 fs::create_dir_all(&read_dir).context("Failed to create 02_read")?;
556 fs::create_dir_all(&functions_dir).context("Failed to create 03_functions")?;
557
558 let mut written = [Vec::new(), Vec::new(), Vec::new()];
559 for (i, entity) in ENTITIES.iter().enumerate() {
560 let n = i + 1;
561 let (table_sql, view_sql, fn_sql) = sql_templates::generate_blog_entity_sql(entity);
562 let table_rel = format!("db/0_schema/01_write/01{n}_tb_{entity}.sql");
563 fs::write(write_dir.join(format!("01{n}_tb_{entity}.sql")), table_sql)
564 .context(format!("Failed to create tb_{entity}.sql"))?;
565 written[0].push(table_rel);
566 let view_rel = format!("db/0_schema/02_read/02{n}_v_{entity}.sql");
567 fs::write(read_dir.join(format!("02{n}_v_{entity}.sql")), view_sql)
568 .context(format!("Failed to create v_{entity}.sql"))?;
569 written[1].push(view_rel);
570 let fn_rel = format!("db/0_schema/03_functions/03{n}_fn_{entity}_crud.sql");
571 fs::write(functions_dir.join(format!("03{n}_fn_{entity}_crud.sql")), fn_sql)
572 .context(format!("Failed to create fn_{entity}_crud.sql"))?;
573 written[2].push(fn_rel);
574 }
575
576 info!("Created db/0_schema/ (s layout)");
577 Ok(written.into_iter().flatten().collect())
578}
579
580fn create_db_m(project_dir: &Path) -> Result<Vec<String>> {
581 let schema_dir = project_dir.join("db").join("0_schema");
582
583 let mut written = [Vec::new(), Vec::new(), Vec::new()];
584 for entity in &ENTITIES {
585 let write_dir = schema_dir.join("01_write").join(entity);
586 let read_dir = schema_dir.join("02_read").join(entity);
587 let functions_dir = schema_dir.join("03_functions").join(entity);
588
589 fs::create_dir_all(&write_dir).context(format!("Failed to create 01_write/{entity}"))?;
590 fs::create_dir_all(&read_dir).context(format!("Failed to create 02_read/{entity}"))?;
591 fs::create_dir_all(&functions_dir)
592 .context(format!("Failed to create 03_functions/{entity}"))?;
593
594 let (table_sql, view_sql, fn_sql) = sql_templates::generate_blog_entity_sql(entity);
595 fs::write(write_dir.join(format!("tb_{entity}.sql")), table_sql)
596 .context(format!("Failed to create tb_{entity}.sql"))?;
597 written[0].push(format!("db/0_schema/01_write/{entity}/tb_{entity}.sql"));
598 fs::write(read_dir.join(format!("v_{entity}.sql")), view_sql)
599 .context(format!("Failed to create v_{entity}.sql"))?;
600 written[1].push(format!("db/0_schema/02_read/{entity}/v_{entity}.sql"));
601 fs::write(functions_dir.join(format!("fn_{entity}_crud.sql")), fn_sql)
602 .context(format!("Failed to create fn_{entity}_crud.sql"))?;
603 written[2].push(format!("db/0_schema/03_functions/{entity}/fn_{entity}_crud.sql"));
604 }
605
606 info!("Created db/0_schema/ (m layout)");
607 Ok(written.into_iter().flatten().collect())
608}
609
610#[cfg(test)]
611mod tests;