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
319auth_start_max_requests = 100
320auth_start_window_secs = 60
321
322[fraiseql.security.audit_logging]
323enabled = true
324
325# Database connection URL — set via DATABASE_URL environment variable at runtime
326# {db_url}
327"#,
328 name = config.project_name,
329 );
330
331 fs::write(project_dir.join("fraiseql.toml"), content)
332 .context("Failed to create fraiseql.toml")?;
333 info!("Created fraiseql.toml");
334 Ok(())
335}
336
337fn create_schema_json(project_dir: &Path) -> Result<()> {
338 let schema = serde_json::json!({
341 "version": "2.0.0",
342 "types": [
343 {
344 "name": "Author",
345 "description": "Blog author",
346 "fields": [
347 { "name": "pk", "type": "Int", "nullable": false, "description": "Internal primary key" },
348 { "name": "id", "type": "ID", "nullable": false, "description": "Public UUID" },
349 { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
350 { "name": "name", "type": "String", "nullable": false },
351 { "name": "email", "type": "String", "nullable": false },
352 { "name": "bio", "type": "String", "nullable": true },
353 { "name": "createdAt", "type": "DateTime", "nullable": false },
354 { "name": "updatedAt", "type": "DateTime", "nullable": false }
355 ]
356 },
357 {
358 "name": "Post",
359 "description": "Blog post",
360 "fields": [
361 { "name": "pk", "type": "Int", "nullable": false },
362 { "name": "id", "type": "ID", "nullable": false },
363 { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
364 { "name": "title", "type": "String", "nullable": false },
365 { "name": "body", "type": "String", "nullable": false },
366 { "name": "published", "type": "Boolean", "nullable": false },
367 { "name": "authorId", "type": "ID", "nullable": false },
368 { "name": "createdAt", "type": "DateTime", "nullable": false },
369 { "name": "updatedAt", "type": "DateTime", "nullable": false }
370 ]
371 },
372 {
373 "name": "Comment",
374 "description": "Comment on a blog post",
375 "fields": [
376 { "name": "pk", "type": "Int", "nullable": false },
377 { "name": "id", "type": "ID", "nullable": false },
378 { "name": "body", "type": "String", "nullable": false },
379 { "name": "authorName", "type": "String", "nullable": false },
380 { "name": "postId", "type": "ID", "nullable": false },
381 { "name": "createdAt", "type": "DateTime", "nullable": false }
382 ]
383 },
384 {
385 "name": "Tag",
386 "description": "Categorization tag for posts",
387 "fields": [
388 { "name": "pk", "type": "Int", "nullable": false },
389 { "name": "id", "type": "ID", "nullable": false },
390 { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
391 { "name": "name", "type": "String", "nullable": false }
392 ]
393 }
394 ],
395 "queries": [
396 {
397 "name": "posts",
398 "return_type": "Post",
399 "returns_list": true,
400 "sql_source": "v_post",
401 "description": "List all published posts"
402 },
403 {
404 "name": "post",
405 "return_type": "Post",
406 "returns_list": false,
407 "nullable": true,
408 "sql_source": "v_post",
409 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
410 },
411 {
412 "name": "authors",
413 "return_type": "Author",
414 "returns_list": true,
415 "sql_source": "v_author"
416 },
417 {
418 "name": "author",
419 "return_type": "Author",
420 "returns_list": false,
421 "nullable": true,
422 "sql_source": "v_author",
423 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
424 },
425 {
426 "name": "tags",
427 "return_type": "Tag",
428 "returns_list": true,
429 "sql_source": "v_tag"
430 }
431 ],
432 "mutations": [
433 {
434 "name": "createAuthor",
435 "return_type": "Author",
436 "sql_source": "fn_author_create",
437 "operation": "CREATE",
438 "description": "Create a blog author",
439 "arguments": [
440 { "name": "identifier", "type": "String", "nullable": false },
441 { "name": "name", "type": "String", "nullable": false },
442 { "name": "email", "type": "String", "nullable": false },
443 { "name": "bio", "type": "String", "nullable": true }
444 ]
445 },
446 {
447 "name": "deleteAuthor",
448 "return_type": "Author",
449 "sql_source": "fn_author_delete",
450 "operation": "DELETE",
451 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
452 },
453 {
454 "name": "createPost",
455 "return_type": "Post",
456 "sql_source": "fn_post_create",
457 "operation": "CREATE",
458 "description": "Create a blog post (unpublished)",
459 "arguments": [
460 { "name": "identifier", "type": "String", "nullable": false },
461 { "name": "title", "type": "String", "nullable": false },
462 { "name": "body", "type": "String", "nullable": false },
463 { "name": "authorId", "type": "ID", "nullable": false }
464 ]
465 },
466 {
467 "name": "publishPost",
468 "return_type": "Post",
469 "sql_source": "fn_post_publish",
470 "operation": "UPDATE",
471 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
472 },
473 {
474 "name": "deletePost",
475 "return_type": "Post",
476 "sql_source": "fn_post_delete",
477 "operation": "DELETE",
478 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
479 },
480 {
481 "name": "createComment",
482 "return_type": "Comment",
483 "sql_source": "fn_comment_create",
484 "operation": "CREATE",
485 "arguments": [
486 { "name": "body", "type": "String", "nullable": false },
487 { "name": "authorName", "type": "String", "nullable": false },
488 { "name": "postId", "type": "ID", "nullable": false }
489 ]
490 },
491 {
492 "name": "deleteComment",
493 "return_type": "Comment",
494 "sql_source": "fn_comment_delete",
495 "operation": "DELETE",
496 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
497 },
498 {
499 "name": "createTag",
500 "return_type": "Tag",
501 "sql_source": "fn_tag_create",
502 "operation": "CREATE",
503 "arguments": [
504 { "name": "identifier", "type": "String", "nullable": false },
505 { "name": "name", "type": "String", "nullable": false }
506 ]
507 },
508 {
509 "name": "deleteTag",
510 "return_type": "Tag",
511 "sql_source": "fn_tag_delete",
512 "operation": "DELETE",
513 "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
514 }
515 ],
516 "enums": [],
517 "input_types": [],
518 "interfaces": [],
519 "unions": [],
520 "subscriptions": []
521 });
522
523 let content = serde_json::to_string_pretty(&schema).context("Failed to serialize schema")?;
524 fs::write(project_dir.join("schema.json"), content).context("Failed to create schema.json")?;
525 info!("Created schema.json");
526 Ok(())
527}
528
529fn create_db_structure(project_dir: &Path, config: &InitConfig) -> Result<Vec<String>> {
533 match config.size {
534 ProjectSize::Xs => create_db_xs(project_dir),
535 ProjectSize::S => create_db_s(project_dir),
536 ProjectSize::M => create_db_m(project_dir),
537 }
538}
539
540fn create_db_xs(project_dir: &Path) -> Result<Vec<String>> {
541 let db_dir = project_dir.join("db").join("0_schema");
542 fs::create_dir_all(&db_dir).context("Failed to create db/0_schema")?;
543
544 let content = sql_templates::generate_single_schema_sql();
545 fs::write(db_dir.join("schema.sql"), content).context("Failed to create schema.sql")?;
546 info!("Created db/0_schema/schema.sql (xs layout)");
547 Ok(vec!["db/0_schema/schema.sql".to_string()])
548}
549
550fn create_db_s(project_dir: &Path) -> Result<Vec<String>> {
551 let schema_dir = project_dir.join("db").join("0_schema");
552 let write_dir = schema_dir.join("01_write");
553 let read_dir = schema_dir.join("02_read");
554 let functions_dir = schema_dir.join("03_functions");
555
556 fs::create_dir_all(&write_dir).context("Failed to create 01_write")?;
557 fs::create_dir_all(&read_dir).context("Failed to create 02_read")?;
558 fs::create_dir_all(&functions_dir).context("Failed to create 03_functions")?;
559
560 let mut written = [Vec::new(), Vec::new(), Vec::new()];
561 for (i, entity) in ENTITIES.iter().enumerate() {
562 let n = i + 1;
563 let (table_sql, view_sql, fn_sql) = sql_templates::generate_blog_entity_sql(entity);
564 let table_rel = format!("db/0_schema/01_write/01{n}_tb_{entity}.sql");
565 fs::write(write_dir.join(format!("01{n}_tb_{entity}.sql")), table_sql)
566 .context(format!("Failed to create tb_{entity}.sql"))?;
567 written[0].push(table_rel);
568 let view_rel = format!("db/0_schema/02_read/02{n}_v_{entity}.sql");
569 fs::write(read_dir.join(format!("02{n}_v_{entity}.sql")), view_sql)
570 .context(format!("Failed to create v_{entity}.sql"))?;
571 written[1].push(view_rel);
572 let fn_rel = format!("db/0_schema/03_functions/03{n}_fn_{entity}_crud.sql");
573 fs::write(functions_dir.join(format!("03{n}_fn_{entity}_crud.sql")), fn_sql)
574 .context(format!("Failed to create fn_{entity}_crud.sql"))?;
575 written[2].push(fn_rel);
576 }
577
578 info!("Created db/0_schema/ (s layout)");
579 Ok(written.into_iter().flatten().collect())
580}
581
582fn create_db_m(project_dir: &Path) -> Result<Vec<String>> {
583 let schema_dir = project_dir.join("db").join("0_schema");
584
585 let mut written = [Vec::new(), Vec::new(), Vec::new()];
586 for entity in &ENTITIES {
587 let write_dir = schema_dir.join("01_write").join(entity);
588 let read_dir = schema_dir.join("02_read").join(entity);
589 let functions_dir = schema_dir.join("03_functions").join(entity);
590
591 fs::create_dir_all(&write_dir).context(format!("Failed to create 01_write/{entity}"))?;
592 fs::create_dir_all(&read_dir).context(format!("Failed to create 02_read/{entity}"))?;
593 fs::create_dir_all(&functions_dir)
594 .context(format!("Failed to create 03_functions/{entity}"))?;
595
596 let (table_sql, view_sql, fn_sql) = sql_templates::generate_blog_entity_sql(entity);
597 fs::write(write_dir.join(format!("tb_{entity}.sql")), table_sql)
598 .context(format!("Failed to create tb_{entity}.sql"))?;
599 written[0].push(format!("db/0_schema/01_write/{entity}/tb_{entity}.sql"));
600 fs::write(read_dir.join(format!("v_{entity}.sql")), view_sql)
601 .context(format!("Failed to create v_{entity}.sql"))?;
602 written[1].push(format!("db/0_schema/02_read/{entity}/v_{entity}.sql"));
603 fs::write(functions_dir.join(format!("fn_{entity}_crud.sql")), fn_sql)
604 .context(format!("Failed to create fn_{entity}_crud.sql"))?;
605 written[2].push(format!("db/0_schema/03_functions/{entity}/fn_{entity}_crud.sql"));
606 }
607
608 info!("Created db/0_schema/ (m layout)");
609 Ok(written.into_iter().flatten().collect())
610}
611
612#[cfg(test)]
613mod tests;