diff --git a/deepwell/migrations/20220906103252_deepwell.sql b/deepwell/migrations/20220906103252_deepwell.sql index b919c0c14e..e449df7c6a 100644 --- a/deepwell/migrations/20220906103252_deepwell.sql +++ b/deepwell/migrations/20220906103252_deepwell.sql @@ -170,6 +170,7 @@ CREATE TABLE page ( deleted_at TIMESTAMP WITH TIME ZONE, from_wikidot BOOLEAN NOT NULL DEFAULT false, site_id BIGINT NOT NULL REFERENCES site(site_id), + latest_revision_id BIGINT, -- nullable to avoid an initial page_revision dependency cycle page_category_id BIGINT NOT NULL REFERENCES page_category(category_id), slug TEXT NOT NULL, discussion_thread_id BIGINT, -- TODO: add REFERENCES to forum threads @@ -269,6 +270,10 @@ CREATE TABLE page_revision ( UNIQUE (page_id, site_id, revision_number) ); +-- Add foreign key constraint for latest_revision_id +ALTER TABLE page ADD CONSTRAINT page_revision_revision_id_fk + FOREIGN KEY (latest_revision_id) REFERENCES page_revision(revision_id); + -- -- Page metadata -- diff --git a/deepwell/scripts/generate-models.sh b/deepwell/scripts/generate-models.sh index 4a15a6b718..929e6e6bb6 100755 --- a/deepwell/scripts/generate-models.sh +++ b/deepwell/scripts/generate-models.sh @@ -10,7 +10,7 @@ cd "${0%/*}/.." # Generate models sea-orm-cli generate entity \ --verbose \ - -database-url postgres://wikijump:wikijump@localhost/wikijump \ - -output-dir src/models \ + --database-url postgres://wikijump:wikijump@localhost/wikijump \ + --output-dir src/models \ --date-time-crate time \ --with-serde both diff --git a/deepwell/src/models/page.rs b/deepwell/src/models/page.rs index 242ecd1221..1d36ac7414 100644 --- a/deepwell/src/models/page.rs +++ b/deepwell/src/models/page.rs @@ -15,6 +15,7 @@ pub struct Model { pub deleted_at: Option, pub from_wikidot: bool, pub site_id: i64, + pub latest_revision_id: Option, pub page_category_id: i64, #[sea_orm(column_type = "Text")] pub slug: String, @@ -31,6 +32,14 @@ pub enum Relation { on_delete = "NoAction" )] PageCategory, + #[sea_orm( + belongs_to = "super::page_revision::Entity", + from = "Column::LatestRevisionId", + to = "super::page_revision::Column::RevisionId", + on_update = "NoAction", + on_delete = "NoAction" + )] + PageRevision, #[sea_orm( belongs_to = "super::site::Entity", from = "Column::SiteId", @@ -39,8 +48,6 @@ pub enum Relation { on_delete = "NoAction" )] Site, - #[sea_orm(has_many = "super::page_revision::Entity")] - PageRevision, #[sea_orm(has_many = "super::page_attribution::Entity")] PageAttribution, #[sea_orm(has_many = "super::page_lock::Entity")] diff --git a/deepwell/src/services/mod.rs b/deepwell/src/services/mod.rs index 99e64d5c17..6ac63795d2 100644 --- a/deepwell/src/services/mod.rs +++ b/deepwell/src/services/mod.rs @@ -65,6 +65,7 @@ pub mod link; pub mod mfa; pub mod outdate; pub mod page; +pub mod page_query; pub mod page_revision; pub mod parent; pub mod password; @@ -98,6 +99,7 @@ pub use self::link::LinkService; pub use self::mfa::MfaService; pub use self::outdate::OutdateService; pub use self::page::PageService; +pub use self::page_query::PageQueryService; pub use self::page_revision::PageRevisionService; pub use self::parent::ParentService; pub use self::password::PasswordService; diff --git a/deepwell/src/services/page/service.rs b/deepwell/src/services/page/service.rs index bbc87d4f5d..16965159b7 100644 --- a/deepwell/src/services/page/service.rs +++ b/deepwell/src/services/page/service.rs @@ -30,6 +30,7 @@ use crate::services::page_revision::{ use crate::services::{CategoryService, FilterService, PageRevisionService, TextService}; use crate::utils::{get_category_name, trim_default}; use crate::web::PageOrder; +use sea_orm::ActiveValue; use wikidot_normalize::normalize; #[derive(Debug)] @@ -79,7 +80,7 @@ impl PageService { slug: Set(slug.clone()), ..Default::default() }; - let page = model.insert(txn).await?; + let PageModel { page_id, .. } = model.insert(txn).await?; // Commit first revision let revision_input = CreateFirstPageRevision { @@ -94,12 +95,20 @@ impl PageService { let CreateFirstPageRevisionOutput { revision_id, parser_errors, - } = PageRevisionService::create_first(ctx, site_id, page.page_id, revision_input) + } = PageRevisionService::create_first(ctx, site_id, page_id, revision_input) .await?; + // Update latest revision + let model = page::ActiveModel { + page_id: Set(page_id), + latest_revision_id: Set(Some(revision_id)), + ..Default::default() + }; + model.update(txn).await?; + // Build and return Ok(CreatePageOutput { - page_id: page.page_id, + page_id, slug, revision_id, parser_errors, @@ -169,12 +178,18 @@ impl PageService { ) .await?; - // Set page updated_at column. + let latest_revision_id = match revision_output { + Some(ref output) => ActiveValue::Set(Some(output.revision_id)), + None => ActiveValue::NotSet, + }; + + // Set page updated_at and latest_revision_id columns. // // Previously this was conditional on whether a revision was actually created. // But since this rerenders regardless, we need to update the page row. let model = page::ActiveModel { page_id: Set(page_id), + latest_revision_id, updated_at: Set(Some(now())), ..Default::default() }; @@ -242,14 +257,21 @@ impl PageService { ) .await?; + let latest_revision_id = match revision_output { + Some(ref output) => ActiveValue::Set(Some(output.revision_id)), + None => ActiveValue::NotSet, + }; + // Update page after move. This changes: - // * slug -- New slug for the page - // * page_category_id -- In case the category also changed - // * updated_at -- This is updated every time a page is changed + // * slug -- New slug for the page + // * page_category_id -- In case the category also changed + // * latest_revision_id -- In case a new revision was created + // * updated_at -- This is updated every time a page is changed let model = page::ActiveModel { page_id: Set(page_id), slug: Set(new_slug.clone()), page_category_id: Set(category_id), + latest_revision_id, updated_at: Set(Some(now())), ..Default::default() }; @@ -310,6 +332,7 @@ impl PageService { // Set deletion flag let model = page::ActiveModel { page_id: Set(page_id), + latest_revision_id: Set(Some(output.revision_id)), deleted_at: Set(Some(now())), ..Default::default() }; @@ -379,6 +402,7 @@ impl PageService { let model = page::ActiveModel { page_id: Set(page_id), page_category_id: Set(category.category_id), + latest_revision_id: Set(Some(output.revision_id)), deleted_at: Set(None), ..Default::default() }; @@ -512,6 +536,21 @@ impl PageService { .await? }; + // Even in production, we want to assert that this invariant holds. + // + // We cannot set the column itself to NOT NULL because of cyclic update + // requirements. However when using PageService, at no point should a method + // quit with this value being null. + if let Some(ref page) = page { + assert!( + page.latest_revision_id.is_some(), + "Page ID {} (slug '{}', site ID {}) has a NULL latest_revision_id column!", + page.page_id, + page.slug, + page.site_id, + ); + } + Ok(page) } @@ -551,6 +590,49 @@ impl PageService { Ok(page) } + /// Gets all pages which match the given page references. + /// + /// The result list is not in the same order as the input, it + /// is up to the caller to order it if they wish. + pub async fn get_pages( + ctx: &ServiceContext<'_>, + site_id: i64, + references: &[Reference<'_>], + ) -> Result> { + tide::log::info!( + "Getting {} pages from references in site ID {}", + references.len(), + site_id, + ); + + let mut filter_ids = Vec::new(); + let mut filter_slugs = Vec::new(); + + for reference in references { + match reference { + Reference::Id(id) => filter_ids.push(*id), + Reference::Slug(slug) => filter_slugs.push(slug.as_ref()), + } + } + + let txn = ctx.transaction(); + let models = Page::find() + .filter( + Condition::all() + .add(page::Column::SiteId.eq(site_id)) + .add(page::Column::DeletedAt.is_null()) + .add( + Condition::any() + .add(page::Column::PageId.is_in(filter_ids)) + .add(page::Column::Slug.is_in(filter_slugs)), + ), + ) + .all(txn) + .await?; + + Ok(models) + } + /// Get all pages in a site, with potential conditions. /// /// The `category` argument: diff --git a/deepwell/src/services/page_query/mod.rs b/deepwell/src/services/page_query/mod.rs new file mode 100644 index 0000000000..80f194e706 --- /dev/null +++ b/deepwell/src/services/page_query/mod.rs @@ -0,0 +1,30 @@ +/* + * services/page_query/mod.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2023 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +mod prelude { + pub use super::super::prelude::*; + pub use super::structs::*; +} + +mod service; +mod structs; + +pub use self::service::PageQueryService; +pub use self::structs::*; diff --git a/deepwell/src/services/page_query/service.rs b/deepwell/src/services/page_query/service.rs new file mode 100644 index 0000000000..f686ef3f61 --- /dev/null +++ b/deepwell/src/services/page_query/service.rs @@ -0,0 +1,452 @@ +/* + * services/page_query/service.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2023 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +#![allow(dead_code, unused_variables)] // TEMP + +use super::prelude::*; +use crate::models::page::{self, Entity as Page}; +use crate::models::page_category::{self, Entity as PageCategory}; +use crate::models::page_connection::{self, Entity as PageConnection}; +use crate::models::page_parent::{self, Entity as PageParent}; +use crate::models::{page_revision, text}; +use crate::services::{PageService, ParentService}; +use sea_query::{Expr, Query}; +use void::Void; + +#[derive(Debug)] +pub struct PageQueryService; + +impl PageQueryService { + pub async fn execute( + ctx: &ServiceContext<'_>, + PageQuery { + current_page_id, + current_site_id, + queried_site_id, + page_type, + categories: + CategoriesSelector { + included_categories, + excluded_categories, + }, + tags: + TagCondition { + any_present: any_tags, + all_present: all_tags, + none_present: no_tags, + }, + page_parent, + contains_outgoing_links, + creation_date, + update_date, + author, + score, + votes, + offset, + range, + name, + slug, + data_form_fields, + order, + pagination, + variables, + }: PageQuery<'_>, + ) -> Result { + tide::log::info!("Building ListPages query from specification"); + + let txn = ctx.transaction(); + let mut condition = Condition::all(); + + // Site ID + // + // The site to query from. If not specified, then this is the current site. + let queried_site_id = queried_site_id.unwrap_or(current_site_id); + condition = condition.add(page::Column::SiteId.eq(queried_site_id)); + tide::log::debug!("Selecting pages from site ID: {queried_site_id}"); + + // Page Type + // TODO track https://github.com/SeaQL/sea-orm/issues/1746 + let hidden_condition = page::Column::Slug.starts_with("_"); + match page_type { + PageTypeSelector::Hidden => { + // Hidden pages are any which have slugs that start with '_'. + tide::log::debug!("Selecting page slugs starting with '_'"); + condition = condition.add(hidden_condition); + } + PageTypeSelector::Normal => { + // Normal pages are anything not in the above category. + tide::log::debug!("Selecting page slugs not starting with '_'"); + condition = condition.add(hidden_condition.not()); + } + PageTypeSelector::All => { + // If we're getting everything, then do nothing. + tide::log::debug!("Selecting all page slugs, normal or hidden"); + } + } + + // Categories (included and excluded) + macro_rules! cat_slugs { + ($list:expr) => { + $list.iter().map(|c| c.as_ref()) + }; + } + + let page_category_condition = match included_categories { + // If all categories are selected (using an asterisk or by only specifying excluded categories), + // then filter only by site_id and exclude the specified excluded categories. + IncludedCategories::All => { + tide::log::debug!("Selecting all categories with exclusions"); + + page::Column::PageCategoryId.in_subquery( + Query::select() + .column(page_category::Column::CategoryId) + .from(PageCategory) + .and_where(page_category::Column::SiteId.eq(queried_site_id)) + .and_where( + page_category::Column::Slug + .is_not_in(cat_slugs!(excluded_categories)), + ) + .to_owned(), + ) + } + + // If a specific list of categories is provided, filter by site_id, inclusion in the + // specified included categories, and exclude the specified excluded categories. + // + // NOTE: Exclusion can only have an effect in this query if it is *also* included. + // Although by definition this is the same as not including the category in the + // included categories to begin with, it is still accounted for to preserve + // backwards-compatibility with poorly-constructed ListPages modules. + IncludedCategories::List(included_categories) => { + tide::log::debug!("Selecting included categories only"); + + page::Column::PageCategoryId.in_subquery( + Query::select() + .column(page_category::Column::CategoryId) + .from(PageCategory) + .and_where(page_category::Column::SiteId.eq(queried_site_id)) + .and_where( + page_category::Column::Slug + .is_in(cat_slugs!(included_categories)), + ) + .and_where( + page_category::Column::Slug + .is_not_in(cat_slugs!(excluded_categories)), + ) + .to_owned(), + ) + } + }; + condition = condition.add(page_category_condition); + + // Page Parents + // + // Adds constraints based on the presence of parent pages. + + // Convenience macro to pull a list of page IDs which are parents + // of the current page. + // + // In the places where this is used, this could be implemented + // as a subquery, meaning: + // + // SELECT child_page_id FROM page_parent + // WHERE parent_page_id IN ( + // SELECT parent_page_id FROM page_parent + // WHERE child_page_id = $0 + // ) + // + // However looking at the query plan, this would be implemented + // as a self-JOIN, and involve a full sequential scan. So querying + // the list of parents ahead of time is faster. + macro_rules! get_parents { + () => { + ParentService::get_parents( + ctx, + current_site_id, + Reference::Id(current_page_id), + ) + .await? + .into_iter() + .map(|parent| parent.parent_page_id) + }; + } + + let page_parent_condition = match page_parent { + // Pages with no parents. + // This means that there should be no rows in `page_parent` + // where they are the child page. + PageParentSelector::NoParent => { + tide::log::debug!("Selecting pages with no parents"); + + page::Column::PageId.not_in_subquery( + Query::select() + .column(page_parent::Column::ChildPageId) + .from(PageParent) + .to_owned(), + ) + } + + // Pages which are siblings of the current page, + // i.e., they share parents in common with the current page. + PageParentSelector::SameParents => { + tide::log::debug!("Selecting pages are siblings under the given parents"); + + page::Column::PageId.in_subquery( + Query::select() + .column(page_parent::Column::ChildPageId) + .from(PageParent) + .and_where( + page_parent::Column::ParentPageId.is_in(get_parents!()), + ) + .to_owned(), + ) + } + + // Pages which are not siblings of the current page, + // i.e., they do not share any parents with the current page. + PageParentSelector::DifferentParents => { + tide::log::debug!( + "Selecting pages which are not siblings under the given parents", + ); + + let parents = ParentService::get_parents( + ctx, + current_site_id, + Reference::Id(current_page_id), + ) + .await? + .into_iter() + .map(|parent| parent.parent_page_id); + + page::Column::PageId.in_subquery( + Query::select() + .column(page_parent::Column::ChildPageId) + .from(PageParent) + .and_where( + page_parent::Column::ParentPageId.is_not_in(get_parents!()), + ) + .to_owned(), + ) + } + + // Pages which are children of the current page. + PageParentSelector::ChildOf => { + tide::log::debug!( + "Selecting pages which are children of the current page", + ); + + page::Column::PageId.in_subquery( + Query::select() + .column(page_parent::Column::ChildPageId) + .from(PageParent) + .and_where(page_parent::Column::ParentPageId.eq(current_page_id)) + .to_owned(), + ) + } + + // Pages with any of the specified parents. + // TODO: Possibly allow either *any* or *all* of specified parents + // rather than only any, in the future. + PageParentSelector::HasParents(parents) => { + tide::log::debug!( + "Selecting on pages which have one of the given as parents", + ); + + let parent_ids = PageService::get_pages(ctx, queried_site_id, parents) + .await? + .into_iter() + .map(|page| page.page_id); + + page::Column::PageId.in_subquery( + Query::select() + .column(page_parent::Column::ChildPageId) + .from(PageParent) + .and_where(page_parent::Column::ParentPageId.is_in(parent_ids)) + .to_owned(), + ) + } + }; + condition = condition.add(page_parent_condition); + + // Slug + if let Some(slug) = slug { + let slug = slug.as_ref(); + tide::log::debug!("Filtering based on slug {slug}"); + condition = condition.add(page::Column::Slug.eq(slug)); + } + + // Contains-link + // + // Selects pages that have an outgoing link (`from_page_id`) + // to a specified page (`to_page_id`). + condition = condition.add( + page::Column::PageId.in_subquery( + Query::select() + .column(page_connection::Column::FromPageId) + .from(PageConnection) + .and_where({ + let incoming_ids = PageService::get_pages( + ctx, + queried_site_id, + contains_outgoing_links, + ) + .await? + .into_iter() + .map(|page| page.page_id); + + page_connection::Column::ToPageId.is_in(incoming_ids) + }) + .to_owned(), + ), + ); + + // Tag filtering + // TODO requires joining with most recent revision + + // Build the final query + let mut query = Page::find().filter(condition); + + // Add necessary joins + macro_rules! join_revision { + () => { + query = query.join(JoinType::Join, page::Relation::PageRevision.def()); + }; + } + macro_rules! join_text { + () => { + query = query.join(JoinType::Join, page_revision::Relation::Text1.def()); + }; + } + // TODO other joins + + // Add on at the query-level (ORDER BY, LIMIT) + { + use sea_orm::query::Order; + use sea_query::{func::Func, SimpleExpr}; + + let OrderBySelector { + property, + ascending, + } = order.unwrap_or_default(); + + tide::log::debug!( + "Ordering ListPages using {:?} (ascending: {})", + property, + ascending, + ); + + let order = if ascending { Order::Asc } else { Order::Desc }; + + // TODO: implement missing properties. these require subqueries or joins or something + match property { + OrderProperty::PageSlug => { + // idk how to do this, we need to strip off the category part somehow + // PgExpr::matches? + tide::log::error!( + "Ordering by page slug (no category), not yet implemented", + ); + todo!() // TODO + } + OrderProperty::FullSlug => { + tide::log::debug!("Ordering by page slug (with category"); + query = query.order_by(page::Column::Slug, order); + } + OrderProperty::Title => { + tide::log::error!("Ordering by title, not yet implemented"); + join_revision!(); + query = query.order_by(page_revision::Column::Title, order); + } + OrderProperty::AltTitle => { + tide::log::error!("Ordering by alt title, not yet implemented"); + join_revision!(); + query = query.order_by(page_revision::Column::AltTitle, order); + } + OrderProperty::CreatedBy => { + tide::log::error!("Ordering by author, not yet implemented"); + todo!() // TODO + } + OrderProperty::CreatedAt => { + tide::log::debug!("Ordering by page creation timestamp"); + query = query.order_by(page::Column::CreatedAt, order); + } + OrderProperty::UpdatedAt => { + tide::log::debug!("Ordering by page last update timestamp"); + query = query.order_by(page::Column::UpdatedAt, order); + } + OrderProperty::Size => { + tide::log::error!("Ordering by page size, not yet implemented"); + join_revision!(); + join_text!(); + let col = Expr::col(text::Column::Contents); + let expr = SimpleExpr::FunctionCall(Func::char_length(col)); + query = query.order_by(expr, order); + } + OrderProperty::Score => { + tide::log::error!("Ordering by score, not yet implemented"); + todo!() // TODO + } + OrderProperty::Votes => { + tide::log::error!("Ordering by vote count, not yet implemented"); + todo!() // TODO + } + OrderProperty::Revisions => { + tide::log::error!("Ordering by revision count, not yet implemented"); + todo!() // TODO + } + OrderProperty::Comments => { + tide::log::error!("Ordering by comment count, not yet implemented"); + todo!() // TODO + } + OrderProperty::Random => { + tide::log::debug!("Ordering by random value"); + let expr = SimpleExpr::FunctionCall(Func::random()); + query = query.order_by(expr, order); + } + OrderProperty::DataFormFieldName => { + tide::log::error!("Ordering by data form field, not yet implemented"); + todo!() // TODO + } + }; + } + + if let Some(limit) = pagination.limit { + tide::log::debug!("Limiting ListPages to a maximum of {limit} pages total"); + query = query.limit(limit); + } + + // TODO pagination + // the "reverse" field means that, for each page, it is reversed. + // + // this does not affect the overall ORDER BY + // for instance, imagine we are selecting from the positive integers + // if the pagination limit is 5 and the order is ascending, but reverse = true, + // then this means we get pages like: + // + // 1. [ 4, 3, 2, 1, 0] + // 2. [ 9, 8, 7, 6, 5] + // 3. [14, 13, 12, 11, 10] + + // Execute it! + let result = query.all(txn).await?; + + // TODO implement query construction + todo!() + } +} diff --git a/deepwell/src/services/page_query/structs.rs b/deepwell/src/services/page_query/structs.rs new file mode 100644 index 0000000000..71daf0583d --- /dev/null +++ b/deepwell/src/services/page_query/structs.rs @@ -0,0 +1,296 @@ +/* + * services/page_query/structs.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2023 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +#![allow(dead_code)] // TEMP + +use super::prelude::*; +use crate::models::{ + page::Model as PageModel, page_parent::Model as PageParentModel, + page_revision::Model as PageRevisionModel, +}; +use crate::services::score::ScoreValue; +use std::borrow::Cow; +use time::OffsetDateTime; + +/// What kinds of pages (hidden or not) to select from. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum PageTypeSelector { + All, + Normal, + Hidden, +} + +pub type CategoryList<'a> = &'a [Cow<'a, str>]; +pub type TagList<'a> = &'a [Cow<'a, str>]; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum IncludedCategories<'a> { + All, + List(CategoryList<'a>), +} + +/// Which categories to select from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CategoriesSelector<'a> { + pub included_categories: IncludedCategories<'a>, + pub excluded_categories: CategoryList<'a>, +} + +/// What tag conditions to maintain during the search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TagCondition<'a> { + /// Represents an OR operator for the tags; page may contain any of these tags. + pub any_present: TagList<'a>, + + /// Represents the AND operator for the tags; page must contain all of these tags. + pub all_present: TagList<'a>, + + /// Represents the NOT operator for the tags; page must *not* contain any of these tags. + pub none_present: TagList<'a>, +} + +/// The relationship of the pages being queried to their parent/child pages. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PageParentSelector<'a> { + /// Pages which have no parent page. + NoParent, + + /// Pages which share any parent page(s) with the page making the query. + SameParents, + + /// Pages which do *not* share any parent page(s) with the page making the query. + DifferentParents, + + /// Pages which are children of the page making the query. + ChildOf, + + /// Pages which have specified parent pages. + HasParents(&'a [Reference<'a>]), +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum ComparisonOperation { + GreaterThan, + LessThan, + GreaterOrEqualThan, + LessOrEqualThan, + Equal, + NotEqual, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum DateTimeResolution { + Second, + Minute, + Hour, + Day, + Month, + Year, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum DateSelector { + /// A time span represented by a timestamp, the "resolution" of the time, and a comparison operator. + Span { + timestamp: OffsetDateTime, + resolution: DateTimeResolution, + comparison: ComparisonOperation, + }, + + /// A time span represented by a timestamp, from present to the time specified. + FromPresent { start: OffsetDateTime }, +} + +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct ScoreSelector { + pub score: ScoreValue, + pub comparison: ComparisonOperation, +} + +/// Range of pages to display, relative to the current page. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum RangeSelector { + /// Display only the current page. + Current, + + /// Display pages before the current page in queried results. + Before, + + /// Display pages after the current page in queried results. + After, + + /// Display all pages besides the current page. + Others, +} + +/// Selects all pages that have a data form with matching field-value pairs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataFormSelector<'a> { + pub field: Cow<'a, str>, + pub value: Cow<'a, str>, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum OrderProperty { + PageSlug, + FullSlug, + Title, + AltTitle, + CreatedBy, + CreatedAt, + UpdatedAt, + Size, + Score, + Votes, + Revisions, + Comments, + Random, + DataFormFieldName, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct OrderBySelector { + pub property: OrderProperty, + pub ascending: bool, +} + +impl Default for OrderBySelector { + fn default() -> Self { + OrderBySelector { + property: OrderProperty::CreatedAt, + ascending: false, + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct PaginationSelector { + pub limit: Option, + pub per_page: u8, + pub reversed: bool, +} + +impl Default for PaginationSelector { + fn default() -> PaginationSelector { + PaginationSelector { + limit: None, + per_page: 20, + reversed: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PageQueryVariables<'a> { + CreatedAt, + CreatedBy, + CreatedBySlug, + CreatedById, + CreatedByLinked, + UpdatedAt, + UpdatedBy, + UpdatedBySlug, + UpdatedById, + UpdatedByLinked, + CommentedAt, + CommentedBy, + CommentedBySlug, + CommentedById, + CommentedByLinked, + PageSlug, + Category, + FullSlug, + Title, + TitleLinked, + ParentNamed, + ParentCategory, + ParentSlug, + ParentTitle, + ParentTitleLinked, + Link, + Content, + ContentN(u64), + Preview, + PreviewN(u64), + Summary, + FirstParagraph, + Tags, + TagsLinked, + TagsLinkedURL(Cow<'a, str>), + HiddenTags, + HiddenTagsLinked, + HiddenTagsLinkedURL(Cow<'a, str>), + FormData(Cow<'a, str>), + FormRaw(Cow<'a, str>), + FormLabel(Cow<'a, str>), + FormHint(Cow<'a, str>), + Children, + Comments, + Size, + Score, + ScoreVotes, + ScorePercent, + Revisions, + Index, + Total, + Limit, + TotalOrLimit, + SiteTitle, + SiteName, + SiteDomain, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PageQuery<'a> { + pub current_page_id: i64, + pub current_site_id: i64, + pub queried_site_id: Option, + pub page_type: PageTypeSelector, + pub categories: CategoriesSelector<'a>, + pub tags: TagCondition<'a>, + pub page_parent: PageParentSelector<'a>, + pub contains_outgoing_links: &'a [Reference<'a>], + pub creation_date: DateSelector, + pub update_date: DateSelector, + pub author: &'a [Cow<'a, str>], + pub score: &'a [ScoreSelector], // 5-star rating selector + pub votes: &'a [ScoreSelector], // upvote/downvote rating selector + pub offset: u32, + pub range: RangeSelector, + pub name: Option>, + pub slug: Option>, + pub data_form_fields: &'a [DataFormSelector<'a>], + pub order: Option, + pub pagination: PaginationSelector, + pub variables: &'a [PageQueryVariables<'a>], +} + +#[derive(Serialize, Debug, PartialEq, Clone)] +pub struct PageQueryOutput<'a>(&'a [PageResult]); + +#[derive(Serialize, Debug, PartialEq, Clone)] +pub struct PageResult { + metadata: PageModel, + last_revision: PageRevisionModel, + // last_comment: TODO, + page_parents: Vec, + wikitext: String, + score: f32, +} diff --git a/deepwell/src/services/parent/service.rs b/deepwell/src/services/parent/service.rs index 47f32e8385..4b5d312176 100644 --- a/deepwell/src/services/parent/service.rs +++ b/deepwell/src/services/parent/service.rs @@ -141,6 +141,7 @@ impl ParentService { } /// Gets all relationships of the given type. + #[allow(dead_code)] // TODO pub async fn get_relationships( ctx: &ServiceContext<'_>, site_id: i64, @@ -148,20 +149,42 @@ impl ParentService { relationship_type: ParentalRelationshipType, ) -> Result> { let txn = ctx.transaction(); - let page = PageService::get(ctx, site_id, reference).await?; + let page_id = PageService::get_id(ctx, site_id, reference).await?; let column = match relationship_type { ParentalRelationshipType::Parent => page_parent::Column::ParentPageId, ParentalRelationshipType::Child => page_parent::Column::ChildPageId, }; let models = PageParent::find() - .filter(column.eq(page.page_id)) + .filter(column.eq(page_id)) .all(txn) .await?; Ok(models) } + /// Gets all children of the given page. + #[allow(dead_code)] // TODO + pub async fn get_children( + ctx: &ServiceContext<'_>, + site_id: i64, + reference: Reference<'_>, + ) -> Result> { + Self::get_relationships(ctx, site_id, reference, ParentalRelationshipType::Child) + .await + } + + /// Gets all parents of the given page. + #[allow(dead_code)] // TODO + pub async fn get_parents( + ctx: &ServiceContext<'_>, + site_id: i64, + reference: Reference<'_>, + ) -> Result> { + Self::get_relationships(ctx, site_id, reference, ParentalRelationshipType::Parent) + .await + } + /// Removes all parent relationships involving this page. /// /// Whether this page is a parent or a child, this method